From 6b5a797ec6bb1facb8e657a71d5e2f36efb0db74 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Tue, 9 Jun 2026 11:55:21 +0100 Subject: [PATCH 1/3] Fix collation/ctype query error for non-default LC_COLLATE. #9798 get_ctypes.sql for PG 16+/17+ wrapped a multi-row UNION inside a scalar subquery in a CASE ELSE branch. When the database is not ICU-based and datcollate != datctype (e.g. LC_COLLATE=C with a different ctype), the scalar subquery returned two rows -> "more than one row returned by a subquery used as an expression", which locked the collation input. Rewrite as a flat UNION of guarded SELECTs returning cname rows, matching how the handler already consumes the result (a list of rows). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../databases/sql/16_plus/get_ctypes.sql | 16 ++++++++-------- .../databases/sql/17_plus/get_ctypes.sql | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/16_plus/get_ctypes.sql b/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/16_plus/get_ctypes.sql index 8a5347a4070..cd1ed28af9f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/16_plus/get_ctypes.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/16_plus/get_ctypes.sql @@ -1,8 +1,8 @@ -SELECT CASE WHEN datlocprovider = 'i' THEN - (SELECT daticulocale as cname FROM pg_catalog.pg_database WHERE datname = current_database()) -ELSE - (SELECT datcollate as cname FROM pg_catalog.pg_database WHERE datname = current_database() - UNION - SELECT datctype as cname FROM pg_catalog.pg_database WHERE datname = current_database()) -END -FROM pg_catalog.pg_database WHERE datname = current_database(); +SELECT daticulocale AS cname FROM pg_catalog.pg_database +WHERE datname = current_database() AND datlocprovider = 'i' +UNION +SELECT datcollate AS cname FROM pg_catalog.pg_database +WHERE datname = current_database() AND datlocprovider <> 'i' +UNION +SELECT datctype AS cname FROM pg_catalog.pg_database +WHERE datname = current_database() AND datlocprovider <> 'i'; diff --git a/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql b/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql index 00221883616..ce86401264f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql @@ -1,8 +1,8 @@ -SELECT CASE WHEN datlocprovider = 'i' THEN - (SELECT datlocale as cname FROM pg_catalog.pg_database WHERE datname = current_database()) -ELSE - (SELECT datcollate as cname FROM pg_catalog.pg_database WHERE datname = current_database() - UNION - SELECT datctype as cname FROM pg_catalog.pg_database WHERE datname = current_database()) -END -FROM pg_catalog.pg_database WHERE datname = current_database(); +SELECT datlocale AS cname FROM pg_catalog.pg_database +WHERE datname = current_database() AND datlocprovider = 'i' +UNION +SELECT datcollate AS cname FROM pg_catalog.pg_database +WHERE datname = current_database() AND datlocprovider <> 'i' +UNION +SELECT datctype AS cname FROM pg_catalog.pg_database +WHERE datname = current_database() AND datlocprovider <> 'i'; From b44e1f3f466fa959f02672b1a09352aa5889fb4b Mon Sep 17 00:00:00 2001 From: Dave Page Date: Mon, 17 Aug 2026 13:20:00 +0100 Subject: [PATCH 2/3] Treat the builtin locale provider as ICU, not as libc PostgreSQL 17 added a third value to datlocprovider, 'b' for the builtin provider, so testing for <> 'i' lumps builtin in with libc. A builtin database keeps its locale in datlocale exactly as an ICU one does, and the datcollate and datctype it carries are merely inherited from its template, so the dialog offered a locale the database does not collate with and omitted the one it does. Verified against PostgreSQL 18: a database created with BUILTIN_LOCALE 'C.UTF-8' from an en_GB.UTF-8 template reported en_GB.UTF-8 before this change and C.UTF-8 after it. This matches 17_plus/properties.sql, which already reads datlocale for both providers. The 16_plus template keeps <> 'i' because PostgreSQL 16 has only the two providers, so there is nothing else for it to match. Tests run the versioned template against a database created with each provider in turn, which also covers the bucket selection, and assert that a libc database reports both its collation and its character type even when they differ, the case that #9798 came from. --- .../databases/sql/17_plus/get_ctypes.sql | 9 +- .../databases/tests/test_db_get_ctypes.py | 144 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py diff --git a/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql b/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql index ce86401264f..9a06912091a 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/17_plus/get_ctypes.sql @@ -1,8 +1,11 @@ +{# Both the ICU and the builtin provider keep the locale in datlocale; only a + libc database collates according to datcollate and datctype, which a + builtin database merely inherits from its template. #} SELECT datlocale AS cname FROM pg_catalog.pg_database -WHERE datname = current_database() AND datlocprovider = 'i' +WHERE datname = current_database() AND datlocprovider IN ('i', 'b') UNION SELECT datcollate AS cname FROM pg_catalog.pg_database -WHERE datname = current_database() AND datlocprovider <> 'i' +WHERE datname = current_database() AND datlocprovider = 'c' UNION SELECT datctype AS cname FROM pg_catalog.pg_database -WHERE datname = current_database() AND datlocprovider <> 'i'; +WHERE datname = current_database() AND datlocprovider = 'c'; diff --git a/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py b/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py new file mode 100644 index 00000000000..9abd92d456b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py @@ -0,0 +1,144 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Tests for the get_ctypes.sql templates (#9798). + +The query feeding the collation and character type fields of the Database +dialog has to report the locale the database actually uses, which depends on +its locale provider: datcollate and datctype for libc, and datlocale for both +ICU and the builtin provider introduced in PostgreSQL 17. A builtin database +still carries the collation it inherited from its template, so reading +datcollate for it reports a locale that is not in use. + +The query is executed here through the versioned template loader, so the test +also covers the server version picking the right template bucket. +""" + +import uuid + +from flask import render_template + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils + +DEFAULTS = ('C', 'POSIX') + + +class GetCtypesTestCase(BaseTestGenerator): + """get_ctypes.sql must report the locale in use, per locale provider.""" + + scenarios = [ + ('libc reports the collation', dict( + provider='libc', swapped=False)), + ('libc reports the character type', dict( + provider='libc', swapped=True)), + ('The builtin provider reports its own locale', dict( + provider='builtin', swapped=False)), + ('ICU reports the ICU locale', dict( + provider='icu', swapped=False)), + ] + + def setUp(self): + # No pgAdmin server registration needed: this exercises the SQL, so + # it talks to the server directly. + self.db_name = None + self.connection = self._connect('postgres') + cursor = self.connection.cursor() + cursor.execute("SELECT current_setting('server_version_num')::int") + self.server_version = cursor.fetchone()[0] + cursor.execute("SELECT datcollate, pg_encoding_to_char(encoding) " + "FROM pg_catalog.pg_database WHERE datname = " + "'template0'") + self.template_collate, self.template_encoding = cursor.fetchone() + + def _connect(self, db_name): + return utils.get_db_connection(db_name, + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + + def _create_database(self, options): + self.db_name = 'test_ctypes_%s' % str(uuid.uuid4())[1:8] + old_isolation_level = self.connection.isolation_level + utils.set_isolation_level(self.connection, 0) + cursor = self.connection.cursor() + cursor.execute('CREATE DATABASE "%s" TEMPLATE template0 %s' % ( + self.db_name, options)) + utils.set_isolation_level(self.connection, old_isolation_level) + self.connection.commit() + + def _reported_locales(self): + """Run the versioned get_ctypes.sql against the new database.""" + template_path = 'databases/sql/#{0}#'.format(self.server_version) + with self.app.app_context(): + sql = render_template("/".join([template_path, 'get_ctypes.sql'])) + + connection = self._connect(self.db_name) + try: + cursor = connection.cursor() + cursor.execute(sql) + return [row[0] for row in cursor.fetchall()] + finally: + connection.close() + + def _skip_unless_distinct_template_locale(self): + if self.template_collate in DEFAULTS: + self.skipTest( + "template0 uses the '%s' locale, so a database with a " + "collation distinguishable from the defaults cannot be " + "created here." % self.template_collate) + + def runTest(self): + if self.provider == 'libc': + self._skip_unless_distinct_template_locale() + # Set exactly one of the two to the template locale, so that the + # value proves which of datcollate and datctype was reported. + collate, ctype = ('C', self.template_collate) if self.swapped \ + else (self.template_collate, 'C') + self._create_database( + "LC_COLLATE '%s' LC_CTYPE '%s'" % (collate, ctype)) + self.assertIn(self.template_collate, self._reported_locales()) + return + + if self.provider == 'builtin': + if self.server_version < 170000: + self.skipTest('The builtin locale provider requires ' + 'PostgreSQL 17 or later.') + if self.template_encoding != 'UTF8': + self.skipTest("template0 is %s encoded, so a UTF-8 builtin " + "locale cannot be used here." + % self.template_encoding) + self._skip_unless_distinct_template_locale() + self._create_database("LOCALE_PROVIDER builtin " + "BUILTIN_LOCALE 'C.UTF-8' ENCODING UTF8") + + reported = self._reported_locales() + self.assertIn('C.UTF-8', reported) + # datcollate is inherited from the template and is not the locale + # this database collates with, so it must not be offered. + self.assertNotIn(self.template_collate, reported) + return + + # ICU + cursor = self.connection.cursor() + cursor.execute("SELECT 1 FROM pg_catalog.pg_collation " + "WHERE collprovider = 'i' LIMIT 1") + if cursor.fetchone() is None: + self.skipTest('This server was built without ICU support.') + self._create_database( + "LOCALE_PROVIDER icu ICU_LOCALE 'en-GB' LC_COLLATE '%s' " + "LC_CTYPE '%s'" % (self.template_collate, self.template_collate)) + self.assertIn('en-GB', self._reported_locales()) + + def tearDown(self): + if self.db_name: + utils.drop_database(self.connection, self.db_name) + self.connection.close() From 18297ff98501e26bc5ba53b85ae0c0dcd22219a4 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 20 Aug 2026 09:20:17 +0100 Subject: [PATCH 3/3] Fix two CI-only failures in the get_ctypes regression tests. The ICU scenario ran unconditionally, but LOCALE_PROVIDER/ICU_LOCALE on CREATE DATABASE was only added in PostgreSQL 15, so it failed with "option \"locale_provider\" not recognized" against PG14 in CI. The builtin scenario probes with a fixed BUILTIN_LOCALE 'C.UTF-8', and skipped only when template0's own datcollate was exactly 'C' or 'POSIX'. Several CI runners have template0 already on 'C.UTF-8', which is the only UTF-8 locale a builtin database can use, so the created database's locale collided with the template's inherited one and assertNotIn(self.template_collate, reported) failed spuriously. Skip that scenario too when template0 is already 'C.UTF-8'. --- .../servers/databases/tests/test_db_get_ctypes.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py b/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py index 9abd92d456b..354f5941368 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py +++ b/web/pgadmin/browser/server_groups/servers/databases/tests/test_db_get_ctypes.py @@ -116,6 +116,11 @@ def runTest(self): self.skipTest("template0 is %s encoded, so a UTF-8 builtin " "locale cannot be used here." % self.template_encoding) + if self.template_collate == 'C.UTF-8': + self.skipTest("template0 already uses the 'C.UTF-8' locale, " + "the only one a UTF-8 builtin database can " + "use, so a distinguishable one cannot be " + "created here.") self._skip_unless_distinct_template_locale() self._create_database("LOCALE_PROVIDER builtin " "BUILTIN_LOCALE 'C.UTF-8' ENCODING UTF8") @@ -128,6 +133,9 @@ def runTest(self): return # ICU + if self.server_version < 150000: + self.skipTest('LOCALE_PROVIDER icu requires PostgreSQL 15 or ' + 'later.') cursor = self.connection.cursor() cursor.execute("SELECT 1 FROM pg_catalog.pg_collation " "WHERE collprovider = 'i' LIMIT 1")