From c5687b7ccf99e056992032917866ff309fd8f8bd Mon Sep 17 00:00:00 2001 From: Dave Page Date: Tue, 9 Jun 2026 11:55:21 +0100 Subject: [PATCH 1/4] 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 0b3a7f1ad23e2fd6d072e4e759622b5856d52103 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Mon, 17 Aug 2026 13:20:00 +0100 Subject: [PATCH 2/4] 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 d025f954eec50ef3469e54af5d435d60a2f867d9 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 20 Aug 2026 09:20:17 +0100 Subject: [PATCH 3/4] 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") From ae994acd627688bc94ae915327c697a7d0ee874a Mon Sep 17 00:00:00 2001 From: Dave Page Date: Tue, 1 Sep 2026 11:54:22 +0100 Subject: [PATCH 4/4] Report the database locale on PostgreSQL 15, and skip what Windows cannot do Three things, all turned up by CI. get_ctypes.sql only existed from the 16_plus bucket down, so a PostgreSQL 15 server fell back to the default template and reported the server's own lc_collate and lc_ctype settings rather than anything about the database, which is exactly the bug this is meant to fix. datlocprovider and daticulocale both arrived in 15, and 15 is also where a database first stopped necessarily collating according to datcollate and datctype, so the template moves down to the 15_plus bucket unchanged rather than being duplicated. The two libc scenarios deliberately set LC_COLLATE and LC_CTYPE to different values, since that is what proves which of the two columns the query reported. Windows builds accept the CREATE DATABASE but then refuse to open the result, so those two scenarios now skip with the server's own explanation when that is what comes back, and continue to fail on anything else. The builtin scenario no longer skips when template0 uses 'C' or 'POSIX'. That guard belongs to the libc scenarios, which need a template locale distinguishable from the defaults; the builtin scenario distinguishes against the 'C.UTF-8' it creates the database with, and 'C' and 'POSIX' are both perfectly distinguishable from that, so skipping them only lost the scenario on the servers most likely to have a default template0. --- .../sql/{16_plus => 15_plus}/get_ctypes.sql | 3 +++ .../databases/tests/test_db_get_ctypes.py | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) rename web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/{16_plus => 15_plus}/get_ctypes.sql (63%) 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/15_plus/get_ctypes.sql similarity index 63% rename from web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/16_plus/get_ctypes.sql rename to web/pgadmin/browser/server_groups/servers/databases/templates/databases/sql/15_plus/get_ctypes.sql index cd1ed28af9f..61bd1fe57f8 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/15_plus/get_ctypes.sql @@ -1,3 +1,6 @@ +{# datlocprovider and daticulocale both arrived in PostgreSQL 15, which is + also where a database first stopped necessarily collating according to + datcollate and datctype, so this bucket starts there. #} SELECT daticulocale AS cname FROM pg_catalog.pg_database WHERE datname = current_database() AND datlocprovider = 'i' UNION 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 354f5941368..3c43b5b6dab 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 @@ -22,6 +22,7 @@ import uuid +import psycopg from flask import render_template from pgadmin.utils.route import BaseTestGenerator @@ -105,7 +106,18 @@ def runTest(self): else (self.template_collate, 'C') self._create_database( "LC_COLLATE '%s' LC_CTYPE '%s'" % (collate, ctype)) - self.assertIn(self.template_collate, self._reported_locales()) + try: + reported = self._reported_locales() + except psycopg.OperationalError as exc: + # Windows builds accept the CREATE DATABASE but refuse to + # open a database whose collation and character type differ, + # so the pair that proves which of the two columns was read + # cannot be exercised there. + if 'not supported on this platform' not in str(exc): + raise + self.skipTest('This server will not open a database whose ' + 'collate and ctype differ: %s' % exc) + self.assertIn(self.template_collate, reported) return if self.provider == 'builtin': @@ -121,7 +133,12 @@ def runTest(self): "the only one a UTF-8 builtin database can " "use, so a distinguishable one cannot be " "created here.") - self._skip_unless_distinct_template_locale() + # Unlike the libc scenarios, nothing here needs a template + # locale that differs from the defaults: 'C' and 'POSIX' are + # both perfectly distinguishable from the 'C.UTF-8' the builtin + # database is created with, and skipping them would lose the + # scenario on exactly the servers most likely to have a default + # template0. self._create_database("LOCALE_PROVIDER builtin " "BUILTIN_LOCALE 'C.UTF-8' ENCODING UTF8")