From cb44791f8c125176e2bc793c263df7d66665ddc4 Mon Sep 17 00:00:00 2001
From: Peter Smith <peter.b.smith@fujitsu.com>
Date: Fri, 4 Sep 2026 17:21:15 +1000
Subject: [PATCH v5] Fix escapes for psql describe schema patterns

psql \dn was building the query with a raw '%s' substitution of the user-supplied pattern, so a pattern containing a single quote (\dn "it's a bug") broke out of the string literal and produced a syntax error on the server.

Fixed by using a pattern escape function instead of raw-substitution.

Patch also addressed other issues:

1. Now, always prints a footer for "Included in publications" when apropriate.
   A previous bug omitted footers for quoted nspnames.

2. Multiple pattern matches now print multiple schemas separately, each with
   their own footer (i.e. same behaviour as describe tables \d pattern).

Author: Peter Smith <smithpb2250@gmail.com>

Reviewed-by: Steven Niu <niushiji@gmail.com>
Reviewed by: Jim Jones <jim.jones@uni-muenster.de>
Reviewed-by: Nishant Sharma <nishant.sharma@enterprisedb.com>
Reviewed-by: surya poondla <suryapoondla4@gmail.com>
Reviewed-by: Ajin Cherian <itsajin@gmail.com>

Discussion: https://www.postgresql.org/message-id/flat/SA5PPF230C70D4BFBD6006437E357AE7CF9A7CB2%40SA5PPF230C70D4B.namprd14.prod.outlook.com#c4b1b145852d2076db6590e0aae5c7da
---
 src/bin/psql/describe.c                   | 153 ++++++++++++++++++----
 src/test/regress/expected/publication.out | 119 ++++++++++++++++-
 src/test/regress/sql/publication.sql      |  30 +++++
 3 files changed, 274 insertions(+), 28 deletions(-)

diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 063e2555814..4d1b9b8764a 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -38,6 +38,7 @@
 #include "settings.h"
 
 static const char *map_typename_pattern(const char *pattern);
+static bool describeOneSchemaDetails(const char *schemaname, bool verbose);
 static bool describeOneTableDetails(const char *schemaname,
 									const char *relationname,
 									const char *oid,
@@ -5262,22 +5263,19 @@ listCollations(const char *pattern, bool verbose, bool showSystem)
 }
 
 /*
- * \dn
- *
- * Describes schemas (namespaces)
+ * Print details and footer information for the specified schema.
  */
-bool
-listSchemas(const char *pattern, bool verbose, bool showSystem)
+static bool
+describeOneSchemaDetails(const char *schemaname, bool verbose)
 {
 	PQExpBufferData buf;
 	PGresult   *res;
 	printQueryOpt myopt = pset.popt;
-	int			pub_schema_tuples = 0;
+	PQExpBufferData title;
 	char	  **footers = NULL;
 
 	initPQExpBuffer(&buf);
-
-	printfPQExpBuffer(&buf, "/* %s */\n", _("Get matching schemas"));
+	printfPQExpBuffer(&buf, "/* %s */\n", _("Get matching schema"));
 	appendPQExpBuffer(&buf,
 					  "SELECT n.nspname AS \"%s\",\n"
 					  "  pg_catalog.pg_get_userbyid(n.nspowner) AS \"%s\"",
@@ -5294,32 +5292,26 @@ listSchemas(const char *pattern, bool verbose, bool showSystem)
 	}
 
 	appendPQExpBufferStr(&buf,
-						 "\nFROM pg_catalog.pg_namespace n\n");
-
-	if (!showSystem && !pattern)
-		appendPQExpBufferStr(&buf,
-							 "WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'\n");
-
-	if (!validateSQLNamePattern(&buf, pattern,
-								!showSystem && !pattern, false,
-								NULL, "n.nspname", NULL,
-								NULL,
-								NULL, 2))
-		goto error_return;
-
-	appendPQExpBufferStr(&buf, "ORDER BY 1;");
+						 "\nFROM pg_catalog.pg_namespace n"
+						 "\nWHERE n.nspname = ");
+	appendStringLiteralConn(&buf, schemaname, pset.db);
 
 	res = PSQLexec(buf.data);
 	if (!res)
 		goto error_return;
 
