diff --git a/docs/en_US/images/statistics_definition.png b/docs/en_US/images/statistics_definition.png new file mode 100644 index 00000000000..0b4e0692f11 Binary files /dev/null and b/docs/en_US/images/statistics_definition.png differ diff --git a/docs/en_US/images/statistics_general.png b/docs/en_US/images/statistics_general.png new file mode 100644 index 00000000000..6aa2906e201 Binary files /dev/null and b/docs/en_US/images/statistics_general.png differ diff --git a/docs/en_US/images/statistics_sql.png b/docs/en_US/images/statistics_sql.png new file mode 100644 index 00000000000..a78ab4df9f6 Binary files /dev/null and b/docs/en_US/images/statistics_sql.png differ 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/statistics_dialog.rst b/docs/en_US/statistics_dialog.rst new file mode 100644 index 00000000000..20ef90265f5 --- /dev/null +++ b/docs/en_US/statistics_dialog.rst @@ -0,0 +1,88 @@ +.. _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. + +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. + +.. image:: images/statistics_general.png + :alt: Statistics dialog general tab + :align: center + +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, 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. + +.. image:: images/statistics_definition.png + :alt: Statistics dialog definition tab + :align: center + +* Use the *Expressions* field to enter one or more SQL expressions separated by + 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 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. + + +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/__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..1170356af76 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/__init__.py @@ -0,0 +1,987 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, 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'}] + }) + + # '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' + _CREATE_SQL = 'create.sql' + _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' + + 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.ext_data_access = None + 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, + has_ext_data_access=self._has_ext_data_access() + ) + 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 _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 + + 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, + has_ext_data_access=self._has_ext_data_access() + ) + 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'] = [] + + # 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: + 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 + """ + # 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 + ) + + # 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) + ) + + # 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_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( + status=400, + success=0, + errormsg=_( + "Either columns or expressions must be specified" + ) + ) + + # 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( + status=400, + success=0, + errormsg=_( + "At least 2 columns must be specified " + "for multi-column statistics." + ) + ) + + # 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, + 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 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) + + 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): + """ + 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._GET_NAME_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, _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(' ') + + 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 + ) + + @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): + """ + 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 + error = self._validate_stattarget(data) + if error is not None: + return error + + 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 + 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]), + 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 + 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') + 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, + has_ext_data_access=self._has_ext_data_access() + ) + 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 is used to get the DDL/DML statements for schema diff. + + :param kwargs + :return: SQL string + """ + gid = kwargs.get('gid') + sid = kwargs.get('sid') + did = kwargs.get('did') + scid = kwargs.get('scid') + 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: + sql = self.sql(gid=gid, sid=sid, did=did, scid=scid, stid=oid, + json_resp=False) + + 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..f150406207d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.js @@ -0,0 +1,122 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, 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: true, + 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, + })); + }); + } + }, + { + owner: pgBrowser.serverInfo[treeNodeInfo.server._id].user.name, + schema: ('schema' in treeNodeInfo) ? treeNodeInfo.schema.label : '', + }, + treeNodeInfo + ); + }, + }); + } + 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..9cf3cd84886 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/static/js/statistics.ui.js @@ -0,0 +1,291 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, 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={}, nodeInfo={}) { + super({ + name: undefined, + oid: undefined, + schema: undefined, + table: undefined, + columns: [], + expression_list: undefined, + stat_types: [], + stattarget: undefined, + owner: undefined, + comment: undefined, + is_sys_obj: undefined, + has_ext_data_access: true, + ...initValues, + }); + + this.fieldOptions = { + role: [], + schema: [], + getTables: null, + 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) { + 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'], + noEmpty: !obj.isNameOptional, + helpMessage: obj.isNameOptional + ? gettext('Leave blank to let the server generate a name.') + : 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 && tid) + ? () => obj.fieldOptions.getColumns({tid: tid}) + : (state.columns || []).map(col => ({label: col, value: col})), + optionsReloadBasis: state.table, + }; + }, + deps: ['schema', 'table'], + mode: ['properties', 'create'], + editable: false, + controlProps: { + multiple: true, + allowClear: true, + }, + helpMessage: gettext('Select at least two columns, or one column alongside an expression.'), + 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'}, + ], + // 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'), + }, + { + id: 'expression_list', + label: gettext('Expressions'), + type: 'text', + mode: ['properties', 'create'], + group: gettext('Definition'), + 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', + 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'), + // 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'), + }, + { + id: 'dependencies_values', + label: gettext('Functional dependencies'), + type: 'multiline', + mode: ['properties'], + readonly: true, + disabled: true, + group: gettext('Computed Statistics'), + visible: (state)=>state.has_ext_data_access, + 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'), + visible: (state)=>state.has_ext_data_access, + 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. 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; + + 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 { + 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, 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 { + setError('stat_types', null); + } + + return errors; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/properties.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/properties.sql new file mode 100644 index 00000000000..2c862a1edd3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/15_plus/properties.sql @@ -0,0 +1,55 @@ +{### Query extended statistics properties from pg_statistic_ext (PostgreSQL 15+) ###} +{% 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, +{### 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 +{### 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 +{% 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/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 new file mode 100644 index 00000000000..a2b696a1f6c --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/16_plus/create.sql @@ -0,0 +1,25 @@ +{### SQL to create extended statistics object (PostgreSQL 16+) ###} +{### 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.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|int }}; +{% endif %} +{% 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/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..4f5cef9c5be --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/17_plus/update.sql @@ -0,0 +1,30 @@ +{### SQL to update extended statistics object (PostgreSQL 17+) ###} +{### 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 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|int }}{% 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..d55476eff39 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/backend_support.sql @@ -0,0 +1,6 @@ +{### Check whether extended statistics are supported ###} +SELECT + CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END AS has_statistics +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/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..fa08b0762c4 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/create.sql @@ -0,0 +1,24 @@ +{### SQL to create extended statistics object (PostgreSQL 14+) ###} +{### 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.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|int }}; +{% endif %} +{% 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..30cb9ec18bf --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/delete.sql @@ -0,0 +1,2 @@ +{### SQL to drop extended statistics object ###} +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 new file mode 100644 index 00000000000..41a586bf7a4 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/get_oid.sql @@ -0,0 +1,14 @@ +{### 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 + 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/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..9c1e2cc4e44 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/properties.sql @@ -0,0 +1,45 @@ +{### 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, +{### 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 +{% 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..9f1243552a8 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/stats.sql @@ -0,0 +1,38 @@ +{### Get statistics for an individual extended statistics object ###} +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 ###} +{% 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 new file mode 100644 index 00000000000..915b410b1b5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/templates/statistics/sql/default/update.sql @@ -0,0 +1,30 @@ +{### 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) }} + 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|int }}; + +{% 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..bb5e443a688 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/statistics_test_data.json @@ -0,0 +1,412 @@ +{ + "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 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 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: 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/", + "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/", + "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: 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/", + "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": {} + } + }, + { + "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 new file mode 100644 index 00000000000..8f7029ddc12 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_add.py @@ -0,0 +1,126 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, 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): + # 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 new file mode 100644 index 00000000000..d7297dfda36 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete.py @@ -0,0 +1,171 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, 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 '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, '') + 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) + 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 new file mode 100644 index 00000000000..b08d93ebebd --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_delete_multiple.py @@ -0,0 +1,130 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, 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): + # 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 new file mode 100644 index 00000000000..cadfacd569a --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_get.py @@ -0,0 +1,161 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, 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 'statistics_id' in self.data: + self.statistics_id = self.data["statistics_id"] + + 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 = get_call() + else: + 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 new file mode 100644 index 00000000000..6f45d0876a9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/test_statistics_put.py @@ -0,0 +1,131 @@ +########################################################################## +# +# 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 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): + # 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_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 new file mode 100644 index 00000000000..cb796b1d279 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/statistics/tests/utils.py @@ -0,0 +1,475 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, 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 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): + """ + 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. + + 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 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 = k.attnum " + f"WHERE s.oid = {statistics_oid}" + ) + 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/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/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..15f97cd211b --- /dev/null +++ b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_statistics.py @@ -0,0 +1,264 @@ +########################################################################## +# +# 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']) + 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 new file mode 100644 index 00000000000..ed593ccd890 --- /dev/null +++ b/web/regression/javascript/schema_ui_files/statistics.ui.spec.js @@ -0,0 +1,125 @@ +///////////////////////////////////////////////////////////// +// +// 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 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); + }); +}); 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'),