Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from flask import render_template
from flask_babel import gettext as _
from pgadmin.utils import unquote_ident
from pgadmin.utils.ajax import internal_server_error
from pgadmin.utils.exception import ObjectGone, ExecuteError
from functools import wraps
Expand Down Expand Up @@ -74,7 +75,7 @@ def _get_columns(res):
order = True
nulls_order = True if (row['options'] & 2) else False

columns.append({"column": row['coldef'].strip('"'),
columns.append({"column": unquote_ident(row['coldef']),
"oper_class": row['opcname'],
"order": order,
"nulls_order": nulls_order,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from flask import render_template
from flask_babel import gettext as _
from pgadmin.utils import unquote_ident
from pgadmin.utils.ajax import internal_server_error
from pgadmin.utils.exception import ObjectGone, ExecuteError
from functools import wraps
Expand Down Expand Up @@ -126,7 +127,7 @@ def search_coveringindex(conn, tid, cols, template_path=None):

index_cols = set()
for r in rest['rows']:
index_cols.add(r['column'].strip('"'))
index_cols.add(unquote_ident(r['column']))

if len(cols - index_cols) == len(index_cols - cols) == 0:
return constraint["idxname"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from pgadmin.browser.server_groups.servers.databases.schemas.tables.\
constraints.type import ConstraintRegistry, ConstraintTypeModule
from pgadmin.browser.utils import PGChildNodeView
from pgadmin.utils import unquote_ident
from pgadmin.utils.ajax import make_json_response, internal_server_error, \
make_response as ajax_response, gone
from pgadmin.browser.server_groups.servers.databases.schemas.tables.\
Expand Down Expand Up @@ -878,7 +879,7 @@ def sql(self, gid, sid, did, scid, tid, cid=None):

columns = []
for row in res['rows']:
columns.append({"column": row['column'].strip('"')})
columns.append({"column": unquote_ident(row['column'])})

data['columns'] = columns

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from flask import render_template
from flask_babel import gettext as _
from pgadmin.utils import unquote_ident
from pgadmin.utils.ajax import internal_server_error
from pgadmin.utils.exception import ObjectGone, ExecuteError
from functools import wraps
Expand Down Expand Up @@ -90,7 +91,7 @@ def get_index_constraints(conn, did, tid, ctype, cid=None, template_path=None):

columns = []
for r in res['rows']:
columns.append({"column": r['column'].strip('"')})
columns.append({"column": unquote_ident(r['column'])})

idx_cons['columns'] = columns

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

"""Regression test for index columns whose names require quoting (#6481).

pg_get_indexdef() returns such a name quoted, so comparing it against
pg_attribute.attname classified the column as an expression and the
Properties panel showed nothing at all. The SQL now compares against
quote_ident(attname), and the name is unquoted properly on the way out
rather than by stripping quote characters, which mangled any name
containing a literal double quote.
"""

import uuid

from pgadmin.browser.server_groups.servers.databases.schemas.tables.tests \
import utils as tables_utils
from pgadmin.browser.server_groups.servers.databases.schemas.tests import \
utils as schema_utils
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_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 indexes_utils


class IndexesQuotedColumnTestCase(BaseTestGenerator):
"""An index on a column needing quotes must report that column."""

url = "/browser/index/obj/"

scenarios = [
('Mixed case column name', dict(
column_name='Mixed Case',
)),
('Column name containing a double quote', dict(
column_name='col"x',
)),
('Column name that is a reserved word', dict(
column_name='select',
)),
]

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 a table.")
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 a table.")

self.table_name = "table_quoted_col_%s" % (str(uuid.uuid4())[1:8])
self.table_id = tables_utils.create_table(self.server, self.db_name,
self.schema_name,
self.table_name)

# The helpers interpolate names into SQL as given, so quote the
# column exactly as the server would.
quoted_column = '"%s"' % self.column_name.replace('"', '""')
self._add_column(quoted_column)

self.index_name = "test_index_quoted_%s" % (str(uuid.uuid4())[1:8])
self.index_id = indexes_utils.create_index(
self.server, self.db_name, self.schema_name, self.table_name,
self.index_name, quoted_column)

def _add_column(self, quoted_column):
connection = utils.get_db_connection(self.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('ALTER TABLE %s.%s ADD COLUMN %s text' % (
self.schema_name, self.table_name, quoted_column))
utils.set_isolation_level(connection, old_isolation_level)
connection.commit()
connection.close()

def runTest(self):
response = indexes_utils.api_get_index(self, self.index_id)
self.assertEqual(response.status_code, 200)

data = response.json
self.assertEqual(len(data['columns']), 1)
column = data['columns'][0]

# The name must come back exactly as the user typed it, and must not
# be mistaken for an expression.
self.assertEqual(column['colname'], self.column_name)
self.assertFalse(column['is_exp'])

def tearDown(self):
database_utils.disconnect_database(self, self.server_id, self.db_id)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from flask import render_template
from flask_babel import gettext
from pgadmin.utils import unquote_ident
from pgadmin.utils.ajax import internal_server_error
from pgadmin.utils.exception import ObjectGone, ExecuteError
from functools import wraps
Expand Down Expand Up @@ -120,7 +121,7 @@ def get_column_details(conn, idx, data, mode='properties', template_path=None):
# we will not strip down colname when using in SQL to display
cols_data = {
'colname': row['attdef'] if mode == 'create' else
row['attdef'].strip('"'),
unquote_ident(row['attdef']),
'collspcname': row['collnspname'],
'op_class': row['opcname'],
'col_num': row['attnum'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SELECT
coll.collname,
nspc.nspname as collnspname,
pg_catalog.format_type(ty.oid,NULL) AS datatype,
CASE WHEN pg_catalog.pg_get_indexdef(i.indexrelid, {{loop.index}}, true) = a.attname THEN FALSE ELSE TRUE END AS is_exp
CASE WHEN pg_catalog.pg_get_indexdef(i.indexrelid, {{loop.index}}, true) = pg_catalog.quote_ident(a.attname) THEN FALSE ELSE TRUE END AS is_exp
FROM pg_catalog.pg_index i
JOIN pg_catalog.pg_attribute a ON (a.attrelid = i.indexrelid AND attnum = {{loop.index}})
JOIN pg_catalog.pg_type ty ON ty.oid=a.atttypid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SELECT
END::text[] AS options,
i.attnum,
pg_catalog.pg_get_indexdef(i.indexrelid, i.attnum, true) as attdef,
CASE WHEN pg_catalog.pg_get_indexdef(i.indexrelid, i.attnum, true) = a.attname THEN FALSE ELSE TRUE END AS is_exp,
CASE WHEN pg_catalog.pg_get_indexdef(i.indexrelid, i.attnum, true) = pg_catalog.quote_ident(a.attname) THEN FALSE ELSE TRUE END AS is_exp,
a.attstattarget as statistics,
CASE WHEN (o.opcdefault = FALSE) THEN o.opcname ELSE null END AS opcname,
op.oprname AS oprname,
Expand Down
25 changes: 25 additions & 0 deletions web/pgadmin/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,31 @@ def document_dir():
return os.path.realpath(os.path.expanduser('~/'))


# A single SQL identifier, quoted, with every embedded double quote doubled.
_QUOTED_IDENT = re.compile(r'"(?:[^"]|"")*"\Z')


def unquote_ident(value):
"""
Reverse the quoting that Driver.qtIdent() and the server's own
quote_ident() apply to an identifier.

Catalogue functions such as pg_get_indexdef() return an identifier quoted
only when it needs to be, with any embedded double quote doubled, so a
column named 'col"x' arrives as '"col""x"'. Stripping the outer quotes
alone would leave that doubled quote behind.

Anything that is not a single quoted identifier, an unquoted name or an
expression such as '(a || b)', is returned unchanged.

:param value: identifier as returned by the server
:return: the identifier as the user typed it
"""
if value and _QUOTED_IDENT.match(value):
return value[1:-1].replace('""', '"')
return value


def get_directory_and_file_name(drivefilepath):
"""
Returns directory name if specified and file name
Expand Down
56 changes: 56 additions & 0 deletions web/pgadmin/utils/tests/test_unquote_ident.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

"""Tests for unquote_ident().

Identifiers arrive from catalogue functions such as pg_get_indexdef() quoted
only when they need to be, with any embedded double quote doubled. The
previous str.strip('"') removed the outer quotes but left the doubled ones
behind, so a column named 'col"x' was displayed as 'col""x' (#6481). An
expression must survive untouched, which strip() also failed at.
"""

from pgadmin.utils import unquote_ident
from pgadmin.utils.route import BaseTestGenerator


class UnquoteIdentTestCase(BaseTestGenerator):
"""unquote_ident() must reverse quote_ident() and leave the rest alone."""

scenarios = [
('An unquoted name is returned as is',
dict(value='colname', expected='colname')),
('Outer quotes are removed',
dict(value='"Col"', expected='Col')),
('A doubled inner quote is unescaped',
dict(value='"col""x"', expected='col"x')),
('Several doubled inner quotes are unescaped',
dict(value='"a""b""c"', expected='a"b"c')),
('A name that is nothing but quotes is unescaped',
dict(value='""""', expected='"')),
('A quoted name containing spaces keeps them',
dict(value='"my column"', expected='my column')),
('An unquoted expression is untouched',
dict(value='(a || b)', expected='(a || b)')),
('An expression of quoted names is untouched',
dict(value='"a" || "b"', expected='"a" || "b"')),
('A lone quote is untouched',
dict(value='"', expected='"')),
('An empty string is untouched',
dict(value='', expected='')),
('None is untouched',
dict(value=None, expected=None)),
]

def setUp(self):
# A pure string function: no server connection required.
pass

def runTest(self):
self.assertEqual(unquote_ident(self.value), self.expected)
Loading