-	myopt.title = _("List of schemas");
+	initPQExpBuffer(&title);
+	printfPQExpBuffer(&title, _("Schema \"%s\""), schemaname);
+	myopt.title = title.data;
 	myopt.translate_header = true;
 
-	if (pattern && pset.sversion >= 150000)
+	/* Footer */
+	if (pset.sversion >= 150000)
 	{
 		PGresult   *result;
 		int			i;
+		int			pub_schema_tuples = 0;
+
 
 		printfPQExpBuffer(&buf, "/* %s */\n",
 						  _("Get publications that publish this schema"));
@@ -5328,15 +5320,20 @@ listSchemas(const char *pattern, bool verbose, bool showSystem)
 						  "FROM pg_catalog.pg_publication p\n"
 						  "     JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
 						  "     JOIN pg_catalog.pg_namespace n ON n.oid = pn.pnnspid \n"
-						  "WHERE n.nspname = '%s'\n"
-						  "ORDER BY 1",
-						  pattern);
+						  "WHERE n.nspname = ");
+		appendStringLiteralConn(&buf, schemaname, pset.db);
+
+		appendPQExpBufferStr(&buf, "ORDER BY 1;");
+
 		result = PSQLexec(buf.data);
 		if (!result)
 			goto error_return;
 		else
 			pub_schema_tuples = PQntuples(result);
 
