From 854ea74a8f3f521f98b8f1436c7d482351d94c87 Mon Sep 17 00:00:00 2001 From: Murtuza Zabuawala Date: Tue, 3 Feb 2026 15:52:12 +0530 Subject: [PATCH 1/4] Feat: Added a Statistics node --- .../servers/databases/schemas/__init__.py | 3 + .../databases/schemas/statistics/__init__.py | 918 ++++++++++++++++++ .../statistics/static/img/coll-statistics.svg | 1 + .../statistics/static/img/statistics.svg | 1 + .../statistics/static/js/statistics.js | 120 +++ .../statistics/static/js/statistics.ui.js | 275 ++++++ .../statistics/sql/16_plus/create.sql | 14 + .../statistics/sql/16_plus/properties.sql | 40 + .../statistics/sql/16_plus/update.sql | 31 + .../statistics/sql/17_plus/create.sql | 14 + .../statistics/sql/17_plus/properties.sql | 40 + .../statistics/sql/17_plus/update.sql | 31 + .../sql/default/backend_support.sql | 5 + .../statistics/sql/default/coll_stats.sql | 5 + .../statistics/sql/default/count.sql | 4 + .../statistics/sql/default/create.sql | 14 + .../statistics/sql/default/delete.sql | 12 + .../statistics/sql/default/get_oid.sql | 6 + .../statistics/sql/default/nodes.sql | 17 + .../statistics/sql/default/properties.sql | 40 + .../statistics/sql/default/stats.sql | 30 + .../statistics/sql/default/update.sql | 24 + .../schemas/statistics/tests/__init__.py | 0 .../statistics/tests/pg/14_plus/__init__.py | 0 .../schemas/statistics/tests/pg/__init__.py | 0 .../tests/statistics_test_data.json | 250 +++++ .../statistics/tests/test_statistics_add.py | 121 +++ .../tests/test_statistics_delete.py | 159 +++ .../tests/test_statistics_delete_multiple.py | 125 +++ .../statistics/tests/test_statistics_get.py | 150 +++ .../statistics/tests/test_statistics_put.py | 126 +++ .../schemas/statistics/tests/utils.py | 332 +++++++ web/webpack.config.js | 1 + web/webpack.shim.js | 1 + 34 files changed, 2910 insertions(+) create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/coll-statistics.svg create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/statistics.svg create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/coll_stats.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/count.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/nodes.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/pg/14_plus/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/pg/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/__init__.py index 6a0235a9394..2bde4638d03 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/__init__.py @@ -157,6 +157,9 @@ def register(self, app, options): from .sequences import blueprint as module self.submodules.append(module) + from .statistics import blueprint as module + self.submodules.append(module) + from .synonyms import blueprint as module self.submodules.append(module) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py new file mode 100644 index 00000000000..d0428b39476 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py @@ -0,0 +1,918 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements Statistics Node""" + +from functools import wraps +import json + +from flask import render_template, request, jsonify +from flask_babel import gettext as _ + +import pgadmin.browser.server_groups.servers.databases as database +from config import PG_DEFAULT_DRIVER +from pgadmin.browser.server_groups.servers.databases.schemas.utils import \ + SchemaChildModule +from pgadmin.browser.utils import PGChildNodeView +from pgadmin.tools.schema_diff.compare import SchemaDiffObjectCompare +from pgadmin.tools.schema_diff.node_registry import SchemaDiffRegistry +from pgadmin.utils.ajax import make_json_response, internal_server_error, \ + make_response as ajax_response, gone +from pgadmin.utils.driver import get_driver + + +class StatisticsModule(SchemaChildModule): + """ + class StatisticsModule(SchemaChildModule) + + A module class for Statistics node derived from + SchemaChildModule. + + Methods: + ------- + * __init__(*args, **kwargs) + - Method is used to initialize the StatisticsModule and its base module. + + * get_nodes(gid, sid, did, scid) + - Method is used to generate the browser collection node. + + * script_load() + - Load the module script for statistics, when any of the database node + is initialized. + + * node_inode() + - Method is overridden from its base class to make the node as leaf node. + + """ + + _NODE_TYPE = 'statistics' + _COLLECTION_LABEL = _("Statistics") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.min_ver = 140000 # Only current official supported versions + self.max_ver = None + + def get_nodes(self, gid, sid, did, scid): + """ + Generate the statistics node + """ + if self.has_nodes( + sid, + did, + scid=scid, + base_template_path=StatisticsView.BASE_TEMPLATE_PATH): + yield self.generate_browser_collection_node(scid) + + @property + def script_load(self): + """ + Load the module script for database, when any of the database node is + initialized. + """ + return database.DatabaseModule.node_type + + @property + def node_inode(self): + """ + Override this property to make the node a leaf node. + + Returns: False as this is the leaf node + """ + return False + + +blueprint = StatisticsModule(__name__) + + +class StatisticsView(PGChildNodeView, SchemaDiffObjectCompare): + """ + This class is responsible for generating routes for Extended Statistics + node. + + Methods: + ------- + * list() + - This function returns all statistics nodes within a schema. + + * nodes() + - This function returns all statistics nodes as tree nodes. + + * properties() + - This function shows the properties of a selected statistics node. + + * create() + - This function creates a new statistics object. + + * update() + - This function updates a statistics object. + + * delete() + - This function deletes a statistics object. + + * sql() + - This function returns the SQL for the selected statistics object. + + * msql() + - This function returns the modified SQL. + + * statistics() + - This function returns statistics information. + + * dependencies() + - This function returns the dependencies for the selected statistics. + + * dependents() + - This function returns the dependents for the selected statistics. + + """ + + node_type = blueprint.node_type + node_label = "Statistics" + node_icon = "icon-%s" % node_type + BASE_TEMPLATE_PATH = 'statistics/sql/#{0}#' + + parent_ids = [ + {'type': 'int', 'id': 'gid'}, + {'type': 'int', 'id': 'sid'}, + {'type': 'int', 'id': 'did'}, + {'type': 'int', 'id': 'scid'} + ] + ids = [ + {'type': 'int', 'id': 'stid'} + ] + + operations = dict({ + 'obj': [ + {'get': 'properties', 'delete': 'delete', 'put': 'update'}, + {'get': 'list', 'post': 'create', 'delete': 'delete'} + ], + 'delete': [{'delete': 'delete'}, {'delete': 'delete'}], + 'children': [{'get': 'children'}], + 'nodes': [{'get': 'nodes'}, {'get': 'nodes'}], + 'sql': [{'get': 'sql'}], + 'msql': [{'get': 'msql'}, {'get': 'msql'}], + 'stats': [{'get': 'statistics'}, {'get': 'statistics'}], + 'dependency': [{'get': 'dependencies'}], + 'dependent': [{'get': 'dependents'}] + }) + + keys_to_ignore = ['oid', 'oid-2', 'schemaoid', 'tableoid'] + + _PROPERTIES_SQL = 'properties.sql' + _NODES_SQL = 'nodes.sql' + _CREATE_SQL = 'create.sql' + _UPDATE_SQL = 'update.sql' + _DELETE_SQL = 'delete.sql' + _OID_SQL = 'get_oid.sql' + _STATS_SQL = 'stats.sql' + _COLL_STATS_SQL = 'coll_stats.sql' + + def check_precondition(action=None): + """ + This function will behave as a decorator which will check + database connection before running view, it will also attach + manager, conn & template_path properties to self + """ + + def wrap(f): + @wraps(f) + def wrapped(self, *args, **kwargs): + + driver = get_driver(PG_DEFAULT_DRIVER) + self.manager = driver.connection_manager(kwargs['sid']) + + if action and action in ["drop"]: + self.conn = self.manager.connection() + elif 'did' in kwargs: + self.conn = self.manager.connection(did=kwargs['did']) + else: + self.conn = self.manager.connection() + + self.datistemplate = False + if ( + self.manager.db_info is not None and + kwargs['did'] in self.manager.db_info and + 'datistemplate' in self.manager.db_info[kwargs['did']] + ): + self.datistemplate = self.manager.db_info[ + kwargs['did']]['datistemplate'] + + self.template_path = self.BASE_TEMPLATE_PATH.format( + self.manager.version + ) + self.qtIdent = driver.qtIdent + + return f(self, *args, **kwargs) + return wrapped + return wrap + + @check_precondition(action='list') + def list(self, gid, sid, did, scid): + """ + This function returns all statistics nodes within a schema. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + + Returns: + JSON of available statistics nodes + """ + SQL = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + scid=scid + ) + status, res = self.conn.execute_dict(SQL) + + if not status: + return internal_server_error(errormsg=res) + + return ajax_response( + response=res['rows'], + status=200 + ) + + @check_precondition(action='nodes') + def nodes(self, gid, sid, did, scid, stid=None): + """ + This function returns all statistics nodes as tree nodes. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID (optional) + + Returns: + JSON of available statistics nodes + """ + res = [] + + SQL = render_template( + "/".join([self.template_path, self._NODES_SQL]), + scid=scid, + stid=stid, + conn=self.conn + ) + status, rset = self.conn.execute_dict(SQL) + if not status: + return internal_server_error(errormsg=rset) + + if stid is not None: + if len(rset['rows']) == 0: + return gone(errormsg=self.not_found_error_msg()) + row = rset['rows'][0] + return make_json_response( + data=self.blueprint.generate_browser_node( + row['oid'], + scid, + row['name'], + icon=self.node_icon, + description=row['comment'] + ), + status=200 + ) + + for row in rset['rows']: + res.append( + self.blueprint.generate_browser_node( + row['oid'], + scid, + row['name'], + icon=self.node_icon, + description=row['comment'] + )) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition(action='properties') + def properties(self, gid, sid, did, scid, stid): + """ + This function shows the properties of the selected statistics node. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + + Returns: + JSON of statistics properties + """ + status, res = self._fetch_properties(scid, stid) + if not status: + return res + + return ajax_response( + response=res, + status=200 + ) + + def _fetch_properties(self, scid, stid): + """ + This function is used to fetch the properties of the specified object + + Args: + scid: Schema ID + stid: Statistics ID + + Returns: + Tuple of (status, result) + """ + sql = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), + scid=scid, stid=stid + ) + 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(self.not_found_error_msg()) + + res['rows'][0]['is_sys_obj'] = ( + res['rows'][0]['oid'] <= self._DATABASE_LAST_SYSTEM_OID or + self.datistemplate) + + # Convert stat_types from raw array to list of names + row = res['rows'][0] + stat_types = [] + if row.get('has_ndistinct'): + stat_types.append('ndistinct') + if row.get('has_dependencies'): + stat_types.append('dependencies') + if row.get('has_mcv'): + stat_types.append('mcv') + row['stat_types'] = stat_types + + # Ensure columns is an array (convert None to empty array) + if row.get('columns') is None: + row['columns'] = [] + + # Set has_expressions boolean based on expressions field + # expressions is pg_node_tree (bytea) - we just check if it exists + row['has_expressions'] = row.get('expressions') is not None + + # Remove expressions from display (it's internal PostgreSQL format) + row.pop('expressions', None) + + # Ensure stattarget has a default value if None + if row.get('stattarget') is None: + row['stattarget'] = -1 + + # Format computed statistics values for display + # These come from pg_statistic_ext_data and are already in text format + if row.get('ndistinct_values'): + row['ndistinct_values'] = str(row['ndistinct_values']) + if row.get('dependencies_values'): + row['dependencies_values'] = str(row['dependencies_values']) + + return True, res['rows'][0] + + @check_precondition(action='create') + def create(self, gid, sid, did, scid): + """ + This function creates a new statistics object. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + + Returns: + JSON response with the new statistics node + """ + data = request.form if request.form else json.loads( + request.data + ) + + # Name is optional in PostgreSQL 16+ (version 160000) + required_args = ['schema', 'table', 'stat_types'] + if self.manager.version < 160000: + required_args.insert(0, 'name') + + for arg in required_args: + is_arg = arg in data + is_str_arg = isinstance(data.get(arg), str) + is_arg_empty_str = is_str_arg and not data.get(arg).strip() + if not is_arg or is_arg_empty_str: + return make_json_response( + status=400, + success=0, + errormsg=_( + "Could not find the required parameter ({})." + ).format(arg) + ) + + # Parse expression_list into expressions array if provided + has_columns = 'columns' in data and len(data.get('columns', [])) > 0 + has_expression_list = 'expression_list' in data and data.get( + 'expression_list', '').strip() + + # Convert expression_list (comma-separated) to expressions array + if has_expression_list: + expr_list = data.get('expression_list', '').strip() + # Split by comma and strip whitespace from each expression + data['expressions'] = [ + {'expression': expr.strip()} + for expr in expr_list.split(',') + if expr.strip() + ] + has_expressions = len(data['expressions']) > 0 + else: + data['expressions'] = [] + has_expressions = False + + if not has_columns and not has_expressions: + return make_json_response( + status=400, + success=0, + errormsg=_( + "Either columns or expressions must be specified" + ) + ) + + # Validate minimum 2 items for multi-column statistics + # (not for single expression) + if has_columns and not has_expressions and \ + len(data.get('columns', [])) < 2: + return make_json_response( + status=400, + success=0, + errormsg=_( + "At least 2 columns must be specified " + "for multi-column statistics." + ) + ) + + # Validate at least 1 stat_type + if len(data.get('stat_types', [])) < 1: + return make_json_response( + status=400, + success=0, + errormsg=_( + "At least 1 statistics type must be selected." + ) + ) + + try: + # Generate CREATE STATISTICS SQL + sql = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=data, conn=self.conn + ) + except Exception as e: + return internal_server_error(errormsg=str(e)) + + status, msg = self.conn.execute_scalar(sql) + if not status: + return internal_server_error(errormsg=msg) + + # Get OID of newly created statistics + # For PostgreSQL 16+, if name is not provided, it's auto-generated + if data.get('name'): + sql = render_template( + "/".join([self.template_path, self._OID_SQL]), + name=data['name'], + schema=data['schema'], + conn=self.conn + ) + sql = sql.strip('\n').strip(' ') + + status, rset = self.conn.execute_2darray(sql) + if not status: + return internal_server_error(errormsg=rset) + + row = rset['rows'][0] + return jsonify( + node=self.blueprint.generate_browser_node( + row['oid'], + scid, + data['name'], + icon=self.node_icon + ) + ) + else: + # For auto-generated names in PG 16+, we need to query for the + # latest statistics + # This is a limitation - we'll return success but + # can't return the node + return make_json_response( + success=1, + info=_( + "Statistics created successfully with auto-generated name" + ) + ) + + @check_precondition(action='delete') + def delete(self, gid, sid, did, scid, stid=None, only_sql=False): + """ + This function deletes the statistics object. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + only_sql: Return SQL only if True + + Returns: + JSON response + """ + if stid is None: + data = request.form if request.form else json.loads( + request.data + ) + else: + data = {'ids': [stid]} + + # Check if CASCADE operation + cascade = self._check_cascade_operation() + + try: + for stid in data['ids']: + # Fetch statistics details first + sql = render_template( + "/".join([self.template_path, self._DELETE_SQL]), + stid=stid + ) + status, res = self.conn.execute_dict(sql) + if not status: + return internal_server_error(errormsg=res) + + elif not res['rows']: + return gone( + errormsg=self.not_found_error_msg() + ) + + # Generate DROP SQL + sql = render_template( + "/".join([self.template_path, self._DELETE_SQL]), + name=res['rows'][0]['name'], + schema=res['rows'][0]['schema'], + cascade=cascade, + conn=self.conn + ) + + if only_sql: + return sql + + status, res = self.conn.execute_scalar(sql) + if not status: + return internal_server_error(errormsg=res) + + return make_json_response( + success=1, + info=_("Statistics dropped") + ) + + except Exception as e: + return internal_server_error(errormsg=str(e)) + + @check_precondition(action='update') + def update(self, gid, sid, did, scid, stid): + """ + This function updates the statistics object. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + + Returns: + JSON response with updated node + """ + data = request.form if request.form else json.loads( + request.data + ) + sql, _ = self.get_SQL(gid, sid, did, data, scid, stid) + + # Most probably this is due to error + if not isinstance(sql, str): + return sql + + sql = sql.strip('\n').strip(' ') + + status, res = self.conn.execute_scalar(sql) + if not status: + return internal_server_error(errormsg=res) + + # Fetch updated node info + sql = render_template( + "/".join([self.template_path, self._NODES_SQL]), + scid=scid, + stid=stid, + conn=self.conn + ) + status, rset = self.conn.execute_2darray(sql) + if not status: + return internal_server_error(errormsg=rset) + + if len(rset['rows']) == 0: + return gone(errormsg=self.not_found_error_msg()) + + row = rset['rows'][0] + + return jsonify( + node=self.blueprint.generate_browser_node( + stid, + scid, + row['name'], + icon=self.node_icon, + description=row['comment'] + ) + ) + + @check_precondition(action='msql') + def msql(self, gid, sid, did, scid, stid=None): + """ + This function returns modified SQL for the object. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + """ + data = {} + for k, v in request.args.items(): + try: + # Comments should be taken as is + if k in ('comment',): + data[k] = v + else: + data[k] = json.loads(v) + except ValueError: + data[k] = v + + if stid is None: + # Schema is required, name is optional in PG 16+ + if 'schema' not in data: + return make_json_response( + status=400, + success=0, + errormsg=_( + "Could not find the required parameter (schema)." + ) + ) + + sql, _sql_name = self.get_SQL(gid, sid, did, data, scid, stid) + + # Most probably this is due to error + if not isinstance(sql, str): + return sql + + sql = sql.strip('\n').strip(' ') + if sql == '': + sql = "--modified SQL" + + return make_json_response( + data=sql, + status=200 + ) + + def get_SQL(self, gid, sid, did, data, scid, stid=None, + add_not_exists_clause=False): + """ + This function generates SQL from model data. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + data: Form data + scid: Schema ID + stid: Statistics ID + add_not_exists_clause: Add IF NOT EXISTS clause + + Returns: + Tuple of (SQL, name) + """ + if stid is not None: + # Update operation + if 'stattarget' in data: + try: + data['stattarget'] = int(data['stattarget']) + except (ValueError, TypeError): + return make_json_response( + status=400, + success=0, + errormsg=_( + "Statistics target must be an integer." + ) + ), None + + status, old_data = self._fetch_properties(scid, stid) + if not status: + return old_data, None + + # Remove keys that shouldn't be compared + for key in self.keys_to_ignore: + old_data.pop(key, None) + + sql = render_template( + "/".join([self.template_path, self._UPDATE_SQL]), + data=data, o_data=old_data, conn=self.conn + ) + return sql, data.get('name', old_data['name']) + else: + # Create operation + # Name is optional in PostgreSQL 16+ + sql = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=data, conn=self.conn, + add_not_exists_clause=add_not_exists_clause + ) + return sql, data.get('name', '') + + @check_precondition(action='sql') + def sql(self, gid, sid, did, scid, stid, **kwargs): + """ + This function generates reverse engineered SQL for the object. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + """ + status, res = self._fetch_properties(scid, stid) + if not status: + return res + + sql = render_template( + "/".join([self.template_path, self._CREATE_SQL]), + data=res, conn=self.conn, add_not_exists_clause=False + ) + + return ajax_response(response=sql) + + @check_precondition(action='stats') + def statistics(self, gid, sid, did, scid, stid=None): + """ + Returns statistics for a particular object. + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID (optional) + """ + if stid is not None: + # Individual statistics + sql = render_template( + "/".join([self.template_path, self._STATS_SQL]), + stid=stid, + conn=self.conn + ) + else: + # Collection statistics + sql = render_template( + "/".join([self.template_path, self._COLL_STATS_SQL]), + scid=scid, + conn=self.conn + ) + + status, res = self.conn.execute_dict(sql) + + if not status: + return internal_server_error(errormsg=res) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition(action='depend') + def dependencies(self, gid, sid, did, scid, stid): + """ + This function gets the dependencies for the selected statistics node + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + """ + dependencies_result = self.get_dependencies( + self.conn, stid + ) + + return ajax_response( + response=dependencies_result, + status=200 + ) + + @check_precondition(action='dependent') + def dependents(self, gid, sid, did, scid, stid): + """ + This function gets the dependents for the selected statistics node + + Args: + gid: Server Group ID + sid: Server ID + did: Database ID + scid: Schema ID + stid: Statistics ID + """ + dependents_result = self.get_dependents( + self.conn, stid + ) + + return ajax_response( + response=dependents_result, + status=200 + ) + + @check_precondition(action='fetch_objects_to_compare') + def fetch_objects_to_compare(self, sid, did, scid): + """ + This function fetches the list of all statistics objects for + schema diff. + + Args: + sid: Server ID + did: Database ID + scid: Schema ID + """ + res = dict() + + SQL = render_template( + "/".join([self.template_path, self._NODES_SQL]), + scid=scid, + schema_diff=True + ) + status, rset = self.conn.execute_2darray(SQL) + if not status: + return internal_server_error(errormsg=rset) + + for row in rset['rows']: + status, data = self._fetch_properties(scid, row['oid']) + if status: + res[row['name']] = data + + return res + + def get_sql_from_diff(self, **kwargs): + """ + This function generates SQL from model data for schema diff. + + Args: + kwargs: Additional parameters + + Returns: + SQL string + """ + gid = kwargs.get('gid') + sid = kwargs.get('sid') + did = kwargs.get('did') + scid = kwargs.get('scid') + source_params = kwargs.get('source_params') + target_params = kwargs.get('target_params') + comp_status = kwargs.get('comp_status') + + if comp_status == 'source_only': + # Object exists in source only - generate CREATE + sql, _ = self.get_SQL(gid=gid, sid=sid, did=did, + data=source_params, scid=scid) + elif comp_status == 'target_only': + # Object exists in target only - generate DROP + sql = render_template( + "/".join([self.template_path, self._DELETE_SQL]), + name=target_params['name'], + schema=target_params['schema'], + cascade=False + ) + else: + # Object exists in both - generate ALTER + sql, _ = self.get_SQL(gid=gid, sid=sid, did=did, + data=source_params, scid=scid, + stid=target_params['oid']) + + return sql + + +SchemaDiffRegistry(blueprint.node_type, StatisticsView) +StatisticsView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/coll-statistics.svg b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/coll-statistics.svg new file mode 100644 index 00000000000..207abf3da2d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/coll-statistics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/statistics.svg b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/statistics.svg new file mode 100644 index 00000000000..207abf3da2d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/img/statistics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js new file mode 100644 index 00000000000..3aaffd910b6 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js @@ -0,0 +1,120 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { getNodeAjaxOptions, getNodeListByName } from '../../../../../../../static/js/node_ajax'; +import StatisticsSchema from './statistics.ui'; + +define('pgadmin.node.statistics', [ + 'sources/gettext', 'sources/url_for', 'pgadmin.browser', + 'pgadmin.node.schema.dir/child', 'pgadmin.node.schema.dir/schema_child_tree_node', + 'pgadmin.browser.collection', +], function( + gettext, url_for, pgBrowser, schemaChild, schemaChildTreeNode +) { + + // Extend the browser's collection class for statistics collection + if (!pgBrowser.Nodes['coll-statistics']) { + pgBrowser.Nodes['coll-statistics'] = + pgBrowser.Collection.extend({ + node: 'statistics', + label: gettext('Statistics'), + type: 'coll-statistics', + columns: ['name', 'table', 'comment'], + hasStatistics: true, + canDrop: schemaChildTreeNode.isTreeItemOfChildOfSchema, + canDropCascade: schemaChildTreeNode.isTreeItemOfChildOfSchema, + }); + } + + // Extend the browser's node class for statistics node + if (!pgBrowser.Nodes['statistics']) { + pgBrowser.Nodes['statistics'] = schemaChild.SchemaChildNode.extend({ + type: 'statistics', + sqlAlterHelp: 'sql-alterstatistics.html', + sqlCreateHelp: 'sql-createstatistics.html', + dialogHelp: url_for('help.static', {'filename': 'statistics_dialog.html'}), + label: gettext('Statistics'), + collection_type: 'coll-statistics', + hasSQL: true, + hasDepends: true, + hasStatistics: false, + Init: function() { + /* Avoid multiple registration of menus */ + if (this.initialized) + return; + + this.initialized = true; + + pgBrowser.add_menus([{ + name: 'create_statistics_on_coll', + node: 'coll-statistics', + module: this, + applies: ['object', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('Statistics...'), + data: {action: 'create', check: true}, + enable: 'canCreate', + shortcut_preference: ['browser', 'sub_menu_create'], + }, { + name: 'create_statistics', + node: 'statistics', + module: this, + applies: ['object', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('Statistics...'), + data: {action: 'create', check: true}, + enable: 'canCreate', + shortcut_preference: ['browser', 'sub_menu_create'], + }, { + name: 'create_statistics_on_schema', + node: 'schema', + module: this, + applies: ['object', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('Statistics...'), + data: {action: 'create', check: false}, + enable: 'canCreate', + }]); + + }, + + getSchema: function(treeNodeInfo, itemNodeData) { + return new StatisticsSchema( + { + role: ()=>getNodeListByName('role', treeNodeInfo, itemNodeData), + schema: ()=>getNodeListByName('schema', treeNodeInfo, itemNodeData, {}, (m)=>{ + // Exclude pg_* schemas + return !(m.label.match(/^pg_/)); + }), + getTables: (params)=>getNodeListByName('table', treeNodeInfo, itemNodeData, {urlParams: params, includeItemKeys: ['_id']}), + getColumns: (params)=>{ + return getNodeAjaxOptions('get_columns', pgBrowser.Nodes['table'], treeNodeInfo, itemNodeData, {urlParams: params, useCache:false}, (rows)=>{ + return rows.map((r)=>({ + 'value': r.name, + 'image': 'icon-column', + 'label': r.name, + })); + }); + } + }, + { + schema: itemNodeData.label, + } + ); + }, + }); + } + return pgBrowser.Nodes['statistics']; +}); diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js new file mode 100644 index 00000000000..7457476ac41 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js @@ -0,0 +1,275 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { isEmptyString } from '../../../../../../../../static/js/validators'; + +export default class StatisticsSchema extends BaseUISchema { + constructor(fieldOptions={}, initValues={}) { + super({ + name: undefined, + oid: undefined, + schema: undefined, + table: undefined, + columns: [], + expression_list: undefined, + has_expressions: false, + stat_types: [], + stattarget: undefined, + owner: undefined, + comment: undefined, + is_sys_obj: undefined, + ...initValues, + }); + + this.fieldOptions = { + role: [], + schema: [], + getTables: null, + getColumns: null, + ...fieldOptions, + }; + this.allTablesOptions = []; + } + + getTableOid(tabName) { + // Fetch the table OID from table name + for(const t of this.allTablesOptions) { + if(t.label === tabName) { + return t._id; + } + } + } + + get idAttribute() { + return 'oid'; + } + + get baseFields() { + let obj = this; + return [ + { + id: 'name', + label: gettext('Name'), + type: 'text', + mode: ['properties', 'create', 'edit'], + helpMessage: gettext('Statistics name'), + }, + { + id: 'oid', + label: gettext('OID'), + type: 'text', + mode: ['properties'], + }, + { + id: 'owner', + label: gettext('Owner'), + type: 'select', + options: this.fieldOptions.role, + controlProps: { allowClear: false }, + mode: ['properties', 'create', 'edit'], + }, + { + id: 'schema', + label: gettext('Schema'), + type: 'select', + options: this.fieldOptions.schema, + controlProps: { allowClear: false }, + mode: ['create', 'edit'], + cache_node: 'database', + cache_level: 'database', + depChange: () => ({ table: null, columns: [] }), + }, + { + id: 'table', + label: gettext('Table'), + type: (state) => ({ + type: 'select', + options: state.schema + ? () => obj.fieldOptions.getTables({ schema: state.schema }) + : [], + optionsReloadBasis: state.schema, + }), + optionsLoaded: (res) => obj.allTablesOptions = res, + mode: ['properties', 'create'], + deps: ['schema'], + editable: false, + noEmpty: true, + helpMessage: gettext('Select the table for which to collect statistics.'), + depChange: (state) => { + if (!state.schema) { + return { table: null, columns: [] }; + } + }, + }, + { + id: 'columns', + label: gettext('Columns'), + type: (state)=>{ + let tid = obj.getTableOid(state.table); + return { + type: 'select', + options: state.table ? ()=>obj.fieldOptions.getColumns({tid: tid}) : [], + optionsReloadBasis: state.table, + }; + }, + deps: ['schema', 'table'], + mode: ['properties', 'create'], + editable: false, + controlProps: { + multiple: true, + allowClear: true, + }, + helpMessage: gettext('Select at least 2 columns for multi-column statistics'), + depChange: (state)=>{ + // Clear columns when table changes + if(!state.table) { + return { + columns: [], + }; + } + } + }, + { + id: 'stat_types', + label: gettext('Statistics types'), + type: 'select', + mode: ['properties', 'create'], + editable: false, + controlProps: { + multiple: true, + allowClear: false, + }, + options: [ + {label: gettext('N-distinct'), value: 'ndistinct'}, + {label: gettext('Dependencies'), value: 'dependencies'}, + {label: gettext('MCV (Most Common Values)'), value: 'mcv'}, + ], + noEmpty: true, + helpMessage: gettext('Select one or more statistics types to collect'), + }, + { + id: 'expression_list', + label: gettext('Expressions'), + type: 'text', + mode: ['create'], + group: gettext('Definition'), + helpMessage: gettext('Enter SQL expression(s) for expression-based statistics, separated by commas'), + }, + { + id: 'has_expressions', + label: gettext('Has expressions?'), + type: 'switch', + mode: ['properties'], + readonly: true, + disabled: true, + helpMessage: gettext('Indicates if this statistics object includes expression-based statistics'), + + }, + { + id: 'stattarget', + label: gettext('Statistics target'), + type: 'int', + mode: ['properties', 'edit'], + min: -1, + helpMessage: gettext('Set statistics target for this object'), + + }, + { + id: 'ndistinct_values', + label: gettext('N-Distinct coefficients'), + type: 'multiline', + mode: ['properties'], + readonly: true, + disabled: true, + group: gettext('Computed Statistics'), + helpMessage: gettext('N-distinct coefficients computed by ANALYZE'), + }, + { + id: 'dependencies_values', + label: gettext('Functional dependencies'), + type: 'multiline', + mode: ['properties'], + readonly: true, + disabled: true, + group: gettext('Computed Statistics'), + helpMessage: gettext('Functional dependency statistics computed by ANALYZE'), + }, + { + id: 'has_mcv_values', + label: gettext('Has MCV data?'), + type: 'switch', + mode: ['properties'], + readonly: true, + disabled: true, + group: gettext('Computed Statistics'), + helpMessage: gettext('Indicates if most-common values data is available for this statistics object'), + }, + { + id: 'comment', + label: gettext('Comment'), + type: 'multiline', + mode: ['properties', 'create', 'edit'], + }, + { + id: 'is_sys_obj', + label: gettext('System statistics?'), + cell:'boolean', + type: 'switch', + mode: ['properties'], + }, + ]; + } + + validate(state, setError) { + let errors = false; + + // Validate table is selected + if (isEmptyString(state.table) && !state.oid) { + setError('table', gettext('Table must be selected.')); + errors = true; + } else { + setError('table', null); + } + + // Validate columns or expressions are provided + const hasColumns = state.columns && state.columns.length > 0; + const hasExpressions = state.expression_list && state.expression_list.trim().length > 0; + + if (!hasColumns && !hasExpressions && !state.oid) { + setError('columns', gettext('Either columns or expressions must be specified.')); + setError('expression_list', gettext('Either columns or expressions must be specified.')); + errors = true; + } else { + // Validate minimum 2 columns for multi-column statistics (when no expressions) + if (hasColumns && !hasExpressions && state.columns.length < 2 && !state.oid) { + setError('columns', gettext('At least 2 columns must be selected for multi-column statistics.')); + errors = true; + } else { + setError('columns', null); + } + + // Clear expression_list error if either columns or expressions are provided + if (hasColumns || hasExpressions) { + setError('expression_list', null); + } + } + + // Validate at least one stat type + if (state.stat_types && state.stat_types.length === 0 && !state.oid) { + setError('stat_types', gettext('At least one statistics type must be selected.')); + errors = true; + } else { + setError('stat_types', null); + } + + return errors; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql new file mode 100644 index 00000000000..d1c356f53f5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql @@ -0,0 +1,14 @@ +{### SQL to create extended statistics object (PostgreSQL 16+) ###} +{### Supports expression-based statistics and optional name ###} +CREATE STATISTICS{% if data.name %}{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn|qtIdent(data.schema, data.name) }}{% endif %}{% if data.stat_types and data.stat_types|length > 0 %} + + ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} + + ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + + FROM {{ conn|qtIdent(data.schema, data.table) }}; +{% if data.comment and data.name %} + +COMMENT ON STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + IS {{ data.comment|qtLiteral(conn) }}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql new file mode 100644 index 00000000000..1e38fd0d1f5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql @@ -0,0 +1,40 @@ +{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 14+) ###} +{% if scid %} +SELECT + s.oid, + s.stxname AS name, + s.stxnamespace AS schemaoid, + ns.nspname AS schema, + s.stxrelid AS tableoid, + t.relname AS table, + pg_catalog.pg_get_userbyid(s.stxowner) AS owner, + s.stxkeys AS column_attnums, + (SELECT array_agg(a.attname ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = s.stxrelid + AND a.attnum = ANY(s.stxkeys) + ) AS columns, + s.stxkind AS stat_types_raw, + CASE WHEN 'd' = ANY(s.stxkind) THEN true ELSE false END AS has_ndistinct, + CASE WHEN 'f' = ANY(s.stxkind) THEN true ELSE false END AS has_dependencies, + CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, + s.stxstattarget AS stattarget, +{### stxexprs added in PostgreSQL 14 for expression statistics ###} + s.stxexprs AS expressions, +{### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} + sd.stxdndistinct AS ndistinct_values, + sd.stxddependencies AS dependencies_values, + CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS has_mcv_values, + des.description AS comment +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace + LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid + LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid + LEFT OUTER JOIN pg_catalog.pg_description des + ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) +WHERE s.stxnamespace = {{scid}}::oid +{% if stid %} + AND s.oid = {{stid}}::oid +{% endif %} +ORDER BY s.stxname +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql new file mode 100644 index 00000000000..2e528fc01dd --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql @@ -0,0 +1,31 @@ +{### SQL to update extended statistics object (PostgreSQL 16+) ###} +{### Rename statistics ###} +{% if data.name and data.name != o_data.name %} +ALTER STATISTICS {% if data.schema and data.schema != o_data.schema %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% else %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% endif %} + + RENAME TO {{ conn|qtIdent(data.name) }}; + +{% endif %} +{### Change schema ###} +{% if data.schema and data.schema != o_data.schema %} +ALTER STATISTICS {{ conn|qtIdent(o_data.schema, data.name if data.name else o_data.name) }} + SET SCHEMA {{ conn|qtIdent(data.schema) }}; + +{% endif %} +{### Change owner ###} +{% if data.owner and data.owner != o_data.owner %} +ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + OWNER TO {{ conn|qtIdent(data.owner) }}; + +{% endif %} +{### Set statistics target (PostgreSQL 13+) ###} +{% if data.stattarget is defined and data.stattarget != o_data.stattarget %} +ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + SET STATISTICS {{ data.stattarget }}; + +{% endif %} +{### Update comment ###} +{% if data.comment is defined and data.comment != o_data.comment %} +COMMENT ON STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + IS {% if data.comment %}{{ data.comment|qtLiteral(conn) }}{% else %}NULL{% endif %}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql new file mode 100644 index 00000000000..b8102738f57 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql @@ -0,0 +1,14 @@ +{### SQL to create extended statistics object (PostgreSQL 17+) ###} +{### Supports expression-based statistics and optional name ###} +CREATE STATISTICS{% if data.name %}{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn|qtIdent(data.schema, data.name) }}{% endif %}{% if data.stat_types and data.stat_types|length > 0 %} + + ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} + + ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + + FROM {{ conn|qtIdent(data.schema, data.table) }}; +{% if data.comment and data.name %} + +COMMENT ON STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + IS {{ data.comment|qtLiteral(conn) }}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql new file mode 100644 index 00000000000..1e38fd0d1f5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql @@ -0,0 +1,40 @@ +{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 14+) ###} +{% if scid %} +SELECT + s.oid, + s.stxname AS name, + s.stxnamespace AS schemaoid, + ns.nspname AS schema, + s.stxrelid AS tableoid, + t.relname AS table, + pg_catalog.pg_get_userbyid(s.stxowner) AS owner, + s.stxkeys AS column_attnums, + (SELECT array_agg(a.attname ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = s.stxrelid + AND a.attnum = ANY(s.stxkeys) + ) AS columns, + s.stxkind AS stat_types_raw, + CASE WHEN 'd' = ANY(s.stxkind) THEN true ELSE false END AS has_ndistinct, + CASE WHEN 'f' = ANY(s.stxkind) THEN true ELSE false END AS has_dependencies, + CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, + s.stxstattarget AS stattarget, +{### stxexprs added in PostgreSQL 14 for expression statistics ###} + s.stxexprs AS expressions, +{### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} + sd.stxdndistinct AS ndistinct_values, + sd.stxddependencies AS dependencies_values, + CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS has_mcv_values, + des.description AS comment +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace + LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid + LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid + LEFT OUTER JOIN pg_catalog.pg_description des + ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) +WHERE s.stxnamespace = {{scid}}::oid +{% if stid %} + AND s.oid = {{stid}}::oid +{% endif %} +ORDER BY s.stxname +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql new file mode 100644 index 00000000000..830ea36f321 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql @@ -0,0 +1,31 @@ +{### SQL to update extended statistics object (PostgreSQL 17+) ###} +{### Rename statistics ###} +{% if data.name and data.name != o_data.name %} +ALTER STATISTICS {% if data.schema and data.schema != o_data.schema %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% else %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% endif %} + + RENAME TO {{ conn|qtIdent(data.name) }}; + +{% endif %} +{### Change schema ###} +{% if data.schema and data.schema != o_data.schema %} +ALTER STATISTICS {{ conn|qtIdent(o_data.schema, data.name if data.name else o_data.name) }} + SET SCHEMA {{ conn|qtIdent(data.schema) }}; + +{% endif %} +{### Change owner ###} +{% if data.owner and data.owner != o_data.owner %} +ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + OWNER TO {{ conn|qtIdent(data.owner) }}; + +{% endif %} +{### Set statistics target (PostgreSQL 17+ supports DEFAULT) ###} +{% if data.stattarget is defined and data.stattarget != o_data.stattarget %} +ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + SET STATISTICS {% if data.stattarget == -1 or data.stattarget == 'DEFAULT' %}DEFAULT{% else %}{{ data.stattarget }}{% endif %}; + +{% endif %} +{### Update comment ###} +{% if data.comment is defined and data.comment != o_data.comment %} +COMMENT ON STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + IS {% if data.comment %}{{ data.comment|qtLiteral(conn) }}{% else %}NULL{% endif %}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql new file mode 100644 index 00000000000..4ee5299a2c3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql @@ -0,0 +1,5 @@ +{### Check if extended statistics are supported (PostgreSQL 10+) ###} +SELECT + CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END AS has_statistics +FROM pg_catalog.pg_class +WHERE relname='pg_statistic_ext' diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/coll_stats.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/coll_stats.sql new file mode 100644 index 00000000000..ff796840b38 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/coll_stats.sql @@ -0,0 +1,5 @@ +{### Get collection statistics for statistics objects ###} +SELECT + COUNT(*) AS {{ conn|qtIdent(_('Statistics')) }} +FROM pg_catalog.pg_statistic_ext s +WHERE s.stxnamespace = {{scid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/count.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/count.sql new file mode 100644 index 00000000000..84da7fa71c8 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/count.sql @@ -0,0 +1,4 @@ +{### Count statistics objects in schema ###} +SELECT COUNT(*) +FROM pg_catalog.pg_statistic_ext s +WHERE s.stxnamespace = {{scid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql new file mode 100644 index 00000000000..edbe5bffdd5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql @@ -0,0 +1,14 @@ +{### SQL to create extended statistics object (PostgreSQL 14+) ###} +{### Supports expression-based statistics ###} +CREATE STATISTICS{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn|qtIdent(data.schema, data.name) }}{% if data.stat_types and data.stat_types|length > 0 %} + + ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} + + ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + + FROM {{ conn|qtIdent(data.schema, data.table) }}; +{% if data.comment %} + +COMMENT ON STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + IS {{ data.comment|qtLiteral(conn) }}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql new file mode 100644 index 00000000000..05329abd2d9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql @@ -0,0 +1,12 @@ +{### SQL to drop extended statistics object ###} +{% if stid %} +SELECT + s.stxname AS name, + ns.nspname AS schema +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace +WHERE s.oid = {{stid}}::oid; +{% endif %} +{% if name %} +DROP STATISTICS{% if cascade %} CASCADE{% endif %} {{ conn|qtIdent(schema, name) }}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql new file mode 100644 index 00000000000..903336d1d8f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql @@ -0,0 +1,6 @@ +{### Get OID of newly created statistics object ###} +SELECT s.oid +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace +WHERE s.stxname = {{name|qtLiteral(conn)}} + AND ns.nspname = {{schema|qtLiteral(conn)}} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/nodes.sql new file mode 100644 index 00000000000..07aeabeb41d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/nodes.sql @@ -0,0 +1,17 @@ +{### List statistics objects for tree view ###} +SELECT + s.oid, + s.stxname AS name, + des.description AS comment +FROM pg_catalog.pg_statistic_ext s + LEFT OUTER JOIN pg_catalog.pg_description des + ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) +WHERE s.stxnamespace = {{scid}}::oid +{% if stid %} + AND s.oid = {{stid}}::oid +{% endif %} +{% if schema_diff %} + AND CASE WHEN (SELECT COUNT(*) FROM pg_catalog.pg_depend + WHERE objid = s.oid AND deptype = 'e') > 0 THEN FALSE ELSE TRUE END +{% endif %} +ORDER BY s.stxname diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql new file mode 100644 index 00000000000..1e38fd0d1f5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql @@ -0,0 +1,40 @@ +{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 14+) ###} +{% if scid %} +SELECT + s.oid, + s.stxname AS name, + s.stxnamespace AS schemaoid, + ns.nspname AS schema, + s.stxrelid AS tableoid, + t.relname AS table, + pg_catalog.pg_get_userbyid(s.stxowner) AS owner, + s.stxkeys AS column_attnums, + (SELECT array_agg(a.attname ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = s.stxrelid + AND a.attnum = ANY(s.stxkeys) + ) AS columns, + s.stxkind AS stat_types_raw, + CASE WHEN 'd' = ANY(s.stxkind) THEN true ELSE false END AS has_ndistinct, + CASE WHEN 'f' = ANY(s.stxkind) THEN true ELSE false END AS has_dependencies, + CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, + s.stxstattarget AS stattarget, +{### stxexprs added in PostgreSQL 14 for expression statistics ###} + s.stxexprs AS expressions, +{### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} + sd.stxdndistinct AS ndistinct_values, + sd.stxddependencies AS dependencies_values, + CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS has_mcv_values, + des.description AS comment +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace + LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid + LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid + LEFT OUTER JOIN pg_catalog.pg_description des + ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) +WHERE s.stxnamespace = {{scid}}::oid +{% if stid %} + AND s.oid = {{stid}}::oid +{% endif %} +ORDER BY s.stxname +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql new file mode 100644 index 00000000000..f86cc8ded51 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql @@ -0,0 +1,30 @@ +{### Get statistics for individual statistics object (PostgreSQL 10-11) ###} +SELECT + s.stxname AS {{ conn|qtIdent(_('Name')) }}, + t.relname AS {{ conn|qtIdent(_('Table')) }}, + (SELECT string_agg(a.attname, ', ' ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = s.stxrelid + AND a.attnum = ANY(s.stxkeys) + ) AS {{ conn|qtIdent(_('Columns')) }}, + CASE + WHEN s.stxkind IS NOT NULL THEN + array_to_string( + ARRAY( + SELECT CASE kind + WHEN 'd' THEN 'ndistinct' + WHEN 'f' THEN 'dependencies' + WHEN 'm' THEN 'mcv' + WHEN 'e' THEN 'expressions' + END + FROM unnest(s.stxkind) AS kind + ), ', ' + ) + ELSE '' + END AS {{ conn|qtIdent(_('Statistics Types')) }}, + NULL AS {{ conn|qtIdent(_('N-Distinct Coefficients')) }}, + NULL AS {{ conn|qtIdent(_('Functional Dependencies')) }}, + NULL AS {{ conn|qtIdent(_('Most Common Values')) }} +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid +WHERE s.oid = {{stid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql new file mode 100644 index 00000000000..15965c773ed --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql @@ -0,0 +1,24 @@ +{### SQL to update extended statistics object ###} +{### Rename statistics ###} +{% if data.name and data.name != o_data.name %} +ALTER STATISTICS {{ conn|qtIdent(o_data.schema, o_data.name) }} + RENAME TO {{ conn|qtIdent(data.name) }}; + +{% endif %} +{### Change schema ###} +{% if data.schema and data.schema != o_data.schema %} +ALTER STATISTICS {{ conn|qtIdent(o_data.schema, data.name if data.name else o_data.name) }} + SET SCHEMA {{ conn|qtIdent(data.schema) }}; + +{% endif %} +{### Change owner ###} +{% if data.owner and data.owner != o_data.owner %} +ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + OWNER TO {{ conn|qtIdent(data.owner) }}; + +{% endif %} +{### Update comment ###} +{% if data.comment is defined and data.comment != o_data.comment %} +COMMENT ON STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + IS {% if data.comment %}{{ data.comment|qtLiteral(conn) }}{% else %}NULL{% endif %}; +{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/pg/14_plus/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/pg/14_plus/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/pg/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/pg/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json new file mode 100644 index 00000000000..91e5f2555a7 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json @@ -0,0 +1,250 @@ +{ + "statistics_create": [ + { + "name": "Create statistics: With ndistinct only.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000, + "skip_msg": "Statistics not supported below PG 14" + }, + "test_data": { + "name": "test_stats_ndistinct", + "schema": "public", + "table": "test_table", + "columns": ["col1", "col2"], + "stat_types": ["ndistinct"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create statistics: With dependencies only.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000 + }, + "test_data": { + "name": "test_stats_dependencies", + "schema": "public", + "table": "test_table", + "columns": ["col1", "col2"], + "stat_types": ["dependencies"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create statistics: With multiple types.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000 + }, + "test_data": { + "name": "test_stats_multi", + "schema": "public", + "table": "test_table", + "columns": ["col1", "col2", "col3"], + "stat_types": ["ndistinct", "dependencies"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create statistics: With MCV", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000, + "skip_msg": "Statistics node not supported below PG 14" + }, + "test_data": { + "name": "test_stats_mcv", + "schema": "public", + "table": "test_table", + "columns": ["col1", "col2"], + "stat_types": ["mcv"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create statistics: With invalid data - Less than 2 columns.", + "url": "/browser/statistics/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "name": "test_stats_invalid", + "schema": "public", + "table": "test_table", + "columns": ["col1"], + "stat_types": ["ndistinct"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 400, + "error_msg": "At least 2 columns must be specified for multi-column statistics.", + "test_result_data": {} + } + }, + { + "name": "Create statistics: With invalid data - No statistics types.", + "url": "/browser/statistics/obj/", + "is_positive_test": false, + "inventory_data": {}, + "test_data": { + "name": "test_stats_no_types", + "schema": "public", + "table": "test_table", + "columns": ["col1", "col2"], + "stat_types": [] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 400, + "error_msg": "At least 1 statistics type must be selected.", + "test_result_data": {} + } + } + ], + "statistics_delete": [ + { + "name": "Delete statistics: Single object.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "is_list": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Delete statistics: Multiple objects.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "is_list": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Delete statistics: Non-existing object.", + "url": "/browser/statistics/obj/", + "is_positive_test": false, + "is_list": false, + "inventory_data": {}, + "test_data": { + "statistics_id": 999999 + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 410, + "error_msg": "could not find the specified statistics.", + "test_result_data": {} + } + } + ], + "statistics_get": [ + { + "name": "Get statistics: Single object properties.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "is_list": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Get statistics: List all objects.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "is_list": true, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Get statistics: Non-existing object.", + "url": "/browser/statistics/obj/", + "is_positive_test": false, + "is_list": false, + "inventory_data": {}, + "test_data": { + "statistics_id": 999999 + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 410, + "error_msg": "could not find the specified statistics.", + "test_result_data": {} + } + } + ], + "statistics_put": [ + { + "name": "Update statistics: Change name.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "name": "test_stats_renamed" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + } + ] +} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py new file mode 100644 index 00000000000..511cb9430b8 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py @@ -0,0 +1,121 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.browser.server_groups.servers.databases.schemas.tests import \ + utils as schema_utils +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as statistics_utils + + +class StatisticsAddTestCase(BaseTestGenerator): + """This class will add new statistics object under schema node.""" + + # Generates scenarios + scenarios = utils.generate_scenarios("statistics_create", + statistics_utils.test_cases) + + def setUp(self): + super().setUp() + # Load test data + self.data = self.test_data + + # Create db connection + self.db_name = parent_node_dict["database"][-1]["db_name"] + schema_info = parent_node_dict["schema"][-1] + self.server_id = schema_info["server_id"] + self.db_id = schema_info["db_id"] + db_con = database_utils.connect_database(self, utils.SERVER_GROUP, + self.server_id, self.db_id) + if not db_con['data']["connected"]: + raise Exception("Could not connect to database to add statistics.") + + # Check server version (Statistics require PG 14+) + if "server_min_version" in self.inventory_data: + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to check version") + if server_con["data"]["version"] < \ + self.inventory_data["server_min_version"]: + self.skipTest(self.inventory_data.get("skip_msg", + "Statistics not supported below PG 14")) + + # Create schema + self.schema_id = schema_info["schema_id"] + self.schema_name = schema_info["schema_name"] + schema_response = schema_utils.verify_schemas(self.server, + self.db_name, + self.schema_name) + if not schema_response: + raise Exception("Could not find the schema to add statistics.") + + # Create test table for statistics + self.table_name = "test_table_stats_%s" % (str(uuid.uuid4())[1:8]) + self.table_id = statistics_utils.create_table_for_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name + ) + + def runTest(self): + """This function will add statistics under schema node.""" + db_user = self.server["username"] + self.data["schema"] = self.schema_name + self.data["table"] = self.table_name + + if "name" in self.data: + statistics_name = \ + self.data["name"] + (str(uuid.uuid4())[1:8]) + self.data["name"] = statistics_name + + if self.is_positive_test: + response = statistics_utils.api_create(self) + + # Assert response + utils.assert_status_code(self, response) + + # Verify in backend + if "name" in self.data: + cross_check_res = statistics_utils.verify_statistics( + self.server, + self.db_name, + self.data["name"] + ) + + self.assertIsNotNone( + cross_check_res, + "Could not find the newly created statistics object." + ) + else: + if self.mocking_required: + with patch(self.mock_data["function_name"], + side_effect=eval(self.mock_data["return_value"])): + response = statistics_utils.api_create(self) + + # Assert response + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + else: + response = statistics_utils.api_create(self) + + # Assert response + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + # Disconnect the database + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py new file mode 100644 index 00000000000..dff34617216 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py @@ -0,0 +1,159 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +from unittest.mock import patch + +from pgadmin.browser.server_groups.servers.databases.schemas.tests import \ + utils as schema_utils +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as statistics_utils + + +class StatisticsDeleteTestCase(BaseTestGenerator): + """This class will delete the statistics object under schema node.""" + + # Generates scenarios + scenarios = utils.generate_scenarios("statistics_delete", + statistics_utils.test_cases) + + def setUp(self): + super().setUp() + # Load test data + self.data = self.test_data + + # Create db connection + self.db_name = parent_node_dict["database"][-1]["db_name"] + schema_info = parent_node_dict["schema"][-1] + self.server_id = schema_info["server_id"] + self.db_id = schema_info["db_id"] + db_con = database_utils.connect_database(self, utils.SERVER_GROUP, + self.server_id, self.db_id) + if not db_con['data']["connected"]: + raise Exception("Could not connect to database to delete " + "statistics.") + + # Check server version (Statistics require PG 14+) + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to check version") + if server_con["data"]["version"] < 140000: + self.skipTest("Statistics not supported below PG 14") + + # Create schema + self.schema_id = schema_info["schema_id"] + self.schema_name = schema_info["schema_name"] + schema_response = schema_utils.verify_schemas(self.server, + self.db_name, + self.schema_name) + if not schema_response: + raise Exception("Could not find the schema to delete statistics") + + # Create test table for statistics + self.table_name = "test_table_stats_%s" % (str(uuid.uuid4())[1:8]) + self.table_id = statistics_utils.create_table_for_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name + ) + + # Create statistics object + self.statistics_name = "test_stats_delete_%s" % \ + (str(uuid.uuid4())[1:8]) + + self.statistics_id = statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name, + ["col1", "col2"], + ["ndistinct", "dependencies"] + ) + + statistics_response = statistics_utils.verify_statistics( + self.server, self.db_name, self.statistics_name + ) + if not statistics_response: + raise Exception("Could not find the statistics to delete.") + + # For multiple delete test + if self.is_list: + self.statistics_name_2 = "test_stats_delete_%s" % \ + (str(uuid.uuid4())[1:8]) + + self.statistics_id_2 = statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name_2, + ["col1", "col2"], + ["ndistinct"] + ) + + statistics_response = statistics_utils.verify_statistics( + self.server, self.db_name, self.statistics_name_2 + ) + if not statistics_response: + raise Exception("Could not find the second statistics " + "to delete.") + + # List to delete multiple statistics + self.data['ids'] = [self.statistics_id, self.statistics_id_2] + + def runTest(self): + """This function will delete the statistics under schema node.""" + + if self.is_positive_test: + if self.is_list: + response = statistics_utils.api_delete(self, '') + else: + response = statistics_utils.api_delete(self) + + # Assert response + utils.assert_status_code(self, response) + + # Verify in backend + statistics_response = statistics_utils.verify_statistics( + self.server, self.db_name, self.statistics_name + ) + self.assertIsNone(statistics_response, + "Deleted statistics still present") + + if self.is_list: + statistics_response_2 = statistics_utils.verify_statistics( + self.server, self.db_name, self.statistics_name_2 + ) + self.assertIsNone(statistics_response_2, + "Second deleted statistics still present") + else: + if self.mocking_required: + with patch( + self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])] + ): + response = statistics_utils.api_delete(self) + elif 'statistics_id' in self.data: + self.statistics_id = self.data["statistics_id"] + response = statistics_utils.api_delete(self) + + # Assert response + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + # Disconnect the database + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py new file mode 100644 index 00000000000..5edbb35f328 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py @@ -0,0 +1,125 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +import json + +from pgadmin.browser.server_groups.servers.databases.schemas.tests import \ + utils as schema_utils +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as statistics_utils + + +class StatisticsDeleteMultipleTestCase(BaseTestGenerator): + """This class will delete multiple statistics objects under schema node""" + + scenarios = [ + # Fetching default URL for statistics node. + ('Fetch statistics Node URL', dict(url='/browser/statistics/obj/')) + ] + + def setUp(self): + super().setUp() + self.db_name = parent_node_dict["database"][-1]["db_name"] + schema_info = parent_node_dict["schema"][-1] + self.server_id = schema_info["server_id"] + self.db_id = schema_info["db_id"] + db_con = database_utils.connect_database(self, utils.SERVER_GROUP, + self.server_id, self.db_id) + if not db_con['data']["connected"]: + raise Exception("Could not connect to database to add statistics") + + # Check server version (Statistics require PG 14+) + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to check version") + if server_con["data"]["version"] < 140000: + self.skipTest("Statistics not supported below PG 14") + + self.schema_id = schema_info["schema_id"] + self.schema_name = schema_info["schema_name"] + schema_response = schema_utils.verify_schemas(self.server, + self.db_name, + self.schema_name) + if not schema_response: + raise Exception("Could not find the schema to add statistics.") + + # Create test table for statistics + self.table_name = "test_table_stats_%s" % (str(uuid.uuid4())[1:8]) + self.table_id = statistics_utils.create_table_for_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name + ) + + # Create multiple statistics objects + self.statistics_name = "test_stats_delete_%s" % str(uuid.uuid4())[1:8] + self.statistics_name_1 = "test_stats_delete_%s" % \ + str(uuid.uuid4())[1:8] + + self.statistics_ids = [ + statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name, + ["col1", "col2"], + ["ndistinct", "dependencies"] + ), + statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name_1, + ["col1", "col2"], + ["mcv"] + ) + ] + + def runTest(self): + """This function will delete multiple statistics under schema node""" + statistics_response = statistics_utils.verify_statistics( + self.server, + self.db_name, + self.statistics_name + ) + if not statistics_response: + raise Exception("Could not find the statistics to delete.") + + statistics_response = statistics_utils.verify_statistics( + self.server, + self.db_name, + self.statistics_name_1 + ) + if not statistics_response: + raise Exception("Could not find the statistics to delete.") + + data = json.dumps({'ids': self.statistics_ids}) + response = self.tester.delete( + self.url + str(utils.SERVER_GROUP) + '/' + + str(self.server_id) + '/' + + str(self.db_id) + '/' + + str(self.schema_id) + '/', + follow_redirects=True, + data=data, + content_type='html/json' + ) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + # Disconnect the database + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py new file mode 100644 index 00000000000..6722929c007 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py @@ -0,0 +1,150 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import uuid +import json +from unittest.mock import patch + +from pgadmin.browser.server_groups.servers.databases.schemas.tests import \ + utils as schema_utils +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as statistics_utils + + +class StatisticsGetTestCase(BaseTestGenerator): + """This class will fetch the statistics under schema node.""" + + # Generates scenarios + scenarios = utils.generate_scenarios("statistics_get", + statistics_utils.test_cases) + + def setUp(self): + super().setUp() + # Load test data + self.data = self.test_data + + # Create db connection + self.db_name = parent_node_dict["database"][-1]["db_name"] + schema_info = parent_node_dict["schema"][-1] + self.server_id = schema_info["server_id"] + self.db_id = schema_info["db_id"] + db_con = database_utils.connect_database(self, utils.SERVER_GROUP, + self.server_id, self.db_id) + if not db_con['data']["connected"]: + raise Exception("Could not connect to database to fetch " + "statistics.") + + # Check server version (Statistics require PG 14+) + if "server_min_version" in self.data: + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception( + "Could not connect to server to check version") + ver = server_con["data"]["version"] + if ver < self.data["server_min_version"]: + self.skipTest(self.data["skip_msg"]) + + if "server_max_version" in self.data: + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to check version") + ver = server_con["data"]["version"] + if ver > self.data["server_max_version"]: + self.skipTest(self.data["skip_msg"]) + + # Create schema + self.schema_id = schema_info["schema_id"] + self.schema_name = schema_info["schema_name"] + schema_response = schema_utils.verify_schemas(self.server, + self.db_name, + self.schema_name) + if not schema_response: + raise Exception("Could not find the schema to fetch statistics") + + # Create test table for statistics + self.table_name = "test_table_stats_%s" % (str(uuid.uuid4())[1:8]) + self.table_id = statistics_utils.create_table_for_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name + ) + + # Create statistics object + self.statistics_name = "test_stats_get_%s" % (str(uuid.uuid4())[1:8]) + + self.statistics_id = statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name, + ["col1", "col2"], + ["ndistinct", "dependencies"] + ) + + # In case of multiple statistics + if self.is_list: + self.statistics_name_2 = "test_stats_get_%s" % \ + (str(uuid.uuid4())[1:8]) + self.statistics_id_2 = statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name_2, + ["col1", "col2"], + ["mcv"] + ) + + def runTest(self): + """This function will fetch the statistics under schema node.""" + if self.is_positive_test: + if self.is_list: + response = statistics_utils.api_get(self, '') + else: + response = statistics_utils.api_get(self) + + # Assert response + utils.assert_status_code(self, response) + + # Check response data + response_data = json.loads(response.data.decode('utf-8')) + if not self.is_list: + # Verify that the statistics name is in the response + self.assertIn('name', response_data) + self.assertEqual(response_data['name'], self.statistics_name) + # Verify columns are returned + self.assertIn('columns', response_data) + # Verify stat_types are returned + self.assertIn('stat_types', response_data) + else: + if self.mocking_required: + with patch( + self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])] + ): + response = statistics_utils.api_get(self) + elif 'statistics_id' in self.data: + # Non-existing statistics id + self.statistics_id = self.data["statistics_id"] + response = statistics_utils.api_get(self) + + # Assert response + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + # Disconnect the database + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py new file mode 100644 index 00000000000..c44d1ac529e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py @@ -0,0 +1,126 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json +import uuid +from unittest.mock import patch + +from pgadmin.browser.server_groups.servers.databases.schemas.tests import \ + utils as schema_utils +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as statistics_utils + + +class StatisticsUpdateTestCase(BaseTestGenerator): + """This class will update the statistics under schema node.""" + + # Generates scenarios + scenarios = utils.generate_scenarios("statistics_put", + statistics_utils.test_cases) + + def setUp(self): + super().setUp() + # Load test data + self.data = self.test_data + + # Create db connection + self.db_name = parent_node_dict["database"][-1]["db_name"] + schema_info = parent_node_dict["schema"][-1] + self.server_id = schema_info["server_id"] + self.db_id = schema_info["db_id"] + db_con = database_utils.connect_database(self, utils.SERVER_GROUP, + self.server_id, self.db_id) + if not db_con['data']["connected"]: + raise Exception("Could not connect to database to update " + "statistics.") + + # Check server version (Statistics require PG 14+) + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to check version") + if server_con["data"]["version"] < 140000: + self.skipTest("Statistics not supported below PG 14") + + # Create schema + self.schema_id = schema_info["schema_id"] + self.schema_name = schema_info["schema_name"] + schema_response = schema_utils.verify_schemas(self.server, + self.db_name, + self.schema_name) + if not schema_response: + raise Exception("Could not find the schema to update statistics") + + # Create test table for statistics + self.table_name = "test_table_stats_%s" % (str(uuid.uuid4())[1:8]) + self.table_id = statistics_utils.create_table_for_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name + ) + + # Create statistics object + self.statistics_name = "test_stats_update_%s" % \ + (str(uuid.uuid4())[1:8]) + + self.statistics_id = statistics_utils.get_statistics_id( + self.server, self.db_name, self.statistics_name + ) + if self.statistics_id is None: + self.statistics_id = statistics_utils.create_statistics( + self.server, + self.db_name, + self.schema_name, + self.table_name, + self.statistics_name, + ["col1", "col2"], + ["ndistinct", "dependencies"] + ) + + def runTest(self): + """This function will update the statistics under schema node.""" + statistics_response = statistics_utils.verify_statistics( + self.server, self.db_name, self.statistics_name + ) + if not statistics_response: + raise Exception("Could not find the statistics to update.") + + self.data["oid"] = self.statistics_id + + if self.is_positive_test: + response = statistics_utils.api_put(self) + + # Assert response + utils.assert_status_code(self, response) + + # Verify the update in the response + response_data = json.loads(response.data.decode('utf-8')) + self.assertIn('node', response_data) + else: + if self.mocking_required: + with patch( + self.mock_data["function_name"], + side_effect=[eval(self.mock_data["return_value"])] + ): + response = statistics_utils.api_put(self) + else: + response = statistics_utils.api_put(self) + + # Assert response + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) + + def tearDown(self): + # Disconnect the database + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py new file mode 100644 index 00000000000..2cc70bb0868 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py @@ -0,0 +1,332 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Utility functions for Statistics tests""" + +import sys +import traceback +import os +import json + +from regression.python_test_utils import test_utils +from urllib.parse import urlencode + +# Load test data from JSON file +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) +with open(CURRENT_PATH + "/statistics_test_data.json") as data_file: + test_cases = json.load(data_file) + + +# API call methods +def api_create(self): + """Create a new statistics object via API""" + return self.tester.post("{0}{1}/{2}/{3}/{4}/". + format(self.url, test_utils.SERVER_GROUP, + self.server_id, self.db_id, + self.schema_id), + data=json.dumps(self.data), + content_type='html/json') + + +def api_get(self, stid=None): + """Get statistics object details via API""" + if stid is None: + stid = self.statistics_id + return self.tester.get("{0}{1}/{2}/{3}/{4}/{5}". + format(self.url, test_utils.SERVER_GROUP, + self.server_id, self.db_id, + self.schema_id, stid), + follow_redirects=True) + + +def api_delete(self, stid=None): + """Delete a statistics object via API""" + if stid is None: + stid = self.statistics_id + return self.tester.delete("{0}{1}/{2}/{3}/{4}/{5}". + format(self.url, test_utils.SERVER_GROUP, + self.server_id, self.db_id, + self.schema_id, stid), + data=json.dumps(self.data), + follow_redirects=True) + + +def api_put(self): + """Update a statistics object via API""" + return self.tester.put("{0}{1}/{2}/{3}/{4}/{5}". + format(self.url, test_utils.SERVER_GROUP, + self.server_id, self.db_id, + self.schema_id, self.statistics_id), + data=json.dumps(self.data), + follow_redirects=True) + + +def api_get_msql(self, url_encode_data): + """Get modified SQL via API""" + return self.tester.get("{0}{1}/{2}/{3}/{4}/{5}?{6}". + format(self.url, test_utils.SERVER_GROUP, + self.server_id, self.db_id, + self.schema_id, self.statistics_id, + urlencode(url_encode_data)), + follow_redirects=True) + + +def create_table_for_statistics(server, db_name, schema_name, table_name): + """ + This function creates a table with multiple columns for statistics testing. + + Args: + server: server details + db_name: database name + schema_name: schema name + table_name: table name + + Returns: + table OID + """ + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + old_isolation_level = connection.isolation_level + test_utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + + # Create a table with multiple columns for statistics testing + query = f"CREATE TABLE {schema_name}.{table_name} " \ + f"(col1 INTEGER, col2 INTEGER, col3 TEXT)" + pg_cursor.execute(query) + + # Insert some test data + insert_query = f"INSERT INTO {schema_name}.{table_name} VALUES " \ + f"(1, 10, 'test1'), (2, 20, 'test2'), (3, 30, 'test3')" + pg_cursor.execute(insert_query) + + test_utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + + # Get OID + pg_cursor.execute( + f"SELECT oid FROM pg_catalog.pg_class " + f"WHERE relname = '{table_name}' " + f"AND relnamespace = (SELECT oid FROM pg_catalog.pg_namespace " + f"WHERE nspname = '{schema_name}')" + ) + table = pg_cursor.fetchone() + table_oid = table[0] if table else None + connection.close() + + return table_oid + except Exception: + traceback.print_exc(file=sys.stderr) + raise + + +def create_statistics(server, db_name, schema_name, table_name, + statistics_name, columns, stat_types): + """ + This function creates a statistics object in the database. + + Args: + server: server details + db_name: database name + schema_name: schema name + table_name: table name + statistics_name: statistics object name + columns: list of column names + stat_types: list of statistics types (ndistinct, dependencies, mcv) + + Returns: + statistics OID + """ + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + old_isolation_level = connection.isolation_level + test_utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + + # Create statistics + stat_types_str = ', '.join(stat_types) + columns_str = ', '.join(columns) + query = f"CREATE STATISTICS {schema_name}.{statistics_name} " \ + f"({stat_types_str}) ON {columns_str} " \ + f"FROM {schema_name}.{table_name}" + pg_cursor.execute(query) + + # Get OID + pg_cursor.execute( + f"SELECT s.oid FROM pg_catalog.pg_statistic_ext s " + f"JOIN pg_catalog.pg_namespace n ON s.stxnamespace = n.oid " + f"WHERE s.stxname = '{statistics_name}' " + f"AND n.nspname = '{schema_name}'" + ) + statistics = pg_cursor.fetchone() + statistics_oid = statistics[0] + test_utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + + return statistics_oid + except Exception: + traceback.print_exc(file=sys.stderr) + raise + + +def verify_statistics(server, db_name, statistics_name): + """ + This function verifies that a statistics object exists in the database. + + Args: + server: server details + db_name: database name + statistics_name: statistics object name + + Returns: + statistics details (oid, name) + """ + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + pg_cursor = connection.cursor() + pg_cursor.execute( + f"SELECT s.oid, s.stxname FROM pg_catalog.pg_statistic_ext s " + f"WHERE s.stxname = '{statistics_name}'" + ) + statistics = pg_cursor.fetchone() + connection.close() + return statistics + except Exception: + traceback.print_exc(file=sys.stderr) + raise + + +def get_statistics_id(server, db_name, statistics_name): + """ + This function retrieves the OID of a statistics object. + + Args: + server: server details + db_name: database name + statistics_name: statistics object name + + Returns: + statistics OID or None + """ + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + pg_cursor = connection.cursor() + pg_cursor.execute( + f"SELECT s.oid FROM pg_catalog.pg_statistic_ext s " + f"WHERE s.stxname = '{statistics_name}'" + ) + statistics = pg_cursor.fetchone() + statistics_id = statistics[0] if statistics else None + connection.close() + return statistics_id + except Exception: + traceback.print_exc(file=sys.stderr) + raise + + +def delete_statistics(server, db_name, schema_name, statistics_name): + """ + This function deletes a statistics object from the database. + + Args: + server: server details + db_name: database name + schema_name: schema name + statistics_name: statistics object name + + Returns: + None + """ + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + old_isolation_level = connection.isolation_level + test_utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + + query = f"DROP STATISTICS IF EXISTS {schema_name}.{statistics_name}" + pg_cursor.execute(query) + + test_utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + except Exception: + traceback.print_exc(file=sys.stderr) + raise + + +def get_statistics_columns(server, db_name, statistics_oid): + """ + This function retrieves the columns for a statistics object. + + Args: + server: server details + db_name: database name + statistics_oid: statistics object OID + + Returns: + list of column names + """ + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + pg_cursor = connection.cursor() + pg_cursor.execute( + f"SELECT array_agg(a.attname) FROM pg_catalog.pg_attribute a " + f"JOIN pg_catalog.pg_statistic_ext s ON a.attrelid = s.stxrelid " + f"WHERE s.oid = {statistics_oid} " + f"AND a.attnum = ANY(s.stxkeys)" + ) + columns = pg_cursor.fetchone() + connection.close() + return columns[0] if columns else [] + except Exception: + traceback.print_exc(file=sys.stderr) + raise diff --git a/web/webpack.config.js b/web/webpack.config.js index 6e5c6877980..305b687e9c2 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -213,6 +213,7 @@ module.exports = [{ 'pure|pgadmin.node.trigger_function', 'pure|pgadmin.node.package', 'pure|pgadmin.node.sequence', + 'pure|pgadmin.node.statistics', 'pure|pgadmin.node.synonym', 'pure|pgadmin.node.type', 'pure|pgadmin.node.rule', diff --git a/web/webpack.shim.js b/web/webpack.shim.js index c370b31af72..82081b34e09 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -129,6 +129,7 @@ let webpackShimConfig = { 'pgadmin.node.schema': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema'), 'pgadmin.node.schema.dir': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/static/js/'), 'pgadmin.node.sequence': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/sequences/static/js/sequence'), + 'pgadmin.node.statistics': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics'), 'pgadmin.node.server': path.join(__dirname, './pgadmin/browser/server_groups/servers/static/js/server'), 'pgadmin.node.server_group': path.join(__dirname, './pgadmin/browser/server_groups/static/js/server_group'), 'pgadmin.node.synonym': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/synonyms/static/js/synonym'), From ccd0b17d5b292c611e36bd6d11bc4e880846c73a Mon Sep 17 00:00:00 2001 From: Murtuza Zabuawala Date: Wed, 10 Jun 2026 10:02:14 +0530 Subject: [PATCH 2/4] chore: fixed additional review comments and added docs for the node --- docs/en_US/images/statistics_definition.png | Bin 0 -> 49224 bytes docs/en_US/images/statistics_general.png | Bin 0 -> 89250 bytes docs/en_US/images/statistics_sql.png | Bin 0 -> 52318 bytes docs/en_US/managing_database_objects.rst | 1 + docs/en_US/release_notes_9_16.rst | 1 + docs/en_US/statistics_dialog.rst | 75 ++++++++++++++++++ .../databases/schemas/statistics/__init__.py | 9 +-- .../statistics/static/js/statistics.ui.js | 4 +- .../statistics/sql/16_plus/create.sql | 2 +- .../statistics/sql/16_plus/properties.sql | 2 +- .../statistics/sql/16_plus/update.sql | 3 +- .../statistics/sql/17_plus/create.sql | 2 +- .../statistics/sql/17_plus/properties.sql | 2 +- .../statistics/sql/17_plus/update.sql | 3 +- .../statistics/sql/default/create.sql | 2 +- .../statistics/sql/default/properties.sql | 2 +- .../tests/statistics_test_data.json | 23 ++++++ .../tests/test_statistics_delete.py | 15 +++- .../statistics/tests/test_statistics_get.py | 17 ++-- .../schemas/statistics/tests/utils.py | 71 ++++++++++++++++- 20 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 docs/en_US/images/statistics_definition.png create mode 100644 docs/en_US/images/statistics_general.png create mode 100644 docs/en_US/images/statistics_sql.png create mode 100644 docs/en_US/statistics_dialog.rst diff --git a/docs/en_US/images/statistics_definition.png b/docs/en_US/images/statistics_definition.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4e0692f11330246549f1ec6a288f5b9a365b6d GIT binary patch literal 49224 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%0DBz3ls-hVvc;H84h%`_$ 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 literal 0 HcmV?d00001 diff --git a/docs/en_US/images/statistics_general.png b/docs/en_US/images/statistics_general.png new file mode 100644 index 0000000000000000000000000000000000000000..6aa2906e201180cdfa34092cab7ead168dcbf18a GIT binary patch literal 89250 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 ZUXZ*DtHf~=>|OzX;vzD_Wr8~1{|A}SbdUf5 literal 0 HcmV?d00001 diff --git a/docs/en_US/images/statistics_sql.png b/docs/en_US/images/statistics_sql.png new file mode 100644 index 0000000000000000000000000000000000000000..a78ab4df9f6a32eb7b7dbdcd20f9e33252c03f7f GIT binary patch literal 52318 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 literal 0 HcmV?d00001 diff --git a/docs/en_US/managing_database_objects.rst b/docs/en_US/managing_database_objects.rst index 5b297568555..a55d6d72d17 100644 --- a/docs/en_US/managing_database_objects.rst +++ b/docs/en_US/managing_database_objects.rst @@ -39,6 +39,7 @@ node, and select *Create Cast...* publication_dialog schema_dialog sequence_dialog + statistics_dialog subscription_dialog synonym_dialog trigger_function_dialog diff --git a/docs/en_US/release_notes_9_16.rst b/docs/en_US/release_notes_9_16.rst index f3f7114d0f5..7076b81499e 100644 --- a/docs/en_US/release_notes_9_16.rst +++ b/docs/en_US/release_notes_9_16.rst @@ -32,6 +32,7 @@ New features | `Issue #9626 `_ - Add support for the TOAST tuple target storage parameter in the Materialized View dialog. | `Issue #9646 `_ - Make the init container security context in the Helm chart configurable via containerSecurityContext, consistent with the main container. | `Issue #9699 `_ - Add support for closing a tab with a middle-click on its title. + | `Issue #2018 `_ - Added support for Extended Statistics objects (CREATE STATISTICS) under the Schema node, with full create, edit, delete, and SQL-generation support for column-based and expression-based statistics on PostgreSQL 14+. Housekeeping ************ diff --git a/docs/en_US/statistics_dialog.rst b/docs/en_US/statistics_dialog.rst new file mode 100644 index 00000000000..ef3094f4119 --- /dev/null +++ b/docs/en_US/statistics_dialog.rst @@ -0,0 +1,75 @@ +.. _statistics_dialog: + +************************** +`Statistics Dialog`:index: +************************** + +Use the *Statistics* dialog to define extended statistics on one or more columns +(or expressions) of a table. Extended statistics let PostgreSQL collect +correlation data across columns that can significantly improve query-plan +estimates for queries that filter or group by multiple columns. + +Extended statistics require PostgreSQL 14 or later. + +The *Statistics* dialog organizes options across the *General* and *Definition* +tabs. The *SQL* tab displays the SQL command generated by your selections. + +.. image:: images/statistics_general.png + :alt: Statistics dialog general tab + :align: center + +Use the fields in the *General* tab to identify the statistics object: + +* Use the *Name* field to enter a descriptive name. On PostgreSQL 16 and later, + the name is optional — PostgreSQL will auto-generate one if left blank. +* Use the *Owner* field to select the role that will own the statistics object. +* Use the *Schema* field to select the schema in which the statistics object + will reside. +* Use the *Comment* field to store an optional note about the statistics object. + +Click the *Definition* tab to continue. + +.. image:: images/statistics_definition.png + :alt: Statistics dialog definition tab + :align: center + +Use the fields in the *Definition* tab to describe the statistics: + +* Use the *Table* field to select the table on which the statistics will be + collected. The list is filtered to tables in the selected schema. +* Use the *Columns* field to select two or more columns. Hold *Ctrl* (or *Cmd* + on macOS) to select multiple columns. At least two columns are required when + collecting column-based statistics. +* Use the *Expressions* field to enter one or more SQL expressions separated by + commas (e.g. ``lower(col1), (col1 + col2)``). Specify expressions instead of + columns when you want statistics on derived values. +* Use the *Statistics types* field to choose which kinds of extended statistics + to collect: + + * *N-distinct* — estimates the number of distinct value combinations across + the selected columns or expressions. + * *Dependencies* — detects functional dependencies between columns, improving + estimates for queries with correlated ``WHERE`` clauses. + * *MCV (Most Common Values)* — records the most common combinations of values, + available on PostgreSQL 12 and later. + +Click the *SQL* tab to continue. + + +Your entries in the *Statistics* dialog generate a SQL command (see an example +below). Use the *SQL* tab for review; revisit or switch tabs to make any changes. + +Example +******* + +The following is an example of the SQL command generated by user selections in +the *Statistics* dialog: + +.. image:: images/statistics_sql.png + :alt: Statistics dialog SQL tab + :align: center + +* Click the *Info* button (i) to access online help. +* Click the *Save* button to save work. +* Click the *Close* button to exit without saving work. +* Click the *Reset* button to restore configuration parameters. diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py index d0428b39476..844f4dc56df 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py @@ -363,12 +363,9 @@ def _fetch_properties(self, scid, stid): if row.get('columns') is None: row['columns'] = [] - # Set has_expressions boolean based on expressions field - # expressions is pg_node_tree (bytea) - we just check if it exists - row['has_expressions'] = row.get('expressions') is not None - - # Remove expressions from display (it's internal PostgreSQL format) - row.pop('expressions', None) + # expression_list is the deparsed text from pg_get_expr; keep it for + # SQL-tab round-trip and set has_expressions for the properties switch + row['has_expressions'] = row.get('expression_list') is not None # Ensure stattarget has a default value if None if row.get('stattarget') is None: diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js index 7457476ac41..16ddb63a13b 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js @@ -116,7 +116,9 @@ export default class StatisticsSchema extends BaseUISchema { let tid = obj.getTableOid(state.table); return { type: 'select', - options: state.table ? ()=>obj.fieldOptions.getColumns({tid: tid}) : [], + options: (state.table && tid) + ? () => obj.fieldOptions.getColumns({tid: tid}) + : (state.columns || []).map(col => ({label: col, value: col})), optionsReloadBasis: state.table, }; }, diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql index d1c356f53f5..7a248f26d38 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql @@ -4,7 +4,7 @@ CREATE STATISTICS{% if data.name %}{% if add_not_exists_clause %} IF NOT EXISTS{ ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} - ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.expression_list %}{{ data.expression_list }}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} FROM {{ conn|qtIdent(data.schema, data.table) }}; {% if data.comment and data.name %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql index 1e38fd0d1f5..1663a0a757d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql @@ -20,7 +20,7 @@ SELECT CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, s.stxstattarget AS stattarget, {### stxexprs added in PostgreSQL 14 for expression statistics ###} - s.stxexprs AS expressions, + pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS expression_list, {### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} sd.stxdndistinct AS ndistinct_values, sd.stxddependencies AS dependencies_values, diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql index 2e528fc01dd..1e9b79e44bc 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql @@ -1,8 +1,7 @@ {### SQL to update extended statistics object (PostgreSQL 16+) ###} {### Rename statistics ###} {% if data.name and data.name != o_data.name %} -ALTER STATISTICS {% if data.schema and data.schema != o_data.schema %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% else %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% endif %} - +ALTER STATISTICS {{ conn|qtIdent(o_data.schema, o_data.name) }} RENAME TO {{ conn|qtIdent(data.name) }}; {% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql index b8102738f57..500d4982c8e 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql @@ -4,7 +4,7 @@ CREATE STATISTICS{% if data.name %}{% if add_not_exists_clause %} IF NOT EXISTS{ ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} - ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.expression_list %}{{ data.expression_list }}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} FROM {{ conn|qtIdent(data.schema, data.table) }}; {% if data.comment and data.name %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql index 1e38fd0d1f5..1663a0a757d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql @@ -20,7 +20,7 @@ SELECT CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, s.stxstattarget AS stattarget, {### stxexprs added in PostgreSQL 14 for expression statistics ###} - s.stxexprs AS expressions, + pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS expression_list, {### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} sd.stxdndistinct AS ndistinct_values, sd.stxddependencies AS dependencies_values, diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql index 830ea36f321..802aa59f93e 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql @@ -1,8 +1,7 @@ {### SQL to update extended statistics object (PostgreSQL 17+) ###} {### Rename statistics ###} {% if data.name and data.name != o_data.name %} -ALTER STATISTICS {% if data.schema and data.schema != o_data.schema %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% else %}{{ conn|qtIdent(o_data.schema, o_data.name) }}{% endif %} - +ALTER STATISTICS {{ conn|qtIdent(o_data.schema, o_data.name) }} RENAME TO {{ conn|qtIdent(data.name) }}; {% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql index edbe5bffdd5..e54d25b59d8 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql @@ -4,7 +4,7 @@ CREATE STATISTICS{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} - ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.expression_list %}{{ data.expression_list }}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} FROM {{ conn|qtIdent(data.schema, data.table) }}; {% if data.comment %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql index 1e38fd0d1f5..1663a0a757d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql @@ -20,7 +20,7 @@ SELECT CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, s.stxstattarget AS stattarget, {### stxexprs added in PostgreSQL 14 for expression statistics ###} - s.stxexprs AS expressions, + pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS expression_list, {### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} sd.stxdndistinct AS ndistinct_values, sd.stxddependencies AS dependencies_values, diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json index 91e5f2555a7..9ff920e98b4 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json @@ -90,6 +90,29 @@ "test_result_data": {} } }, + { + "name": "Create statistics: With expressions (PG 14+).", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000, + "skip_msg": "Statistics node not supported below PG 14" + }, + "test_data": { + "name": "test_stats_expr", + "schema": "public", + "table": "test_table", + "expression_list": "lower(col3), (col1 + col2)", + "stat_types": ["ndistinct"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, { "name": "Create statistics: With invalid data - Less than 2 columns.", "url": "/browser/statistics/obj/", diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py index dff34617216..5118d62935c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py @@ -140,15 +140,22 @@ def runTest(self): self.assertIsNone(statistics_response_2, "Second deleted statistics still present") else: + if 'statistics_id' in self.data: + self.statistics_id = self.data["statistics_id"] if self.mocking_required: with patch( self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])] ): - response = statistics_utils.api_delete(self) - elif 'statistics_id' in self.data: - self.statistics_id = self.data["statistics_id"] - response = statistics_utils.api_delete(self) + response = ( + statistics_utils.api_delete(self, '') + if self.is_list else statistics_utils.api_delete(self) + ) + else: + response = ( + statistics_utils.api_delete(self, '') + if self.is_list else statistics_utils.api_delete(self) + ) # Assert response utils.assert_status_code(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py index 6722929c007..d1377a38a5f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py @@ -130,16 +130,23 @@ def runTest(self): # Verify stat_types are returned self.assertIn('stat_types', response_data) else: + if 'statistics_id' in self.data: + self.statistics_id = self.data["statistics_id"] + get_call = ( + statistics_utils.api_get(self, '') + if self.is_list else statistics_utils.api_get(self) + ) if self.mocking_required: with patch( self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])] ): - response = statistics_utils.api_get(self) - elif 'statistics_id' in self.data: - # Non-existing statistics id - self.statistics_id = self.data["statistics_id"] - response = statistics_utils.api_get(self) + response = ( + statistics_utils.api_get(self, '') + if self.is_list else statistics_utils.api_get(self) + ) + else: + response = get_call # Assert response utils.assert_status_code(self, response) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py index 2cc70bb0868..573035f5c30 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py @@ -190,6 +190,67 @@ def create_statistics(server, db_name, schema_name, table_name, raise +def create_statistics_with_expressions(server, db_name, schema_name, + table_name, statistics_name, + expressions, stat_types): + """ + Creates a statistics object using expressions rather than plain columns. + + Args: + server: server details + db_name: database name + schema_name: schema name + table_name: table name + statistics_name: statistics object name + expressions: list of SQL expression strings + stat_types: list of statistics types (ndistinct, dependencies, mcv) + + Returns: + statistics OID + """ + connection = None + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + old_isolation_level = connection.isolation_level + test_utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + + stat_types_str = ', '.join(stat_types) + exprs_str = ', '.join(f'({e})' for e in expressions) + query = ( + f"CREATE STATISTICS {schema_name}.{statistics_name} " + f"({stat_types_str}) ON {exprs_str} " + f"FROM {schema_name}.{table_name}" + ) + pg_cursor.execute(query) + + pg_cursor.execute( + f"SELECT s.oid FROM pg_catalog.pg_statistic_ext s " + f"JOIN pg_catalog.pg_namespace n ON s.stxnamespace = n.oid " + f"WHERE s.stxname = '{statistics_name}' " + f"AND n.nspname = '{schema_name}'" + ) + statistics = pg_cursor.fetchone() + statistics_oid = statistics[0] + test_utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + + return statistics_oid + except Exception: + traceback.print_exc(file=sys.stderr) + raise + finally: + if connection: + connection.close() + + def verify_statistics(server, db_name, statistics_name): """ This function verifies that a statistics object exists in the database. @@ -319,10 +380,12 @@ def get_statistics_columns(server, db_name, statistics_oid): ) pg_cursor = connection.cursor() pg_cursor.execute( - f"SELECT array_agg(a.attname) FROM pg_catalog.pg_attribute a " - f"JOIN pg_catalog.pg_statistic_ext s ON a.attrelid = s.stxrelid " - f"WHERE s.oid = {statistics_oid} " - f"AND a.attnum = ANY(s.stxkeys)" + f"SELECT array_agg(a.attname ORDER BY s.stxkeys_pos) " + f"FROM pg_catalog.pg_statistic_ext s, " + f"unnest(s.stxkeys) WITH ORDINALITY AS s(attnum, stxkeys_pos) " + f"JOIN pg_catalog.pg_attribute a " + f" ON a.attrelid = s.stxrelid AND a.attnum = s.attnum " + f"WHERE s.oid = {statistics_oid}" ) columns = pg_cursor.fetchone() connection.close() From 4ae1264ba1cadcb355082af835846c181937362b Mon Sep 17 00:00:00 2001 From: Dave Page Date: Tue, 18 Aug 2026 11:26:50 +0100 Subject: [PATCH 3/4] Statistics node: fix the review findings Follow-up work on Murtuza Zabuawala's Extended Statistics node, who has moved on, so the remaining review findings are addressed here. The schema diff integration could not work at all: get_sql_from_diff() read source_params, target_params and comp_status from its keyword arguments, none of which the schema diff engine passes, so every comparison involving a statistics object fell through to a subscript of None. It now follows the contract the engine actually uses, delegating to sql() and delete() so that check_precondition binds the connection to the side being generated, and honouring target_schema. The properties query joined pg_statistic_ext_data unconditionally, which only a superuser may read: not even pg_read_all_stats grants access, so the node failed outright for everybody else. The values ANALYZE collected are now selected only when has_table_privilege() says we may, and the dialog hides the Computed Statistics group when we may not. From PostgreSQL 15 that catalog holds a row per stxdinherit variant, which listed inheritance parents twice, so 15_plus takes the non-inherited row in preference to the inherited one a partitioned parent has. DROP STATISTICS took its CASCADE before the object name, which is a syntax error, so Drop (Cascade) could never have worked. Definitions mixing columns and expressions lost their columns, because the ON clause emitted one or the other: the SQL tab, and the CREATE schema diff generates, described only part of the object. The expression list is also no longer split on commas, which mangled anything with an argument list such as coalesce(col1, col2) and made the SQL preview disagree with what was executed; it is passed to the server as entered. Also: the statistics target is no longer silently ignored on PG 14 and 15, where the default template had no SET STATISTICS; an owner chosen at create time is applied rather than ignored; the reverse engineered SQL carries the owner and a non-default statistics target; a name omitted on PG 16+ no longer leaves the tree without the new node; the dialog seeds the schema from the tree rather than from the node's own label, defaults the owner, and requires a name only below PG 16; the expressions are visible in the Properties view; the ANALYZE values and the raw catalog columns are excluded from schema diff comparison, which otherwise reported identical objects as different; and request.form is copied before keys are added to it. Tests cover what was broken: the mixed definition round trip and the modified SQL, a comma bearing expression, a nameless create on PG 16+, cascade delete, the statistics target and the comment, and a schema diff test over two databases asserting that identical objects compare as identical whatever ANALYZE recorded, that the generated SQL describes columns and expressions alike, and that applying it settles every difference. A Jest spec covers the dialog schema. Redundant version buckets are removed: properties.sql was identical in three, and create.sql and update.sql duplicated in one apiece. --- docs/en_US/release_notes_9_16.rst | 1 - docs/en_US/statistics_dialog.rst | 52 ++-- .../databases/schemas/statistics/__init__.py | 216 +++++++++------ .../statistics/static/js/statistics.js | 10 +- .../statistics/static/js/statistics.ui.js | 46 +-- .../sql/{16_plus => 15_plus}/properties.sql | 21 +- .../statistics/sql/15_plus/stats.sql | 45 +++ .../statistics/sql/16_plus/create.sql | 15 +- .../statistics/sql/16_plus/update.sql | 30 -- .../statistics/sql/17_plus/create.sql | 14 - .../statistics/sql/17_plus/properties.sql | 40 --- .../sql/default/backend_support.sql | 7 +- .../statistics/sql/default/create.sql | 14 +- .../statistics/sql/default/delete.sql | 12 +- .../statistics/sql/default/get_name.sql | 7 + .../statistics/sql/default/get_oid.sql | 18 +- .../statistics/sql/default/properties.sql | 7 +- .../statistics/sql/default/stats.sql | 18 +- .../statistics/sql/default/update.sql | 8 +- .../tests/statistics_test_data.json | 116 ++++++++ .../statistics/tests/test_statistics_add.py | 7 +- .../tests/test_statistics_delete.py | 2 +- .../tests/test_statistics_delete_multiple.py | 2 +- .../statistics/tests/test_statistics_get.py | 2 +- .../statistics/tests/test_statistics_put.py | 2 +- .../statistics/tests/test_statistics_sql.py | 140 ++++++++++ .../schemas/statistics/tests/utils.py | 90 +++++- .../schema_diff/tests/pg/17_plus/source.sql | 14 + .../schema_diff/tests/pg/17_plus/target.sql | 14 + .../schema_diff/tests/pg/default/source.sql | 14 + .../schema_diff/tests/pg/default/target.sql | 14 + .../tests/test_schema_diff_statistics.py | 261 ++++++++++++++++++ .../schema_ui_files/statistics.ui.spec.js | 116 ++++++++ 33 files changed, 1115 insertions(+), 260 deletions(-) rename web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/{16_plus => 15_plus}/properties.sql (67%) create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/stats.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_name.sql create mode 100644 web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_sql.py create mode 100644 web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py create mode 100644 web/regression/javascript/schema_ui_files/statistics.ui.spec.js diff --git a/docs/en_US/release_notes_9_16.rst b/docs/en_US/release_notes_9_16.rst index 7076b81499e..f3f7114d0f5 100644 --- a/docs/en_US/release_notes_9_16.rst +++ b/docs/en_US/release_notes_9_16.rst @@ -32,7 +32,6 @@ New features | `Issue #9626 `_ - Add support for the TOAST tuple target storage parameter in the Materialized View dialog. | `Issue #9646 `_ - Make the init container security context in the Helm chart configurable via containerSecurityContext, consistent with the main container. | `Issue #9699 `_ - Add support for closing a tab with a middle-click on its title. - | `Issue #2018 `_ - Added support for Extended Statistics objects (CREATE STATISTICS) under the Schema node, with full create, edit, delete, and SQL-generation support for column-based and expression-based statistics on PostgreSQL 14+. Housekeeping ************ diff --git a/docs/en_US/statistics_dialog.rst b/docs/en_US/statistics_dialog.rst index ef3094f4119..21bb7f19360 100644 --- a/docs/en_US/statistics_dialog.rst +++ b/docs/en_US/statistics_dialog.rst @@ -18,13 +18,30 @@ tabs. The *SQL* tab displays the SQL command generated by your selections. :alt: Statistics dialog general tab :align: center -Use the fields in the *General* tab to identify the statistics object: +Use the fields in the *General* tab to describe the statistics object: -* Use the *Name* field to enter a descriptive name. On PostgreSQL 16 and later, - the name is optional — PostgreSQL will auto-generate one if left blank. +* Use the *Name* field to enter a descriptive name. On PostgreSQL 16 and later + the name is optional, and the server will generate one from the table and the + columns or expressions if you leave it blank. * Use the *Owner* field to select the role that will own the statistics object. * Use the *Schema* field to select the schema in which the statistics object will reside. +* Use the *Table* field to select the table on which the statistics will be + collected. The list is filtered to tables in the selected schema. +* Use the *Columns* field to select two or more columns. Hold *Ctrl* (or *Cmd* + on macOS) to select multiple columns. At least two columns are required when + collecting column based statistics, although a single column is enough when + it is combined with an expression. +* Use the *Statistics types* field to choose which kinds of extended statistics + to collect: + + * *N-distinct*, which estimates the number of distinct value combinations + across the selected columns or expressions. + * *Dependencies*, which detects functional dependencies between columns, + improving estimates for queries with correlated ``WHERE`` clauses. + * *MCV (Most Common Values)*, which records the most common combinations of + values. + * Use the *Comment* field to store an optional note about the statistics object. Click the *Definition* tab to continue. @@ -33,25 +50,18 @@ Click the *Definition* tab to continue. :alt: Statistics dialog definition tab :align: center -Use the fields in the *Definition* tab to describe the statistics: - -* Use the *Table* field to select the table on which the statistics will be - collected. The list is filtered to tables in the selected schema. -* Use the *Columns* field to select two or more columns. Hold *Ctrl* (or *Cmd* - on macOS) to select multiple columns. At least two columns are required when - collecting column-based statistics. * Use the *Expressions* field to enter one or more SQL expressions separated by - commas (e.g. ``lower(col1), (col1 + col2)``). Specify expressions instead of - columns when you want statistics on derived values. -* Use the *Statistics types* field to choose which kinds of extended statistics - to collect: - - * *N-distinct* — estimates the number of distinct value combinations across - the selected columns or expressions. - * *Dependencies* — detects functional dependencies between columns, improving - estimates for queries with correlated ``WHERE`` clauses. - * *MCV (Most Common Values)* — records the most common combinations of values, - available on PostgreSQL 12 and later. + commas (for example ``lower(col1), (col1 + col2)``). Each expression must be + enclosed in parentheses unless it is a function call, and the list is passed + to the server exactly as you enter it, so an expression may itself contain + commas. Expressions may be given instead of columns, or alongside them when + you want statistics over a mixture of the two. + +When you open the dialog on an existing statistics object, the *Properties* +view also reports the statistics target and, for superusers, the values that +``ANALYZE`` has collected. Those values are held in +``pg_catalog.pg_statistic_ext_data``, which only a superuser may read, so the +*Computed Statistics* group is hidden for everybody else. Click the *SQL* tab to continue. diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py index 844f4dc56df..a0d884f4314 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## @@ -163,7 +163,19 @@ class StatisticsView(PGChildNodeView, SchemaDiffObjectCompare): 'dependent': [{'get': 'dependents'}] }) - keys_to_ignore = ['oid', 'oid-2', 'schemaoid', 'tableoid'] + # 'column_attnums' and 'stat_types_raw' are the raw catalog representation + # of data we also expose in a readable form, and the '*_values' keys hold + # the data ANALYZE happened to collect, which differs between two servers + # holding identical definitions. None of them may take part in a schema + # diff comparison. + keys_to_ignore = ['oid', 'oid-2', 'schemaoid', 'tableoid', + 'column_attnums', 'stat_types_raw', 'ndistinct_values', + 'dependencies_values', 'has_mcv_values', + 'has_ext_data_access'] + + # Whether the connected user may read pg_statistic_ext_data, resolved + # lazily and reset for every request by check_precondition. + ext_data_access = None _PROPERTIES_SQL = 'properties.sql' _NODES_SQL = 'nodes.sql' @@ -171,6 +183,7 @@ class StatisticsView(PGChildNodeView, SchemaDiffObjectCompare): _UPDATE_SQL = 'update.sql' _DELETE_SQL = 'delete.sql' _OID_SQL = 'get_oid.sql' + _GET_NAME_SQL = 'get_name.sql' _STATS_SQL = 'stats.sql' _COLL_STATS_SQL = 'coll_stats.sql' @@ -195,6 +208,7 @@ def wrapped(self, *args, **kwargs): else: self.conn = self.manager.connection() + self.ext_data_access = None self.datistemplate = False if ( self.manager.db_info is not None and @@ -229,7 +243,8 @@ def list(self, gid, sid, did, scid): """ SQL = render_template( "/".join([self.template_path, self._PROPERTIES_SQL]), - scid=scid + scid=scid, + has_ext_data_access=self._has_ext_data_access() ) status, res = self.conn.execute_dict(SQL) @@ -322,6 +337,29 @@ def properties(self, gid, sid, did, scid, stid): status=200 ) + def _has_ext_data_access(self): + """ + pg_catalog.pg_statistic_ext_data holds the data ANALYZE collected, and + is readable by superusers only: not even pg_read_all_stats grants + access to it. Joining it unconditionally would make the whole node + unusable for everybody else, so ask first and leave the computed + values out when we cannot read them. + + The answer is cached for the lifetime of the request, which keeps + schema diff from asking once per object. + + Returns: + True if the connected user can read pg_statistic_ext_data + """ + if self.ext_data_access is None: + status, res = self.conn.execute_scalar( + "SELECT pg_catalog.has_table_privilege(" + "'pg_catalog.pg_statistic_ext_data', 'SELECT')" + ) + self.ext_data_access = bool(status and res) + + return self.ext_data_access + def _fetch_properties(self, scid, stid): """ This function is used to fetch the properties of the specified object @@ -335,7 +373,8 @@ def _fetch_properties(self, scid, stid): """ sql = render_template( "/".join([self.template_path, self._PROPERTIES_SQL]), - scid=scid, stid=stid + scid=scid, stid=stid, + has_ext_data_access=self._has_ext_data_access() ) status, res = self.conn.execute_dict(sql) @@ -363,9 +402,10 @@ def _fetch_properties(self, scid, stid): if row.get('columns') is None: row['columns'] = [] - # expression_list is the deparsed text from pg_get_expr; keep it for - # SQL-tab round-trip and set has_expressions for the properties switch - row['has_expressions'] = row.get('expression_list') is not None + # The computed values are only present when the connected user can + # read pg_statistic_ext_data; flag it so the dialog can say so rather + # than implying ANALYZE has not run. + row['has_ext_data_access'] = self._has_ext_data_access() # Ensure stattarget has a default value if None if row.get('stattarget') is None: @@ -394,7 +434,9 @@ def create(self, gid, sid, did, scid): Returns: JSON response with the new statistics node """ - data = request.form if request.form else json.loads( + # request.form is an ImmutableMultiDict, and we add keys below, so + # take a copy rather than mutating it. + data = dict(request.form) if request.form else json.loads( request.data ) @@ -416,24 +458,14 @@ def create(self, gid, sid, did, scid): ).format(arg) ) - # Parse expression_list into expressions array if provided + # The expression list is passed to the server verbatim: it is a list + # of SQL expressions, and splitting it on commas here would mangle + # anything with an argument list, such as coalesce(col1, col2). has_columns = 'columns' in data and len(data.get('columns', [])) > 0 - has_expression_list = 'expression_list' in data and data.get( - 'expression_list', '').strip() - - # Convert expression_list (comma-separated) to expressions array - if has_expression_list: - expr_list = data.get('expression_list', '').strip() - # Split by comma and strip whitespace from each expression - data['expressions'] = [ - {'expression': expr.strip()} - for expr in expr_list.split(',') - if expr.strip() - ] - has_expressions = len(data['expressions']) > 0 - else: - data['expressions'] = [] - has_expressions = False + has_expressions = bool( + (data.get('expression_list') or '').strip() + ) + data['expression_list'] = (data.get('expression_list') or '').strip() if not has_columns and not has_expressions: return make_json_response( @@ -444,8 +476,10 @@ def create(self, gid, sid, did, scid): ) ) - # Validate minimum 2 items for multi-column statistics - # (not for single expression) + # A statistics object needs at least two items, unless it is the + # single expression form (CREATE STATISTICS ... ON (expr) FROM ...), + # so only the columns-only case can be checked here; PostgreSQL has + # the final say on the expression list. if has_columns and not has_expressions and \ len(data.get('columns', [])) < 2: return make_json_response( @@ -480,41 +514,35 @@ def create(self, gid, sid, did, scid): if not status: return internal_server_error(errormsg=msg) - # Get OID of newly created statistics - # For PostgreSQL 16+, if name is not provided, it's auto-generated - if data.get('name'): - sql = render_template( - "/".join([self.template_path, self._OID_SQL]), - name=data['name'], - schema=data['schema'], - conn=self.conn - ) - sql = sql.strip('\n').strip(' ') + # Get the OID and name of the newly created object. On PostgreSQL 16+ + # the name is optional, in which case the server generates one, so + # look the object up by the table it was defined on and take the + # newest one rather than by name. + sql = render_template( + "/".join([self.template_path, self._OID_SQL]), + name=data.get('name'), + schema=data['schema'], + table=data['table'], + conn=self.conn + ) + sql = sql.strip('\n').strip(' ') - status, rset = self.conn.execute_2darray(sql) - if not status: - return internal_server_error(errormsg=rset) + status, rset = self.conn.execute_2darray(sql) + if not status: + return internal_server_error(errormsg=rset) - row = rset['rows'][0] - return jsonify( - node=self.blueprint.generate_browser_node( - row['oid'], - scid, - data['name'], - icon=self.node_icon - ) - ) - else: - # For auto-generated names in PG 16+, we need to query for the - # latest statistics - # This is a limitation - we'll return success but - # can't return the node - return make_json_response( - success=1, - info=_( - "Statistics created successfully with auto-generated name" - ) + if len(rset['rows']) == 0: + return gone(errormsg=self.not_found_error_msg()) + + row = rset['rows'][0] + return jsonify( + node=self.blueprint.generate_browser_node( + row['oid'], + scid, + row['name'], + icon=self.node_icon ) + ) @check_precondition(action='delete') def delete(self, gid, sid, did, scid, stid=None, only_sql=False): @@ -546,7 +574,7 @@ def delete(self, gid, sid, did, scid, stid=None, only_sql=False): for stid in data['ids']: # Fetch statistics details first sql = render_template( - "/".join([self.template_path, self._DELETE_SQL]), + "/".join([self.template_path, self._GET_NAME_SQL]), stid=stid ) status, res = self.conn.execute_dict(sql) @@ -600,7 +628,7 @@ def update(self, gid, sid, did, scid, stid): data = request.form if request.form else json.loads( request.data ) - sql, _ = self.get_SQL(gid, sid, did, data, scid, stid) + sql, _sql_name = self.get_SQL(gid, sid, did, data, scid, stid) # Most probably this is due to error if not isinstance(sql, str): @@ -752,16 +780,28 @@ def sql(self, gid, sid, did, scid, stid, **kwargs): did: Database ID scid: Schema ID stid: Statistics ID + kwargs: target_schema to generate the SQL against another schema, + and json_resp=False to get the SQL itself back, both used + by schema diff """ + target_schema = kwargs.get('target_schema', None) + json_resp = kwargs.get('json_resp', True) + status, res = self._fetch_properties(scid, stid) if not status: return res + if target_schema: + res['schema'] = target_schema + sql = render_template( "/".join([self.template_path, self._CREATE_SQL]), data=res, conn=self.conn, add_not_exists_clause=False ) + if not json_resp: + return sql + return ajax_response(response=sql) @check_precondition(action='stats') @@ -781,7 +821,8 @@ def statistics(self, gid, sid, did, scid, stid=None): sql = render_template( "/".join([self.template_path, self._STATS_SQL]), stid=stid, - conn=self.conn + conn=self.conn, + has_ext_data_access=self._has_ext_data_access() ) else: # Collection statistics @@ -874,39 +915,38 @@ def fetch_objects_to_compare(self, sid, did, scid): def get_sql_from_diff(self, **kwargs): """ - This function generates SQL from model data for schema diff. - - Args: - kwargs: Additional parameters + This function is used to get the DDL/DML statements for schema diff. - Returns: - SQL string + :param kwargs + :return: SQL string """ gid = kwargs.get('gid') sid = kwargs.get('sid') did = kwargs.get('did') scid = kwargs.get('scid') - source_params = kwargs.get('source_params') - target_params = kwargs.get('target_params') - comp_status = kwargs.get('comp_status') - - if comp_status == 'source_only': - # Object exists in source only - generate CREATE - sql, _ = self.get_SQL(gid=gid, sid=sid, did=did, - data=source_params, scid=scid) - elif comp_status == 'target_only': - # Object exists in target only - generate DROP - sql = render_template( - "/".join([self.template_path, self._DELETE_SQL]), - name=target_params['name'], - schema=target_params['schema'], - cascade=False - ) + oid = kwargs.get('oid') + data = kwargs.get('data', None) + drop_sql = kwargs.get('drop_sql', False) + target_schema = kwargs.get('target_schema', None) + + # Each branch goes through a view method carrying the + # check_precondition decorator, so that the connection is bound to the + # server and database named in the parameters: schema diff calls this + # for the source and the target in turn. + if data: + if target_schema: + data['schema'] = target_schema + sql, _sql_name = self.get_SQL(gid=gid, sid=sid, did=did, + data=data, scid=scid, stid=oid) + elif drop_sql: + sql = self.delete(gid=gid, sid=sid, did=did, scid=scid, + stid=oid, only_sql=True) + elif target_schema: + sql = self.sql(gid=gid, sid=sid, did=did, scid=scid, stid=oid, + target_schema=target_schema, json_resp=False) else: - # Object exists in both - generate ALTER - sql, _ = self.get_SQL(gid=gid, sid=sid, did=did, - data=source_params, scid=scid, - stid=target_params['oid']) + sql = self.sql(gid=gid, sid=sid, did=did, scid=scid, stid=oid, + json_resp=False) return sql diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js index 3aaffd910b6..f150406207d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js @@ -2,7 +2,7 @@ // // pgAdmin 4 - PostgreSQL Tools // -// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// Copyright (C) 2013 - 2026, The pgAdmin Development Team // This software is released under the PostgreSQL Licence // ////////////////////////////////////////////////////////////// @@ -43,7 +43,7 @@ define('pgadmin.node.statistics', [ collection_type: 'coll-statistics', hasSQL: true, hasDepends: true, - hasStatistics: false, + hasStatistics: true, Init: function() { /* Avoid multiple registration of menus */ if (this.initialized) @@ -110,8 +110,10 @@ define('pgadmin.node.statistics', [ } }, { - schema: itemNodeData.label, - } + owner: pgBrowser.serverInfo[treeNodeInfo.server._id].user.name, + schema: ('schema' in treeNodeInfo) ? treeNodeInfo.schema.label : '', + }, + treeNodeInfo ); }, }); diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js index 16ddb63a13b..d03d1a61842 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js @@ -2,7 +2,7 @@ // // pgAdmin 4 - PostgreSQL Tools // -// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// Copyright (C) 2013 - 2026, The pgAdmin Development Team // This software is released under the PostgreSQL Licence // ////////////////////////////////////////////////////////////// @@ -12,7 +12,7 @@ import BaseUISchema from 'sources/SchemaView/base_schema.ui'; import { isEmptyString } from '../../../../../../../../static/js/validators'; export default class StatisticsSchema extends BaseUISchema { - constructor(fieldOptions={}, initValues={}) { + constructor(fieldOptions={}, initValues={}, nodeInfo={}) { super({ name: undefined, oid: undefined, @@ -20,12 +20,12 @@ export default class StatisticsSchema extends BaseUISchema { table: undefined, columns: [], expression_list: undefined, - has_expressions: false, stat_types: [], stattarget: undefined, owner: undefined, comment: undefined, is_sys_obj: undefined, + has_ext_data_access: true, ...initValues, }); @@ -36,9 +36,16 @@ export default class StatisticsSchema extends BaseUISchema { getColumns: null, ...fieldOptions, }; + this.nodeInfo = nodeInfo; this.allTablesOptions = []; } + get isNameOptional() { + // PostgreSQL 16 made the statistics name optional, generating one from + // the table and the columns or expressions when it is left blank. + return (this.nodeInfo?.server?.version ?? 0) >= 160000; + } + getTableOid(tabName) { // Fetch the table OID from table name for(const t of this.allTablesOptions) { @@ -60,7 +67,10 @@ export default class StatisticsSchema extends BaseUISchema { label: gettext('Name'), type: 'text', mode: ['properties', 'create', 'edit'], - helpMessage: gettext('Statistics name'), + noEmpty: !obj.isNameOptional, + helpMessage: obj.isNameOptional + ? gettext('Leave blank to let the server generate a name.') + : gettext('Statistics name'), }, { id: 'oid', @@ -129,7 +139,7 @@ export default class StatisticsSchema extends BaseUISchema { multiple: true, allowClear: true, }, - helpMessage: gettext('Select at least 2 columns for multi-column statistics'), + helpMessage: gettext('Select at least two columns, or one column alongside an expression.'), depChange: (state)=>{ // Clear columns when table changes if(!state.table) { @@ -161,19 +171,10 @@ export default class StatisticsSchema extends BaseUISchema { id: 'expression_list', label: gettext('Expressions'), type: 'text', - mode: ['create'], + mode: ['properties', 'create'], group: gettext('Definition'), - helpMessage: gettext('Enter SQL expression(s) for expression-based statistics, separated by commas'), - }, - { - id: 'has_expressions', - label: gettext('Has expressions?'), - type: 'switch', - mode: ['properties'], - readonly: true, - disabled: true, - helpMessage: gettext('Indicates if this statistics object includes expression-based statistics'), - + readonly: (state)=>!obj.isNew(state), + helpMessage: gettext('Enter one or more SQL expressions, separated by commas, each in parentheses unless it is a function call. The list is passed to the server as entered.'), }, { id: 'stattarget', @@ -192,6 +193,9 @@ export default class StatisticsSchema extends BaseUISchema { readonly: true, disabled: true, group: gettext('Computed Statistics'), + // The computed values live in pg_statistic_ext_data, which only a + // superuser may read; hide them rather than showing them as empty. + visible: (state)=>state.has_ext_data_access, helpMessage: gettext('N-distinct coefficients computed by ANALYZE'), }, { @@ -202,6 +206,7 @@ export default class StatisticsSchema extends BaseUISchema { readonly: true, disabled: true, group: gettext('Computed Statistics'), + visible: (state)=>state.has_ext_data_access, helpMessage: gettext('Functional dependency statistics computed by ANALYZE'), }, { @@ -212,6 +217,7 @@ export default class StatisticsSchema extends BaseUISchema { readonly: true, disabled: true, group: gettext('Computed Statistics'), + visible: (state)=>state.has_ext_data_access, helpMessage: gettext('Indicates if most-common values data is available for this statistics object'), }, { @@ -241,7 +247,10 @@ export default class StatisticsSchema extends BaseUISchema { setError('table', null); } - // Validate columns or expressions are provided + // Validate columns or expressions are provided. A statistics object + // needs at least two items, unless it is the single expression form, so + // only the columns-only case can be checked here: the server has the + // final say on the expression list. const hasColumns = state.columns && state.columns.length > 0; const hasExpressions = state.expression_list && state.expression_list.trim().length > 0; @@ -250,7 +259,6 @@ export default class StatisticsSchema extends BaseUISchema { setError('expression_list', gettext('Either columns or expressions must be specified.')); errors = true; } else { - // Validate minimum 2 columns for multi-column statistics (when no expressions) if (hasColumns && !hasExpressions && state.columns.length < 2 && !state.oid) { setError('columns', gettext('At least 2 columns must be selected for multi-column statistics.')); errors = true; diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/properties.sql similarity index 67% rename from web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql rename to web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/properties.sql index 1663a0a757d..2c862a1edd3 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/properties.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/properties.sql @@ -1,4 +1,4 @@ -{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 14+) ###} +{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 15+) ###} {% if scid %} SELECT s.oid, @@ -21,15 +21,30 @@ SELECT s.stxstattarget AS stattarget, {### stxexprs added in PostgreSQL 14 for expression statistics ###} pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS expression_list, -{### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} +{### pg_statistic_ext_data is readable by superusers only, so the data ###} +{### ANALYZE collected is only selected when we are allowed to read it ###} +{% if has_ext_data_access %} sd.stxdndistinct AS ndistinct_values, sd.stxddependencies AS dependencies_values, CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS has_mcv_values, +{% endif %} des.description AS comment FROM pg_catalog.pg_statistic_ext s LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid - LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid +{### PostgreSQL 15 added stxdinherit, so an inheritance parent has a row ###} +{### per variant and a plain join would list the object twice; prefer the ###} +{### non-inherited row, falling back to the inherited one, which is all a ###} +{### partitioned parent has ###} +{% if has_ext_data_access %} + LEFT JOIN LATERAL ( + SELECT d.stxdndistinct, d.stxddependencies, d.stxdmcv + FROM pg_catalog.pg_statistic_ext_data d + WHERE d.stxoid = s.oid + ORDER BY d.stxdinherit + LIMIT 1 + ) sd ON true +{% endif %} LEFT OUTER JOIN pg_catalog.pg_description des ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) WHERE s.stxnamespace = {{scid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/stats.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/stats.sql new file mode 100644 index 00000000000..ecfd1e6004e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/stats.sql @@ -0,0 +1,45 @@ +{### Get statistics for an individual extended statistics object (PG 15+) ###} +SELECT + s.stxname AS {{ conn|qtIdent(_('Name')) }}, + t.relname AS {{ conn|qtIdent(_('Table')) }}, + (SELECT string_agg(a.attname, ', ' ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = s.stxrelid + AND a.attnum = ANY(s.stxkeys) + ) AS {{ conn|qtIdent(_('Columns')) }}, + pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS {{ conn|qtIdent(_('Expressions')) }}, + CASE + WHEN s.stxkind IS NOT NULL THEN + array_to_string( + ARRAY( + SELECT CASE kind + WHEN 'd' THEN 'ndistinct' + WHEN 'f' THEN 'dependencies' + WHEN 'm' THEN 'mcv' + WHEN 'e' THEN 'expressions' + END + FROM unnest(s.stxkind) AS kind + ), ', ' + ) + ELSE '' + END AS {{ conn|qtIdent(_('Statistics Types')) }} +{### The values ANALYZE collected live in pg_statistic_ext_data, which ###} +{### only a superuser may read, and PostgreSQL 15 gave inheritance ###} +{### parents one row per variant ###} +{% if has_ext_data_access %} + ,sd.stxdndistinct AS {{ conn|qtIdent(_('N-Distinct Coefficients')) }}, + sd.stxddependencies AS {{ conn|qtIdent(_('Functional Dependencies')) }}, + CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS {{ conn|qtIdent(_('Has Most Common Values')) }} +{% endif %} +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid +{% if has_ext_data_access %} + LEFT JOIN LATERAL ( + SELECT d.stxdndistinct, d.stxddependencies, d.stxdmcv + FROM pg_catalog.pg_statistic_ext_data d + WHERE d.stxoid = s.oid + ORDER BY d.stxdinherit + LIMIT 1 + ) sd ON true +{% endif %} +WHERE s.oid = {{stid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql index 7a248f26d38..aafd3ded6af 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql @@ -1,12 +1,23 @@ {### SQL to create extended statistics object (PostgreSQL 16+) ###} -{### Supports expression-based statistics and optional name ###} +{### The name is optional from PostgreSQL 16, in which case the server ###} +{### generates one, and IF NOT EXISTS may only be used with a name ###} CREATE STATISTICS{% if data.name %}{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn|qtIdent(data.schema, data.name) }}{% endif %}{% if data.stat_types and data.stat_types|length > 0 %} ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} - ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.expression_list %}{{ data.expression_list }}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + ON {% if data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% if data.columns and data.expression_list %}, {% endif %}{% if data.expression_list %}{{ data.expression_list }}{% endif %} FROM {{ conn|qtIdent(data.schema, data.table) }}; +{% if data.owner and data.name %} + +ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + OWNER TO {{ conn|qtIdent(data.owner) }}; +{% endif %} +{% if data.name and data.stattarget is defined and data.stattarget is not none and data.stattarget != -1 %} + +ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + SET STATISTICS {{ data.stattarget }}; +{% endif %} {% if data.comment and data.name %} COMMENT ON STATISTICS {{ conn|qtIdent(data.schema, data.name) }} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql deleted file mode 100644 index 1e9b79e44bc..00000000000 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/update.sql +++ /dev/null @@ -1,30 +0,0 @@ -{### SQL to update extended statistics object (PostgreSQL 16+) ###} -{### Rename statistics ###} -{% if data.name and data.name != o_data.name %} -ALTER STATISTICS {{ conn|qtIdent(o_data.schema, o_data.name) }} - RENAME TO {{ conn|qtIdent(data.name) }}; - -{% endif %} -{### Change schema ###} -{% if data.schema and data.schema != o_data.schema %} -ALTER STATISTICS {{ conn|qtIdent(o_data.schema, data.name if data.name else o_data.name) }} - SET SCHEMA {{ conn|qtIdent(data.schema) }}; - -{% endif %} -{### Change owner ###} -{% if data.owner and data.owner != o_data.owner %} -ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} - OWNER TO {{ conn|qtIdent(data.owner) }}; - -{% endif %} -{### Set statistics target (PostgreSQL 13+) ###} -{% if data.stattarget is defined and data.stattarget != o_data.stattarget %} -ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} - SET STATISTICS {{ data.stattarget }}; - -{% endif %} -{### Update comment ###} -{% if data.comment is defined and data.comment != o_data.comment %} -COMMENT ON STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} - IS {% if data.comment %}{{ data.comment|qtLiteral(conn) }}{% else %}NULL{% endif %}; -{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql deleted file mode 100644 index 500d4982c8e..00000000000 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/create.sql +++ /dev/null @@ -1,14 +0,0 @@ -{### SQL to create extended statistics object (PostgreSQL 17+) ###} -{### Supports expression-based statistics and optional name ###} -CREATE STATISTICS{% if data.name %}{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn|qtIdent(data.schema, data.name) }}{% endif %}{% if data.stat_types and data.stat_types|length > 0 %} - - ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} - - ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.expression_list %}{{ data.expression_list }}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} - - FROM {{ conn|qtIdent(data.schema, data.table) }}; -{% if data.comment and data.name %} - -COMMENT ON STATISTICS {{ conn|qtIdent(data.schema, data.name) }} - IS {{ data.comment|qtLiteral(conn) }}; -{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql deleted file mode 100644 index 1663a0a757d..00000000000 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/properties.sql +++ /dev/null @@ -1,40 +0,0 @@ -{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 14+) ###} -{% if scid %} -SELECT - s.oid, - s.stxname AS name, - s.stxnamespace AS schemaoid, - ns.nspname AS schema, - s.stxrelid AS tableoid, - t.relname AS table, - pg_catalog.pg_get_userbyid(s.stxowner) AS owner, - s.stxkeys AS column_attnums, - (SELECT array_agg(a.attname ORDER BY a.attnum) - FROM pg_catalog.pg_attribute a - WHERE a.attrelid = s.stxrelid - AND a.attnum = ANY(s.stxkeys) - ) AS columns, - s.stxkind AS stat_types_raw, - CASE WHEN 'd' = ANY(s.stxkind) THEN true ELSE false END AS has_ndistinct, - CASE WHEN 'f' = ANY(s.stxkind) THEN true ELSE false END AS has_dependencies, - CASE WHEN 'm' = ANY(s.stxkind) THEN true ELSE false END AS has_mcv, - s.stxstattarget AS stattarget, -{### stxexprs added in PostgreSQL 14 for expression statistics ###} - pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS expression_list, -{### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} - sd.stxdndistinct AS ndistinct_values, - sd.stxddependencies AS dependencies_values, - CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS has_mcv_values, - des.description AS comment -FROM pg_catalog.pg_statistic_ext s - LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace - LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid - LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid - LEFT OUTER JOIN pg_catalog.pg_description des - ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) -WHERE s.stxnamespace = {{scid}}::oid -{% if stid %} - AND s.oid = {{stid}}::oid -{% endif %} -ORDER BY s.stxname -{% endif %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql index 4ee5299a2c3..d55476eff39 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql @@ -1,5 +1,6 @@ -{### Check if extended statistics are supported (PostgreSQL 10+) ###} +{### Check whether extended statistics are supported ###} SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END AS has_statistics -FROM pg_catalog.pg_class -WHERE relname='pg_statistic_ext' +FROM pg_catalog.pg_class c +WHERE c.relname = 'pg_statistic_ext' + AND c.relnamespace = 'pg_catalog'::regnamespace diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql index e54d25b59d8..226fb1975ac 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql @@ -1,12 +1,22 @@ {### SQL to create extended statistics object (PostgreSQL 14+) ###} -{### Supports expression-based statistics ###} +{### Supports column based, expression based and mixed statistics ###} CREATE STATISTICS{% if add_not_exists_clause %} IF NOT EXISTS{% endif %} {{ conn|qtIdent(data.schema, data.name) }}{% if data.stat_types and data.stat_types|length > 0 %} ({% for stype in data.stat_types %}{{ stype }}{% if not loop.last %}, {% endif %}{% endfor %}){% endif %} - ON {% if data.expressions and data.expressions|length > 0 %}{% for expr in data.expressions %}({{ expr.expression if expr.expression is defined else expr }}){% if not loop.last %}, {% endif %}{% endfor %}{% elif data.expression_list %}{{ data.expression_list }}{% elif data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %} + ON {% if data.columns %}{% for col in data.columns %}{{ conn|qtIdent(col) }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% if data.columns and data.expression_list %}, {% endif %}{% if data.expression_list %}{{ data.expression_list }}{% endif %} FROM {{ conn|qtIdent(data.schema, data.table) }}; +{% if data.owner %} + +ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + OWNER TO {{ conn|qtIdent(data.owner) }}; +{% endif %} +{% if data.stattarget is defined and data.stattarget is not none and data.stattarget != -1 %} + +ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} + SET STATISTICS {{ data.stattarget }}; +{% endif %} {% if data.comment %} COMMENT ON STATISTICS {{ conn|qtIdent(data.schema, data.name) }} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql index 05329abd2d9..30cb9ec18bf 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql @@ -1,12 +1,2 @@ {### SQL to drop extended statistics object ###} -{% if stid %} -SELECT - s.stxname AS name, - ns.nspname AS schema -FROM pg_catalog.pg_statistic_ext s - LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace -WHERE s.oid = {{stid}}::oid; -{% endif %} -{% if name %} -DROP STATISTICS{% if cascade %} CASCADE{% endif %} {{ conn|qtIdent(schema, name) }}; -{% endif %} +DROP STATISTICS {{ conn|qtIdent(schema, name) }}{% if cascade %} CASCADE{% endif %}; diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_name.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_name.sql new file mode 100644 index 00000000000..c6bcc3ff6a1 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_name.sql @@ -0,0 +1,7 @@ +{### Get the name and schema of an extended statistics object ###} +SELECT + s.stxname AS name, + ns.nspname AS schema +FROM pg_catalog.pg_statistic_ext s + LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace +WHERE s.oid = {{stid}}::oid; diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql index 903336d1d8f..41a586bf7a4 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql @@ -1,6 +1,14 @@ -{### Get OID of newly created statistics object ###} -SELECT s.oid +{### Get the OID and name of a newly created statistics object ###} +{### From PostgreSQL 16 the name is optional, so when we do not have one ###} +{### the newest object on the table is the one that was just created ###} +SELECT s.oid, s.stxname AS name FROM pg_catalog.pg_statistic_ext s - LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace -WHERE s.stxname = {{name|qtLiteral(conn)}} - AND ns.nspname = {{schema|qtLiteral(conn)}} + JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace + JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid +WHERE ns.nspname = {{schema|qtLiteral(conn)}} +{% if name %} + AND s.stxname = {{name|qtLiteral(conn)}} +{% else %} + AND t.relname = {{table|qtLiteral(conn)}} +{% endif %} +ORDER BY s.oid DESC LIMIT 1 diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql index 1663a0a757d..9c1e2cc4e44 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql @@ -21,15 +21,20 @@ SELECT s.stxstattarget AS stattarget, {### stxexprs added in PostgreSQL 14 for expression statistics ###} pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS expression_list, -{### Statistics data from pg_statistic_ext_data (PostgreSQL 12+) ###} +{### pg_statistic_ext_data is readable by superusers only, so the data ###} +{### ANALYZE collected is only selected when we are allowed to read it ###} +{% if has_ext_data_access %} sd.stxdndistinct AS ndistinct_values, sd.stxddependencies AS dependencies_values, CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS has_mcv_values, +{% endif %} des.description AS comment FROM pg_catalog.pg_statistic_ext s LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid = s.stxnamespace LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid +{% if has_ext_data_access %} LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid +{% endif %} LEFT OUTER JOIN pg_catalog.pg_description des ON (des.objoid = s.oid AND des.classoid = 'pg_statistic_ext'::regclass) WHERE s.stxnamespace = {{scid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql index f86cc8ded51..9f1243552a8 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql @@ -1,4 +1,4 @@ -{### Get statistics for individual statistics object (PostgreSQL 10-11) ###} +{### Get statistics for an individual extended statistics object ###} SELECT s.stxname AS {{ conn|qtIdent(_('Name')) }}, t.relname AS {{ conn|qtIdent(_('Table')) }}, @@ -7,6 +7,7 @@ SELECT WHERE a.attrelid = s.stxrelid AND a.attnum = ANY(s.stxkeys) ) AS {{ conn|qtIdent(_('Columns')) }}, + pg_catalog.pg_get_expr(s.stxexprs, s.stxrelid) AS {{ conn|qtIdent(_('Expressions')) }}, CASE WHEN s.stxkind IS NOT NULL THEN array_to_string( @@ -21,10 +22,17 @@ SELECT ), ', ' ) ELSE '' - END AS {{ conn|qtIdent(_('Statistics Types')) }}, - NULL AS {{ conn|qtIdent(_('N-Distinct Coefficients')) }}, - NULL AS {{ conn|qtIdent(_('Functional Dependencies')) }}, - NULL AS {{ conn|qtIdent(_('Most Common Values')) }} + END AS {{ conn|qtIdent(_('Statistics Types')) }} +{### The values ANALYZE collected live in pg_statistic_ext_data, which ###} +{### only a superuser may read ###} +{% if has_ext_data_access %} + ,sd.stxdndistinct AS {{ conn|qtIdent(_('N-Distinct Coefficients')) }}, + sd.stxddependencies AS {{ conn|qtIdent(_('Functional Dependencies')) }}, + CASE WHEN sd.stxdmcv IS NOT NULL THEN true ELSE false END AS {{ conn|qtIdent(_('Has Most Common Values')) }} +{% endif %} FROM pg_catalog.pg_statistic_ext s LEFT JOIN pg_catalog.pg_class t ON t.oid = s.stxrelid +{% if has_ext_data_access %} + LEFT JOIN pg_catalog.pg_statistic_ext_data sd ON sd.stxoid = s.oid +{% endif %} WHERE s.oid = {{stid}}::oid diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql index 15965c773ed..1e24a41ccf3 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql @@ -1,4 +1,4 @@ -{### SQL to update extended statistics object ###} +{### SQL to update extended statistics object (PostgreSQL 14+) ###} {### Rename statistics ###} {% if data.name and data.name != o_data.name %} ALTER STATISTICS {{ conn|qtIdent(o_data.schema, o_data.name) }} @@ -16,6 +16,12 @@ ALTER STATISTICS {{ conn|qtIdent(o_data.schema, data.name if data.name else o_da ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} OWNER TO {{ conn|qtIdent(data.owner) }}; +{% endif %} +{### Set statistics target (PostgreSQL 13+) ###} +{% if data.stattarget is defined and data.stattarget != o_data.stattarget %} +ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} + SET STATISTICS {{ data.stattarget }}; + {% endif %} {### Update comment ###} {% if data.comment is defined and data.comment != o_data.comment %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json index 9ff920e98b4..10f14995564 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json @@ -113,6 +113,75 @@ "test_result_data": {} } }, + { + "name": "Create statistics: With columns and expressions together.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000, + "skip_msg": "Statistics not supported below PG 14" + }, + "test_data": { + "name": "test_stats_mixed", + "schema": "public", + "table": "test_table", + "columns": ["col1"], + "expression_list": "(col1 + col2)", + "stat_types": ["ndistinct"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create statistics: With an expression containing a comma.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000, + "skip_msg": "Statistics not supported below PG 14" + }, + "test_data": { + "name": "test_stats_comma_expr", + "schema": "public", + "table": "test_table", + "expression_list": "coalesce(col1, col2), lower(col3)", + "stat_types": ["ndistinct"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Create statistics: Without a name (PG 16+).", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 160000, + "skip_msg": "The statistics name is only optional from PG 16" + }, + "test_data": { + "schema": "public", + "table": "test_table", + "columns": ["col1", "col2"], + "stat_types": ["ndistinct"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, { "name": "Create statistics: With invalid data - Less than 2 columns.", "url": "/browser/statistics/obj/", @@ -185,6 +254,21 @@ "test_result_data": {} } }, + { + "name": "Delete statistics: Single object with cascade.", + "url": "/browser/statistics/delete/", + "is_positive_test": true, + "is_list": false, + "inventory_data": {}, + "test_data": {}, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, { "name": "Delete statistics: Non-existing object.", "url": "/browser/statistics/obj/", @@ -268,6 +352,38 @@ "error_msg": null, "test_result_data": {} } + }, + { + "name": "Update statistics: Change the statistics target.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "stattarget": 500 + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, + { + "name": "Update statistics: Change the comment.", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": {}, + "test_data": { + "comment": "Statistics object comment" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } } ] } diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py index 511cb9430b8..8f7029ddc12 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## @@ -117,5 +117,10 @@ def runTest(self): utils.assert_error_message(self, response) def tearDown(self): + # Dropping the table takes the statistics objects defined on it with + # it, whatever the server named them. + statistics_utils.drop_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) # Disconnect the database database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py index 5118d62935c..cea0c8ea1b7 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py index 5edbb35f328..5a300801af3 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py index d1377a38a5f..1d688d14c39 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py index c44d1ac529e..56bfa232d1f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_sql.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_sql.py new file mode 100644 index 00000000000..25f0002a0e5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_sql.py @@ -0,0 +1,140 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import json +import uuid +from urllib.parse import urlencode + +from pgadmin.browser.server_groups.servers.databases.schemas.tests import \ + utils as schema_utils +from pgadmin.browser.server_groups.servers.databases.tests import utils as \ + database_utils +from pgadmin.utils import server_utils +from pgadmin.utils.route import BaseTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils +from . import utils as statistics_utils + + +class StatisticsSQLTestCase(BaseTestGenerator): + """ + This class checks the reverse engineered SQL for a statistics object + defined on a mixture of columns and expressions, which has to describe + both, and has to be valid SQL. + """ + + scenarios = [( + 'Reverse engineered SQL for a mixed statistics object', {} + )] + + def setUp(self): + super().setUp() + self.db_name = parent_node_dict["database"][-1]["db_name"] + schema_info = parent_node_dict["schema"][-1] + self.server_id = schema_info["server_id"] + self.db_id = schema_info["db_id"] + db_con = database_utils.connect_database(self, utils.SERVER_GROUP, + self.server_id, self.db_id) + if not db_con['data']["connected"]: + raise Exception("Could not connect to database to fetch the " + "statistics SQL.") + + server_con = server_utils.connect_server(self, self.server_id) + if server_con["info"] != "Server connected.": + raise Exception("Could not connect to server to check version") + if server_con["data"]["version"] < 140000: + self.skipTest("Statistics not supported below PG 14") + + self.schema_id = schema_info["schema_id"] + self.schema_name = schema_info["schema_name"] + schema_response = schema_utils.verify_schemas(self.server, + self.db_name, + self.schema_name) + if not schema_response: + raise Exception("Could not find the schema to add statistics.") + + self.table_name = "test_table_stats_%s" % (str(uuid.uuid4())[1:8]) + statistics_utils.create_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) + + # A statistics object over one column and one expression: neither + # part may be lost when the definition is read back. + self.statistics_name = "test_stats_sql_%s" % (str(uuid.uuid4())[1:8]) + self.statistics_id = statistics_utils.create_statistics_with_columns( + self.server, self.db_name, self.schema_name, self.table_name, + self.statistics_name, ["col1"], ["(col2 + 1)"], ["ndistinct"] + ) + + def runTest(self): + response = self.tester.get( + "/browser/statistics/sql/{0}/{1}/{2}/{3}/{4}".format( + utils.SERVER_GROUP, self.server_id, self.db_id, + self.schema_id, self.statistics_id + ), + follow_redirects=True + ) + self.assertEqual(response.status_code, 200) + + sql = json.loads(response.data.decode('utf-8')) + + self.assertIn( + 'col1', sql, + "The column is missing from the reverse engineered SQL." + ) + self.assertIn( + 'col2 + 1', sql, + "The expression is missing from the reverse engineered SQL." + ) + + # The modified SQL for a rename and a comment has to describe both + # changes, and nothing else. + response = self.tester.get( + "/browser/statistics/msql/{0}/{1}/{2}/{3}/{4}?{5}".format( + utils.SERVER_GROUP, self.server_id, self.db_id, + self.schema_id, self.statistics_id, + urlencode({ + 'name': json.dumps(self.statistics_name + '_renamed'), + 'comment': 'A renamed statistics object', + }) + ), + follow_redirects=True + ) + self.assertEqual(response.status_code, 200) + + msql = json.loads(response.data.decode('utf-8'))['data'] + self.assertIn('ALTER STATISTICS', msql) + self.assertIn('RENAME TO', msql) + self.assertIn('COMMENT ON STATISTICS', msql) + self.assertNotIn('CREATE STATISTICS', msql) + + # The definition has to be valid SQL, so drop the object and let the + # server rebuild it from what we generated. + statistics_utils.delete_statistics( + self.server, self.db_name, self.schema_name, self.statistics_name + ) + statistics_utils.execute_statement( + self.server, self.db_name, sql + ) + + self.assertIsNotNone( + statistics_utils.verify_statistics( + self.server, self.db_name, self.statistics_name + ), + "The generated SQL did not recreate the statistics object." + ) + + def tearDown(self): + statistics_utils.delete_statistics( + self.server, self.db_name, self.schema_name, self.statistics_name + ) + statistics_utils.drop_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) + database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py index 573035f5c30..cb796b1d279 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py @@ -2,7 +2,7 @@ # # pgAdmin 4 - PostgreSQL Tools # -# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# Copyright (C) 2013 - 2026, The pgAdmin Development Team # This software is released under the PostgreSQL Licence # ########################################################################## @@ -190,6 +190,85 @@ def create_statistics(server, db_name, schema_name, table_name, raise +def execute_statement(server, db_name, statement): + """ + This function runs an arbitrary statement against the test database. + + Args: + server: server details + db_name: database name + statement: the SQL to run + """ + connection = None + try: + connection = test_utils.get_db_connection( + db_name, + server['username'], + server['db_password'], + server['host'], + server['port'], + server['sslmode'] + ) + old_isolation_level = connection.isolation_level + test_utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute(statement) + test_utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + except Exception: + traceback.print_exc(file=sys.stderr) + raise + finally: + if connection: + connection.close() + + +def drop_table_for_statistics(server, db_name, schema_name, table_name): + """ + This function drops a table created for statistics testing. + + Args: + server: server details + db_name: database name + schema_name: schema name + table_name: table name + """ + execute_statement( + server, db_name, + f"DROP TABLE IF EXISTS {schema_name}.{table_name} CASCADE" + ) + + +def create_statistics_with_columns(server, db_name, schema_name, table_name, + statistics_name, columns, expressions, + stat_types): + """ + Creates a statistics object over a mixture of columns and expressions. + + Args: + server: server details + db_name: database name + schema_name: schema name + table_name: table name + statistics_name: statistics object name + columns: list of column names + expressions: list of SQL expression strings, each parenthesised + stat_types: list of statistics types (ndistinct, dependencies, mcv) + + Returns: + statistics OID + """ + stat_types_str = ', '.join(stat_types) + items_str = ', '.join(list(columns) + list(expressions)) + execute_statement( + server, db_name, + f"CREATE STATISTICS {schema_name}.{statistics_name} " + f"({stat_types_str}) ON {items_str} FROM {schema_name}.{table_name}" + ) + + return get_statistics_id(server, db_name, statistics_name) + + def create_statistics_with_expressions(server, db_name, schema_name, table_name, statistics_name, expressions, stat_types): @@ -380,11 +459,12 @@ def get_statistics_columns(server, db_name, statistics_oid): ) pg_cursor = connection.cursor() pg_cursor.execute( - f"SELECT array_agg(a.attname ORDER BY s.stxkeys_pos) " - f"FROM pg_catalog.pg_statistic_ext s, " - f"unnest(s.stxkeys) WITH ORDINALITY AS s(attnum, stxkeys_pos) " + f"SELECT array_agg(a.attname ORDER BY k.stxkeys_pos) " + f"FROM pg_catalog.pg_statistic_ext s " + f"CROSS JOIN LATERAL unnest(s.stxkeys) " + f" WITH ORDINALITY AS k(attnum, stxkeys_pos) " f"JOIN pg_catalog.pg_attribute a " - f" ON a.attrelid = s.stxrelid AND a.attnum = s.attnum " + f" ON a.attrelid = s.stxrelid AND a.attnum = k.attnum " f"WHERE s.oid = {statistics_oid}" ) columns = pg_cursor.fetchone() diff --git a/web/pgadmin/tools/schema_diff/tests/pg/17_plus/source.sql b/web/pgadmin/tools/schema_diff/tests/pg/17_plus/source.sql index 63d84df1b80..b0354ed02bb 100644 --- a/web/pgadmin/tools/schema_diff/tests/pg/17_plus/source.sql +++ b/web/pgadmin/tools/schema_diff/tests/pg/17_plus/source.sql @@ -1159,3 +1159,17 @@ ALTER SUBSCRIPTION subscription_test1 RENAME TO subscription_test; DROP SUBSCRIPTION subscription_test; + + +-- +-- Name: statistics_identical; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_identical (ndistinct, dependencies) ON col1, col2 FROM test_schema_diff.table_for_identical; + + +-- +-- Name: statistics_only_in_source; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_only_in_source (ndistinct) ON col1, (lower(col2)) FROM test_schema_diff.table_for_index; diff --git a/web/pgadmin/tools/schema_diff/tests/pg/17_plus/target.sql b/web/pgadmin/tools/schema_diff/tests/pg/17_plus/target.sql index 0351ae4c389..5d2e52d58c0 100644 --- a/web/pgadmin/tools/schema_diff/tests/pg/17_plus/target.sql +++ b/web/pgadmin/tools/schema_diff/tests/pg/17_plus/target.sql @@ -1092,3 +1092,17 @@ ALTER SUBSCRIPTION subscription_test1_in_target RENAME TO subscription_test_in_target; DROP SUBSCRIPTION subscription_test_in_target; + + +-- +-- Name: statistics_identical; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_identical (ndistinct, dependencies) ON col1, col2 FROM test_schema_diff.table_for_identical; + + +-- +-- Name: statistics_only_in_target; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_only_in_target (mcv) ON col1, col2 FROM test_schema_diff.table_for_index; diff --git a/web/pgadmin/tools/schema_diff/tests/pg/default/source.sql b/web/pgadmin/tools/schema_diff/tests/pg/default/source.sql index 9a0ba56f830..922f1e41067 100644 --- a/web/pgadmin/tools/schema_diff/tests/pg/default/source.sql +++ b/web/pgadmin/tools/schema_diff/tests/pg/default/source.sql @@ -1164,3 +1164,17 @@ ALTER SUBSCRIPTION subscription_test1 RENAME TO subscription_test; DROP SUBSCRIPTION subscription_test; + + +-- +-- Name: statistics_identical; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_identical (ndistinct, dependencies) ON col1, col2 FROM test_schema_diff.table_for_identical; + + +-- +-- Name: statistics_only_in_source; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_only_in_source (ndistinct) ON col1, (lower(col2)) FROM test_schema_diff.table_for_index; diff --git a/web/pgadmin/tools/schema_diff/tests/pg/default/target.sql b/web/pgadmin/tools/schema_diff/tests/pg/default/target.sql index 8d56b453be6..7a799fce3ab 100644 --- a/web/pgadmin/tools/schema_diff/tests/pg/default/target.sql +++ b/web/pgadmin/tools/schema_diff/tests/pg/default/target.sql @@ -1097,3 +1097,17 @@ ALTER SUBSCRIPTION subscription_test1_in_target RENAME TO subscription_test_in_target; DROP SUBSCRIPTION subscription_test_in_target; + + +-- +-- Name: statistics_identical; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_identical (ndistinct, dependencies) ON col1, col2 FROM test_schema_diff.table_for_identical; + + +-- +-- Name: statistics_only_in_target; Type: STATISTICS; Schema: test_schema_diff; Owner: postgres +-- + +CREATE STATISTICS test_schema_diff.statistics_only_in_target (mcv) ON col1, col2 FROM test_schema_diff.table_for_index; diff --git a/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py new file mode 100644 index 00000000000..e65b79d927a --- /dev/null +++ b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py @@ -0,0 +1,261 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Schema Diff tests for extended statistics objects (#2018). + +The values ANALYZE collects for a statistics object, and the attribute +numbers behind its column list, are not part of its definition: two +databases holding the same definition must compare as identical whatever +ANALYZE happened to record. Where the definitions really do differ, the +generated SQL has to be valid, which means the whole definition, columns +and expressions alike, and the drop behaviour in the place PostgreSQL +expects it. +""" + +import json +import secrets +import uuid + +from pgadmin.utils.route import BaseSocketTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils + +SCHEMA_NAME = 'test_statistics_diff' + +DDL = """ +CREATE SCHEMA {0}; + +CREATE TABLE {0}.table_for_statistics ( + col1 integer NOT NULL, + col2 integer, + col3 text +); + +INSERT INTO {0}.table_for_statistics + SELECT i % 10, i % 5, 'val' || i FROM generate_series(1, 100) i; + +CREATE STATISTICS {0}.statistics_identical (ndistinct, dependencies) + ON col1, col2 FROM {0}.table_for_statistics; + +CREATE STATISTICS {0}.statistics_mixed (ndistinct) + ON col1, (lower(col3)) FROM {0}.table_for_statistics; + +CREATE STATISTICS {0}.statistics_changed (ndistinct) + ON col1, col2 FROM {0}.table_for_statistics; + +COMMENT ON STATISTICS {0}.statistics_changed IS '{1} side'; + +ANALYZE {0}.table_for_statistics; +""" + +# Only the source has this one, so the diff has to create it in the target. +SOURCE_ONLY_DDL = """ +CREATE STATISTICS {0}.statistics_source_only (mcv) + ON col2, (col1 + col2) FROM {0}.table_for_statistics; +""" + +# Only the target has this one, so the diff has to drop it. +TARGET_ONLY_DDL = """ +CREATE STATISTICS {0}.statistics_target_only (ndistinct) + ON col1, col3 FROM {0}.table_for_statistics; +""" + + +class SchemaDiffStatisticsTestCase(BaseSocketTestGenerator): + """ This class will test Schema Diff against statistics objects. """ + scenarios = [ + ('Schema diff comparison of statistics objects', dict()) + ] + SOCKET_NAMESPACE = '/schema_diff' + + def setUp(self): + super().setUp() + + # Extended statistics objects with expressions arrived in PG 14, and + # the node does not claim to support anything older. + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + pg_cursor = connection.cursor() + pg_cursor.execute("SHOW server_version_num") + server_version = int(pg_cursor.fetchone()[0]) + connection.close() + if server_version < 140000: + self.skipTest("Statistics not supported below PG 14") + + self.src_database = "db_stats_diff_src_%s" % str(uuid.uuid4())[1:8] + self.tar_database = "db_stats_diff_tar_%s" % str(uuid.uuid4())[1:8] + + self.src_db_id = utils.create_database(self.server, self.src_database) + self.tar_db_id = utils.create_database(self.server, self.tar_database) + + self.server = parent_node_dict["server"][-1]["server"] + self.server_id = parent_node_dict["server"][-1]["server_id"] + + self.execute_sql(self.src_database, DDL.format(SCHEMA_NAME, 'source')) + self.execute_sql(self.src_database, + SOURCE_ONLY_DDL.format(SCHEMA_NAME)) + self.execute_sql(self.tar_database, DDL.format(SCHEMA_NAME, 'target')) + self.execute_sql(self.tar_database, + TARGET_ONLY_DDL.format(SCHEMA_NAME)) + + def execute_sql(self, db_name, sql): + """ + Run a statement batch against one of the test databases. + + :param db_name: Database to run against + :param sql: SQL to execute + """ + connection = utils.get_db_connection(db_name, + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute(sql) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + + def compare(self): + """ + Compare the two test databases and return the result. + + :return: List of compared objects + """ + data = { + 'trans_id': self.trans_id, + 'source_sid': self.server_id, + 'source_did': self.src_db_id, + 'target_sid': self.server_id, + 'target_did': self.tar_db_id, + 'ignore_owner': 0, + 'ignore_whitespaces': 0, + 'ignore_tablespace': 0, + 'ignore_grants': 0 + } + self.socket_client.emit('compare_database', data, + namespace=self.SOCKET_NAMESPACE) + received = self.socket_client.get_received(self.SOCKET_NAMESPACE) + response_data = received[-1]['args'][0] + self.assertEqual(received[-1]['name'], "compare_database_success", + response_data) + return response_data + + def find_object(self, response_data, title): + """ + Pick a single compared statistics object out of the result. + + :param response_data: Result of compare() + :param title: Object name + :return: The compared object + """ + for diff in response_data: + if diff.get('type') == 'statistics' and \ + diff.get('title') == title: + return diff + + self.fail('statistics {0} was not compared'.format(title)) + + def runTest(self): + """ This function will test Schema Diff for statistics objects. """ + self.trans_id = str(secrets.choice(range(1, 99999))) + response = self.tester.get( + 'schema_diff/initialize/{}'.format(self.trans_id)) + self.assertEqual(response.status_code, 200) + + received = self.socket_client.get_received(self.SOCKET_NAMESPACE) + self.assertEqual(received[0]['name'], 'connected') + + self.tester.post( + 'schema_diff/server/connect/{}'.format(self.server_id), + data=json.dumps({'password': self.server['db_password']}), + content_type='html/json') + self.tester.post('schema_diff/database/connect/{0}/{1}'.format( + self.server_id, self.src_db_id)) + self.tester.post('schema_diff/database/connect/{0}/{1}'.format( + self.server_id, self.tar_db_id)) + + response_data = self.compare() + + # What ANALYZE recorded, and the attribute numbers behind the column + # list, say nothing about the definition. + identical = self.find_object(response_data, 'statistics_identical') + self.assertEqual(identical['status'], 'Identical', + 'Identical statistics objects were reported as {0}: ' + '{1}'.format(identical['status'], + identical.get('diff_ddl'))) + + # Neither does either of them for a definition mixing a column and an + # expression. + mixed = self.find_object(response_data, 'statistics_mixed') + self.assertEqual(mixed['status'], 'Identical', + 'Identical statistics objects over a column and an ' + 'expression were reported as {0}: {1}'.format( + mixed['status'], mixed.get('diff_ddl'))) + + # An object the source alone has must be created in the target, with + # its column and its expression intact. + source_only = self.find_object(response_data, + 'statistics_source_only') + self.assertEqual(source_only['status'], 'Source Only') + self.assertIn('CREATE STATISTICS', source_only['diff_ddl']) + self.assertIn('col2', source_only['diff_ddl']) + self.assertIn('col1 + col2', source_only['diff_ddl']) + + # An object the target alone has must be dropped, and DROP STATISTICS + # takes its drop behaviour after the object name. + target_only = self.find_object(response_data, + 'statistics_target_only') + self.assertEqual(target_only['status'], 'Target Only') + self.assertIn('DROP STATISTICS', target_only['diff_ddl']) + self.assertNotIn('DROP STATISTICS CASCADE', + target_only['diff_ddl']) + + # A genuine difference is still reported. + changed = self.find_object(response_data, 'statistics_changed') + self.assertEqual(changed['status'], 'Different') + self.assertIn('COMMENT ON STATISTICS', changed['diff_ddl']) + + # Applying the whole script must succeed, and must settle every + # difference. + for diff in (source_only, target_only, changed): + self.execute_sql(self.tar_database, diff['diff_ddl']) + + response_data = self.compare() + for title in ('statistics_identical', 'statistics_mixed', + 'statistics_source_only', 'statistics_changed'): + self.assertEqual( + self.find_object(response_data, title)['status'], 'Identical', + '{0} was not settled by the generated SQL'.format(title)) + + for diff in response_data: + if diff.get('type') == 'statistics': + self.assertNotEqual(diff.get('title'), + 'statistics_target_only', + 'The generated SQL did not drop the ' + 'object that only the target had.') + + def tearDown(self): + """This function drops the added databases""" + super().tearDown() + for db_name in (self.src_database, self.tar_database): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + utils.drop_database(connection, db_name) diff --git a/web/regression/javascript/schema_ui_files/statistics.ui.spec.js b/web/regression/javascript/schema_ui_files/statistics.ui.spec.js new file mode 100644 index 00000000000..da2d1bd08a3 --- /dev/null +++ b/web/regression/javascript/schema_ui_files/statistics.ui.spec.js @@ -0,0 +1,116 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + + +import StatisticsSchema from '../../../pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui'; +import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '../genericFunctions'; + +describe('StatisticsSchema', () => { + const createSchemaObj = (version=180000) => new StatisticsSchema( + { + role: () => [], + schema: () => [], + getTables: () => [], + getColumns: () => [], + }, + { + owner: 'postgres', + schema: 'public', + }, + { + server: {version: version}, + } + ); + let schemaObj = createSchemaObj(); + let getInitData = () => Promise.resolve({}); + + beforeEach(() => { + genericBeforeEach(); + }); + + it('create', () => { + getCreateView(createSchemaObj()); + }); + + it('edit', () => { + getEditView(createSchemaObj(), getInitData); + }); + + it('properties', () => { + getPropertiesView(createSchemaObj(), getInitData); + }); + + it('name is required before PostgreSQL 16 and optional from 16', () => { + const nameField = (obj) => obj.baseFields.find((f) => f.id == 'name'); + + expect(createSchemaObj(150000).isNameOptional).toBe(false); + expect(nameField(createSchemaObj(150000)).noEmpty).toBe(true); + + expect(createSchemaObj(160000).isNameOptional).toBe(true); + expect(nameField(createSchemaObj(160000)).noEmpty).toBe(false); + }); + + it('computed statistics are hidden without access to the catalog', () => { + const computed = schemaObj.baseFields.filter( + (f) => f.group == 'Computed Statistics' + ); + expect(computed.length).toBe(3); + + for (const field of computed) { + expect(field.visible({has_ext_data_access: true})).toBe(true); + expect(field.visible({has_ext_data_access: false})).toBe(false); + } + }); + + it('validate', () => { + let state = {}; + let setError = jest.fn(); + + // A table has to be chosen. + schemaObj.validate(state, setError); + expect(setError).toHaveBeenCalledWith('table', 'Table must be selected.'); + + state.table = 'test_table'; + + // Neither columns nor expressions given. + state.columns = []; + state.expression_list = null; + state.stat_types = ['ndistinct']; + schemaObj.validate(state, setError); + expect(setError).toHaveBeenCalledWith( + 'columns', 'Either columns or expressions must be specified.'); + + // One column on its own is not enough for a statistics object. + state.columns = ['col1']; + schemaObj.validate(state, setError); + expect(setError).toHaveBeenCalledWith( + 'columns', + 'At least 2 columns must be selected for multi-column statistics.'); + + // Two columns are. + state.columns = ['col1', 'col2']; + expect(schemaObj.validate(state, setError)).toBe(false); + + // So is one column alongside an expression, and so is an expression on + // its own: the single expression form needs nothing else. + state.columns = ['col1']; + state.expression_list = '(col2 + 1)'; + expect(schemaObj.validate(state, setError)).toBe(false); + + state.columns = []; + state.expression_list = 'coalesce(col1, col2)'; + expect(schemaObj.validate(state, setError)).toBe(false); + + // At least one statistics type is needed. + state.stat_types = []; + schemaObj.validate(state, setError); + expect(setError).toHaveBeenCalledWith( + 'stat_types', 'At least one statistics type must be selected.'); + }); +}); From 5deb4a0b2606285b57a7b110b192a14401ad53a9 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 20 Aug 2026 09:26:53 +0100 Subject: [PATCH 4/4] Statistics node: fix CodeRabbit review findings on PR #10310 - getNodeAjaxOptions: bypass the cache read (not just the write) when useCache:false is passed, so the Statistics dialog's column list can't come back stale from another table's cached entry. - Validate stattarget server-side on create as well as update, rejecting non-integer values before they reach the create/update SQL templates; render it through Jinja's |int filter in all four templates as defense in depth. 'DEFAULT' is left untouched so the PostgreSQL 17+ reset-to-default branch stays reachable. - Allow an empty stat_types selection for the expression-only form: PostgreSQL's univariate expression statistics (a single expression, no columns) don't accept a statistics-kind clause at all, so the "at least one type" rule now only applies when columns are involved. Added a positive test case and JS spec coverage for this path. - test_statistics_get.py: build the mocked API call lazily so it isn't fired twice (once unpatched, once patched) when mocking is required. - Close the setUp/tearDown leaks: four statistics test cases now drop their scratch table in tearDown like test_statistics_add.py already did, and the schema-diff statistics test closes each database connection it opens. - Docs: correct the PostgreSQL version statement (CREATE STATISTICS is PG 10+; this dialog targets PG 14+ where expression statistics were added) and describe computed-statistics visibility by privilege rather than by superuser status, matching has_ext_data_access. --- docs/en_US/statistics_dialog.rst | 13 +++-- .../databases/schemas/statistics/__init__.py | 58 ++++++++++++++----- .../statistics/static/js/statistics.ui.js | 16 +++-- .../statistics/sql/16_plus/create.sql | 2 +- .../statistics/sql/17_plus/update.sql | 2 +- .../statistics/sql/default/create.sql | 2 +- .../statistics/sql/default/update.sql | 2 +- .../tests/statistics_test_data.json | 23 ++++++++ .../tests/test_statistics_delete.py | 5 ++ .../tests/test_statistics_delete_multiple.py | 5 ++ .../statistics/tests/test_statistics_get.py | 22 ++++--- .../statistics/tests/test_statistics_put.py | 5 ++ web/pgadmin/browser/static/js/node_ajax.js | 3 +- .../tests/test_schema_diff_statistics.py | 5 +- .../schema_ui_files/statistics.ui.spec.js | 11 +++- 15 files changed, 135 insertions(+), 39 deletions(-) diff --git a/docs/en_US/statistics_dialog.rst b/docs/en_US/statistics_dialog.rst index 21bb7f19360..20ef90265f5 100644 --- a/docs/en_US/statistics_dialog.rst +++ b/docs/en_US/statistics_dialog.rst @@ -9,7 +9,10 @@ Use the *Statistics* dialog to define extended statistics on one or more columns correlation data across columns that can significantly improve query-plan estimates for queries that filter or group by multiple columns. -Extended statistics require PostgreSQL 14 or later. +PostgreSQL introduced ``CREATE STATISTICS`` in PostgreSQL 10, and added +expression statistics in PostgreSQL 14. This dialog is available for +PostgreSQL 14 and later, which covers all currently supported PostgreSQL +versions. The *Statistics* dialog organizes options across the *General* and *Definition* tabs. The *SQL* tab displays the SQL command generated by your selections. @@ -58,10 +61,10 @@ Click the *Definition* tab to continue. you want statistics over a mixture of the two. When you open the dialog on an existing statistics object, the *Properties* -view also reports the statistics target and, for superusers, the values that -``ANALYZE`` has collected. Those values are held in -``pg_catalog.pg_statistic_ext_data``, which only a superuser may read, so the -*Computed Statistics* group is hidden for everybody else. +view also reports the statistics target and, for roles with access, the values +that ``ANALYZE`` has collected. Those values are held in +``pg_catalog.pg_statistic_ext_data``, which is not publicly readable, so the +*Computed Statistics* group is hidden when the current role lacks access. Click the *SQL* tab to continue. diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py index a0d884f4314..1170356af76 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py @@ -491,8 +491,11 @@ def create(self, gid, sid, did, scid): ) ) - # Validate at least 1 stat_type - if len(data.get('stat_types', [])) < 1: + # Validate at least 1 stat_type, unless this is the expression-only + # form. PostgreSQL's univariate expression statistics (a single + # expression, no columns) do not accept a statistics-kind clause at + # all, so that form is left for the server to validate. + if has_columns and len(data.get('stat_types', [])) < 1: return make_json_response( status=400, success=0, @@ -715,6 +718,39 @@ def msql(self, gid, sid, did, scid, stid=None): status=200 ) + @staticmethod + def _validate_stattarget(data): + """ + Validate (and normalise) data['stattarget'] in place before it is + interpolated into the create/update SQL templates. + + 'DEFAULT' is left untouched, as the PostgreSQL 17+ update template + renders it verbatim to reset the statistics target. Anything else + must be convertible to an integer; a bogus value is rejected here + rather than reaching the SQL as defense in depth. + + Args: + data: Form data + + Returns: + An error response tuple (response, None) if invalid, else None. + """ + if 'stattarget' not in data or data['stattarget'] == 'DEFAULT': + return None + + try: + data['stattarget'] = int(data['stattarget']) + except (ValueError, TypeError): + return make_json_response( + status=400, + success=0, + errormsg=_( + "Statistics target must be an integer." + ) + ), None + + return None + def get_SQL(self, gid, sid, did, data, scid, stid=None, add_not_exists_clause=False): """ @@ -734,17 +770,9 @@ def get_SQL(self, gid, sid, did, data, scid, stid=None, """ if stid is not None: # Update operation - if 'stattarget' in data: - try: - data['stattarget'] = int(data['stattarget']) - except (ValueError, TypeError): - return make_json_response( - status=400, - success=0, - errormsg=_( - "Statistics target must be an integer." - ) - ), None + error = self._validate_stattarget(data) + if error is not None: + return error status, old_data = self._fetch_properties(scid, stid) if not status: @@ -761,6 +789,10 @@ def get_SQL(self, gid, sid, did, data, scid, stid=None, return sql, data.get('name', old_data['name']) else: # Create operation + error = self._validate_stattarget(data) + if error is not None: + return error + # Name is optional in PostgreSQL 16+ sql = render_template( "/".join([self.template_path, self._CREATE_SQL]), diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js index d03d1a61842..9cf3cd84886 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js @@ -164,7 +164,10 @@ export default class StatisticsSchema extends BaseUISchema { {label: gettext('Dependencies'), value: 'dependencies'}, {label: gettext('MCV (Most Common Values)'), value: 'mcv'}, ], - noEmpty: true, + // Required only for column-based statistics: PostgreSQL's univariate + // expression form (a single expression, no columns) does not accept + // a statistics-kind clause at all. Enforced conditionally in + // validate() below rather than here, since noEmpty can't see state. helpMessage: gettext('Select one or more statistics types to collect'), }, { @@ -193,8 +196,8 @@ export default class StatisticsSchema extends BaseUISchema { readonly: true, disabled: true, group: gettext('Computed Statistics'), - // The computed values live in pg_statistic_ext_data, which only a - // superuser may read; hide them rather than showing them as empty. + // The computed values live in pg_statistic_ext_data, which is not + // publicly readable; hide them rather than showing them as empty. visible: (state)=>state.has_ext_data_access, helpMessage: gettext('N-distinct coefficients computed by ANALYZE'), }, @@ -272,8 +275,11 @@ export default class StatisticsSchema extends BaseUISchema { } } - // Validate at least one stat type - if (state.stat_types && state.stat_types.length === 0 && !state.oid) { + // Validate at least one stat type, unless this is the expression-only + // form. PostgreSQL's univariate expression statistics (a single + // expression, no columns) do not accept a statistics-kind clause at + // all, so only require one when columns are involved. + if (hasColumns && state.stat_types && state.stat_types.length === 0 && !state.oid) { setError('stat_types', gettext('At least one statistics type must be selected.')); errors = true; } else { diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql index aafd3ded6af..a2b696a1f6c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql @@ -16,7 +16,7 @@ ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} {% if data.name and data.stattarget is defined and data.stattarget is not none and data.stattarget != -1 %} ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} - SET STATISTICS {{ data.stattarget }}; + SET STATISTICS {{ data.stattarget|int }}; {% endif %} {% if data.comment and data.name %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql index 802aa59f93e..4f5cef9c5be 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql @@ -20,7 +20,7 @@ ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, {### Set statistics target (PostgreSQL 17+ supports DEFAULT) ###} {% if data.stattarget is defined and data.stattarget != o_data.stattarget %} ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} - SET STATISTICS {% if data.stattarget == -1 or data.stattarget == 'DEFAULT' %}DEFAULT{% else %}{{ data.stattarget }}{% endif %}; + SET STATISTICS {% if data.stattarget == -1 or data.stattarget == 'DEFAULT' %}DEFAULT{% else %}{{ data.stattarget|int }}{% endif %}; {% endif %} {### Update comment ###} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql index 226fb1975ac..fa08b0762c4 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql @@ -15,7 +15,7 @@ ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} {% if data.stattarget is defined and data.stattarget is not none and data.stattarget != -1 %} ALTER STATISTICS {{ conn|qtIdent(data.schema, data.name) }} - SET STATISTICS {{ data.stattarget }}; + SET STATISTICS {{ data.stattarget|int }}; {% endif %} {% if data.comment %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql index 1e24a41ccf3..915b410b1b5 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql @@ -20,7 +20,7 @@ ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, {### Set statistics target (PostgreSQL 13+) ###} {% if data.stattarget is defined and data.stattarget != o_data.stattarget %} ALTER STATISTICS {{ conn|qtIdent(data.schema if data.schema else o_data.schema, data.name if data.name else o_data.name) }} - SET STATISTICS {{ data.stattarget }}; + SET STATISTICS {{ data.stattarget|int }}; {% endif %} {### Update comment ###} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json index 10f14995564..bb5e443a688 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json @@ -160,6 +160,29 @@ "test_result_data": {} } }, + { + "name": "Create statistics: Single expression, no statistics types (PG 14+).", + "url": "/browser/statistics/obj/", + "is_positive_test": true, + "inventory_data": { + "server_min_version": 140000, + "skip_msg": "Statistics not supported below PG 14" + }, + "test_data": { + "name": "test_stats_single_expr", + "schema": "public", + "table": "test_table", + "expression_list": "(col1 + col2)", + "stat_types": [] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200, + "error_msg": null, + "test_result_data": {} + } + }, { "name": "Create statistics: Without a name (PG 16+).", "url": "/browser/statistics/obj/", diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py index cea0c8ea1b7..d7297dfda36 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py @@ -162,5 +162,10 @@ def runTest(self): utils.assert_error_message(self, response) def tearDown(self): + # Dropping the table takes the statistics objects defined on it with + # it, whatever the server named them. + statistics_utils.drop_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) # Disconnect the database database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py index 5a300801af3..b08d93ebebd 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py @@ -121,5 +121,10 @@ def runTest(self): self.assertEqual(response.status_code, 200) def tearDown(self): + # Dropping the table takes the statistics objects defined on it with + # it, whatever the server named them. + statistics_utils.drop_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) # Disconnect the database database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py index 1d688d14c39..cadfacd569a 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py @@ -132,26 +132,30 @@ def runTest(self): else: if 'statistics_id' in self.data: self.statistics_id = self.data["statistics_id"] - get_call = ( - statistics_utils.api_get(self, '') - if self.is_list else statistics_utils.api_get(self) - ) + + def get_call(): + if self.is_list: + return statistics_utils.api_get(self, '') + return statistics_utils.api_get(self) + if self.mocking_required: with patch( self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])] ): - response = ( - statistics_utils.api_get(self, '') - if self.is_list else statistics_utils.api_get(self) - ) + response = get_call() else: - response = get_call + response = get_call() # Assert response utils.assert_status_code(self, response) utils.assert_error_message(self, response) def tearDown(self): + # Dropping the table takes the statistics objects defined on it with + # it, whatever the server named them. + statistics_utils.drop_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) # Disconnect the database database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py index 56bfa232d1f..6f45d0876a9 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py @@ -122,5 +122,10 @@ def runTest(self): utils.assert_error_message(self, response) def tearDown(self): + # Dropping the table takes the statistics objects defined on it with + # it, whatever the server named them. + statistics_utils.drop_table_for_statistics( + self.server, self.db_name, self.schema_name, self.table_name + ) # Disconnect the database database_utils.disconnect_database(self, self.server_id, self.db_id) diff --git a/web/pgadmin/browser/static/js/node_ajax.js b/web/pgadmin/browser/static/js/node_ajax.js index 8ba04f7469b..9c4ee83de44 100644 --- a/web/pgadmin/browser/static/js/node_ajax.js +++ b/web/pgadmin/browser/static/js/node_ajax.js @@ -111,7 +111,8 @@ export function getNodeAjaxOptions(url, nodeObj, treeNodeInfo, itemNodeData, par * If yes - use that, and do not bother about fetching it again, * and use it. */ - let data = cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel); + let data = otherParams.useCache ? + cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel) : undefined; if (_.isUndefined(data) || _.isNull(data)) { // Share a single in-flight request among all concurrent callers asking diff --git a/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py index e65b79d927a..15f97cd211b 100644 --- a/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py +++ b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py @@ -258,4 +258,7 @@ def tearDown(self): self.server['host'], self.server['port'], self.server['sslmode']) - utils.drop_database(connection, db_name) + try: + utils.drop_database(connection, db_name) + finally: + connection.close() diff --git a/web/regression/javascript/schema_ui_files/statistics.ui.spec.js b/web/regression/javascript/schema_ui_files/statistics.ui.spec.js index da2d1bd08a3..ed593ccd890 100644 --- a/web/regression/javascript/schema_ui_files/statistics.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/statistics.ui.spec.js @@ -107,10 +107,19 @@ describe('StatisticsSchema', () => { state.expression_list = 'coalesce(col1, col2)'; expect(schemaObj.validate(state, setError)).toBe(false); - // At least one statistics type is needed. + // At least one statistics type is needed when columns are involved. + state.columns = ['col1', 'col2']; + state.expression_list = null; state.stat_types = []; schemaObj.validate(state, setError); expect(setError).toHaveBeenCalledWith( 'stat_types', 'At least one statistics type must be selected.'); + + // But not for the expression-only form: PostgreSQL's univariate + // expression statistics don't accept a statistics-kind clause at all. + state.columns = []; + state.expression_list = 'coalesce(col1, col2)'; + state.stat_types = []; + expect(schemaObj.validate(state, setError)).toBe(false); }); });