+		/* Avoid showing "(1 row)" for tables without footers */
+		myopt.topt.default_footer = false;
+
 		if (pub_schema_tuples > 0)
 		{
 			/*
@@ -5386,6 +5383,108 @@ error_return:
 	return false;
 }
 
+/*
+ * \dn
+ *
+ * Describes schemas (namespaces)
+ *
+ * If no pattern is specified list all schemas.
+ *
+ * If a pattern is specified call describeOneSchemaDetails for each schema
+ * that matches the pattern.
+ */
+bool
+listSchemas(const char *pattern, bool verbose, bool showSystem)
+{
+	PQExpBufferData buf;
+	PGresult   *res;
+	printQueryOpt myopt = pset.popt;
+	int			num_schemas;
+
+	initPQExpBuffer(&buf);
+
+	printfPQExpBuffer(&buf, "/* %s */\n", _("Get matching schemas"));
+	appendPQExpBuffer(&buf,
+					  "SELECT n.nspname AS \"%s\",\n"
+					  "  pg_catalog.pg_get_userbyid(n.nspowner) AS \"%s\"",
+					  gettext_noop("Name"),
+					  gettext_noop("Owner"));
+
+	if (verbose)
+	{
+		appendPQExpBufferStr(&buf, ",\n  ");
+		printACLColumn(&buf, "n.nspacl");
+		appendPQExpBuffer(&buf,
+						  ",\n  pg_catalog.obj_description(n.oid, 'pg_namespace') AS \"%s\"",
+						  gettext_noop("Description"));
+	}
+
+	appendPQExpBufferStr(&buf,
+						 "\nFROM pg_catalog.pg_namespace n\n");
+
+	if (!showSystem && !pattern)
+		appendPQExpBufferStr(&buf,
+							 "WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'\n");
+
+	if (!validateSQLNamePattern(&buf, pattern,
+								!showSystem && !pattern, false,
+								NULL, "n.nspname", NULL,
+								NULL,
+								NULL, 2))
+		goto error_return;
+
+	appendPQExpBufferStr(&buf, "ORDER BY 1;");
+
+	res = PSQLexec(buf.data);
+	if (!res)
+		goto error_return;
+
+	num_schemas = PQntuples(res);
+
+	/*
+	 * Most functions in this file are content to print an empty table when
+	 * there are no matching objects.  We intentionally deviate from that
+	 * here, but only in !quiet mode, to be same as \dt
+	 */
+	if (num_schemas == 0 && !pset.quiet)
+	{
+		if (pattern)
+			pg_log_error("Did not find any schemas named \"%s\".",
+						 pattern);
+		else
+			pg_log_error("Did not find any schemas");
+
+		PQclear(res);
+		termPQExpBuffer(&buf);
+		return false;
+	}
+
+	if (pattern == NULL || num_schemas == 0)
+	{
+		myopt.title = _("List of schemas");
+		myopt.translate_header = true;
+
+		printQuery(res, &myopt, pset.queryFout, false, pset.logfile);
+	}
+	else
+	{
+		for (int i = 0; i < num_schemas; i++)
+		{
+			const char *nspname = PQgetvalue(res, i, 0);
+
+			describeOneSchemaDetails(nspname, verbose);
+		}
+	}
+
+	termPQExpBuffer(&buf);
+	PQclear(res);
+	return true;
+
+error_return:
+	termPQExpBuffer(&buf);
+	return false;
+}
+
 
 /*
  * \dFp
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 4f21462cc17..3bfa2b46bbb 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -129,7 +129,7 @@ Tables from schemas:
 SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub_forschema FOR TABLES IN SCHEMA pub_test;
 \dn pub_test
-           List of schemas
+          Schema "pub_test"
    Name   |          Owner           
 ----------+--------------------------
  pub_test | regress_publication_user
@@ -171,6 +171,123 @@ CREATE PUBLICATION testpub_parsertst FOR TABLES IN SCHEMA foo, test.foo;
 ERROR:  invalid schema name
 LINE 1: ...CATION testpub_parsertst FOR TABLES IN SCHEMA foo, test.foo;
                                                               ^
+-- should be able to publish and describe schemas and tables with embedded quotes
+CREATE SCHEMA "it's my schema";
+CREATE TABLE "it's my schema"."it's my schema table"("it's my col1" int, "it's my col2" int);
+CREATE TABLE "it's my public table"("it's my col1" int, "it's my col2" int);
+CREATE PUBLICATION regress_pub_embedded_quotes1 FOR TABLES IN SCHEMA "it's my schema";
+CREATE PUBLICATION regress_pub_embedded_quotes2 FOR TABLE "it's my schema"."it's my schema table";
+CREATE PUBLICATION regress_pub_embedded_quotes3 FOR TABLE "it's my public table"("it's my col1");
+CREATE PUBLICATION regress_pub_embedded_quotes4 FOR ALL TABLES EXCEPT (TABLE "it's my public table");
+\dn "it's my schema"
+          Schema "it's my schema"
+      Name      |          Owner           
+----------------+--------------------------
+ it's my schema | regress_publication_user
+Included in publications:
+    "regress_pub_embedded_quotes1"
+
+\d "it's my schema"."it's my schema table"
+       Table "it's my schema.it's my schema table"
+    Column    |  Type   | Collation | Nullable | Default 
+--------------+---------+-----------+----------+---------
+ it's my col1 | integer |           |          | 
+ it's my col2 | integer |           |          | 
+Included in publications:
+    "regress_pub_embedded_quotes1"
+    "regress_pub_embedded_quotes2"
+    "regress_pub_embedded_quotes4"
+    "testpub_foralltables"
+
+\d "it's my public table"
+           Table "public.it's my public table"
+    Column    |  Type   | Collation | Nullable | Default 
+--------------+---------+-----------+----------+---------
+ it's my col1 | integer |           |          | 
+ it's my col2 | integer |           |          | 
+Included in publications:
+    "regress_pub_embedded_quotes3" (it's my col1)
+    "testpub_foralltables"
+Excluded from publications:
+    "regress_pub_embedded_quotes4"
+
+\dRp+ regress_pub_embedded_quotes*
+                                                   Publication regress_pub_embedded_quotes1
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "it's my schema"
+
+                                                   Publication regress_pub_embedded_quotes2
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables:
+    "it's my schema.it's my schema table"
+
+                                                   Publication regress_pub_embedded_quotes3
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables:
+    "public.it's my public table" (it's my col1)
+
+                                                   Publication regress_pub_embedded_quotes4
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | t          | f             | t       | t       | t       | t         | none              | f        | 
+Except tables:
+    "public.it's my public table"
+
+DROP PUBLICATION regress_pub_embedded_quotes1, regress_pub_embedded_quotes2, regress_pub_embedded_quotes3, regress_pub_embedded_quotes4;
+DROP SCHEMA "it's my schema" CASCADE;
+NOTICE:  drop cascades to table "it's my schema"."it's my schema table"
+DROP TABLE "it's my public table";
+-- \dn pattern gives footers for each matched schema
+CREATE SCHEMA "SCH_1";
+CREATE SCHEMA sch_1;
+CREATE SCHEMA sch_2;
+CREATE SCHEMA sch_3;
+CREATE PUBLICATION pub_sch_1 FOR TABLES IN SCHEMA SCH_1;
+CREATE PUBLICATION pub_sch_2 FOR TABLES IN SCHEMA sch_2;
+\dn sch??
+          Schema "sch_1"
+ Name  |          Owner           
+-------+--------------------------
+ sch_1 | regress_publication_user
+Included in publications:
+    "pub_sch_1"
+
+          Schema "sch_2"
+ Name  |          Owner           
+-------+--------------------------
+ sch_2 | regress_publication_user
+Included in publications:
+    "pub_sch_2"
+
+          Schema "sch_3"
+ Name  |          Owner           
+-------+--------------------------
+ sch_3 | regress_publication_user
+
+-- quoted names are case sensitive
+\dn "SCH_1"
+          Schema "SCH_1"
+ Name  |          Owner           
+-------+--------------------------
+ SCH_1 | regress_publication_user
+
+\dn "sch_1"
+          Schema "sch_1"
+ Name  |          Owner           
+-------+--------------------------
+ sch_1 | regress_publication_user
+Included in publications:
+    "pub_sch_1"
+
+DROP PUBLICATION pub_sch_1, pub_sch_2;
+DROP SCHEMA "SCH_1", sch_1, sch_2, sch_3;
 -- should be able to add a table of the same schema to the schema publication
 ALTER PUBLICATION testpub_forschema ADD TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index fac54b02e27..78045978326 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -90,6 +90,36 @@ RESET client_min_messages;
 CREATE PUBLICATION testpub_parsertst FOR TABLE pub_test.testpub_nopk, CURRENT_SCHEMA;
 CREATE PUBLICATION testpub_parsertst FOR TABLES IN SCHEMA foo, test.foo;
 
+-- should be able to publish and describe schemas and tables with embedded quotes
+CREATE SCHEMA "it's my schema";
+CREATE TABLE "it's my schema"."it's my schema table"("it's my col1" int, "it's my col2" int);
+CREATE TABLE "it's my public table"("it's my col1" int, "it's my col2" int);
+CREATE PUBLICATION regress_pub_embedded_quotes1 FOR TABLES IN SCHEMA "it's my schema";
+CREATE PUBLICATION regress_pub_embedded_quotes2 FOR TABLE "it's my schema"."it's my schema table";
+CREATE PUBLICATION regress_pub_embedded_quotes3 FOR TABLE "it's my public table"("it's my col1");
+CREATE PUBLICATION regress_pub_embedded_quotes4 FOR ALL TABLES EXCEPT (TABLE "it's my public table");
+\dn "it's my schema"
+\d "it's my schema"."it's my schema table"
+\d "it's my public table"
+\dRp+ regress_pub_embedded_quotes*
+DROP PUBLICATION regress_pub_embedded_quotes1, regress_pub_embedded_quotes2, regress_pub_embedded_quotes3, regress_pub_embedded_quotes4;
+DROP SCHEMA "it's my schema" CASCADE;
+DROP TABLE "it's my public table";
+
+-- \dn pattern gives footers for each matched schema
+CREATE SCHEMA "SCH_1";
+CREATE SCHEMA sch_1;
+CREATE SCHEMA sch_2;
+CREATE SCHEMA sch_3;
+CREATE PUBLICATION pub_sch_1 FOR TABLES IN SCHEMA SCH_1;
+CREATE PUBLICATION pub_sch_2 FOR TABLES IN SCHEMA sch_2;
+\dn sch??
+-- quoted names are case sensitive
+\dn "SCH_1"
+\dn "sch_1"
+DROP PUBLICATION pub_sch_1, pub_sch_2;
+DROP SCHEMA "SCH_1", sch_1, sch_2, sch_3;
+
 -- should be able to add a table of the same schema to the schema publication
 ALTER PUBLICATION testpub_forschema ADD TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-- 
2.47.3

