From 8029b0db10a9cd0ac88e3a85a5d2278c192d14e7 Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Fri, 10 Jul 2026 23:20:32 +0530 Subject: [PATCH 1/7] feat: add system-object variants for relation commands - add S variants for relation listing and description while preserving schema visibility - match psql pattern, special-relation, TOAST, and partitioned-index behavior - add catalog, visibility, detail, and TOAST tests plus a changelog entry --- changelog.rst | 4 +++ pgspecial/dbcommands.py | 64 +++++++++++++++++++++++++++++++++++------ tests/test_specials.py | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/changelog.rst b/changelog.rst index aad249a..e2230cd 100644 --- a/changelog.rst +++ b/changelog.rst @@ -2,6 +2,10 @@ Unreleased ========== +Features: +--------- +* Add the ``S`` modifier to relation commands so they can include system objects. + Bug fixes: ---------- * Include relation type/name titles in `\d` and `\d+` describe output so wildcard describe results retain per-relation context. diff --git a/pgspecial/dbcommands.py b/pgspecial/dbcommands.py index f46c8cc..13b8d0d 100644 --- a/pgspecial/dbcommands.py +++ b/pgspecial/dbcommands.py @@ -432,7 +432,7 @@ def _describe_extension(cur, oid): yield None, cur, headers, cur.statusmessage -def list_objects(cur, pattern, verbose, relkinds): +def list_objects(cur, pattern, verbose, relkinds, show_system=False): """ Returns (title, rows, header, status) @@ -440,9 +440,20 @@ def list_objects(cur, pattern, verbose, relkinds): and list_indexes relkinds is a list of strings to filter pg_class.relkind + show_system includes objects from system schemas """ schema_pattern, table_pattern = sql_name_pattern(pattern) + include_system_relations = show_system or bool(pattern) + + # Match psql: system searches include legacy special relations and, + # for table listings, TOAST relations. + relkinds = list(relkinds) + if include_system_relations: + relkinds.append("s") + + if "r" in relkinds: + relkinds.append("t") params = {"relkind": relkinds} if verbose: @@ -460,9 +471,10 @@ def list_objects(cur, pattern, verbose, relkinds): CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' WHEN 'p' THEN 'partitioned table' - WHEN 'm' THEN 'materialized view' WHEN 'i' THEN 'index' + WHEN 'm' THEN 'materialized view' + WHEN 'i' THEN 'index' WHEN 'I' THEN 'partitioned index' WHEN 'S' THEN 'sequence' WHEN 's' THEN 'special' - WHEN 'f' THEN 'foreign table' END + WHEN 'f' THEN 'foreign table' WHEN 't' THEN 'TOAST table' END as type, pg_catalog.pg_get_userbyid(c.relowner) as owner {verbose_columns} @@ -478,6 +490,8 @@ def list_objects(cur, pattern, verbose, relkinds): if schema_pattern: params["schema_pattern"] = SQL(" AND n.nspname ~ {}").format(schema_pattern) + elif show_system or pattern: + params["schema_pattern"] = SQL(" AND pg_catalog.pg_table_is_visible(c.oid) ") else: params["schema_pattern"] = SQL( """ @@ -506,24 +520,49 @@ def list_tables(cur, pattern, verbose): return list_objects(cur, pattern, verbose, ["r", "p", ""]) +@special_command("\\dtS", "\\dtS[+] [pattern]", "List user and system tables.") +def list_tables_system(cur, pattern, verbose): + return list_objects(cur, pattern, verbose, ["r", "p", ""], show_system=True) + + @special_command("\\dv", "\\dv[+] [pattern]", "List views.") def list_views(cur, pattern, verbose): - return list_objects(cur, pattern, verbose, ["v", "s", ""]) + return list_objects(cur, pattern, verbose, ["v", ""]) + + +@special_command("\\dvS", "\\dvS[+] [pattern]", "List user and system views.") +def list_views_system(cur, pattern, verbose): + return list_objects(cur, pattern, verbose, ["v", ""], show_system=True) @special_command("\\dm", "\\dm[+] [pattern]", "List materialized views.") def list_materialized_views(cur, pattern, verbose): - return list_objects(cur, pattern, verbose, ["m", "s", ""]) + return list_objects(cur, pattern, verbose, ["m", ""]) + + +@special_command("\\dmS", "\\dmS[+] [pattern]", "List user and system materialized views.") +def list_materialized_views_system(cur, pattern, verbose): + return list_objects(cur, pattern, verbose, ["m", ""], show_system=True) @special_command("\\ds", "\\ds[+] [pattern]", "List sequences.") def list_sequences(cur, pattern, verbose): - return list_objects(cur, pattern, verbose, ["S", "s", ""]) + return list_objects(cur, pattern, verbose, ["S", ""]) + + +@special_command("\\dsS", "\\dsS[+] [pattern]", "List user and system sequences.") +def list_sequences_system(cur, pattern, verbose): + return list_objects(cur, pattern, verbose, ["S", ""], show_system=True) @special_command("\\di", "\\di[+] [pattern]", "List indexes.") def list_indexes(cur, pattern, verbose): - return list_objects(cur, pattern, verbose, ["i", "s", ""]) + return list_objects(cur, pattern, verbose, ["i", "I", ""]) + + +@special_command("\\diS", "\\diS[+] [pattern]", "List user and system indexes.") +def list_indexes_system(cur, pattern, verbose): + return list_objects(cur, pattern, verbose, ["i", "I", ""], show_system=True) @special_command("\\df", "\\df[+] [pattern]", "List functions.") @@ -889,13 +928,22 @@ def _fetch_oid_details(cur, oid): @special_command("describe", "DESCRIBE [pattern]", "", hidden=True, case_sensitive=False) @special_command("\\d", "\\d[+] [pattern]", "List or describe tables, views and sequences.") def describe_table_details(cur, pattern, verbose): + return _describe_table_details(cur, pattern, verbose) + + +@special_command("\\dS", "\\dS[+] [pattern]", "List or describe user and system relations.") +def describe_table_details_system(cur, pattern, verbose): + return _describe_table_details(cur, pattern, verbose, show_system=True) + + +def _describe_table_details(cur, pattern, verbose, show_system=False): """ Returns (title, rows, headers, status) """ # This is a simple \d[+] command. No table name to follow. if not pattern: - return list_objects(cur, pattern, verbose, ["r", "p", "v", "m", "S", "f", ""]) + return list_objects(cur, pattern, verbose, ["r", "p", "v", "m", "S", "f", ""], show_system=show_system) # This is a \d command. A royal pain in the ass. schema, relname = sql_name_pattern(pattern) diff --git a/tests/test_specials.py b/tests/test_specials.py index a9a5d55..f9098e8 100755 --- a/tests/test_specials.py +++ b/tests/test_specials.py @@ -136,6 +136,22 @@ def test_slash_d(executor): assert results == expected +@dbtest +def test_slash_d_system(executor): + rows = executor(r"\dS")[1] + + assert ("public", "tbl1", "table", POSTGRES_USER) in rows + assert ("pg_catalog", "pg_class", "table", POSTGRES_USER) in rows + assert not any(row[1] == "pg_class_oid_index" for row in rows) + + +@dbtest +def test_slash_d_system_pattern(executor): + results = executor(r"\dS pg_class") + + assert results[0] == 'Table "pg_catalog.pg_class"' + + @dbtest def test_slash_d_verbose(executor): results = executor(r"\d+") @@ -520,6 +536,47 @@ def test_slash_dt(executor): assert results == expected +@pytest.mark.parametrize( + ("command", "user_object", "system_object"), + [ + (r"\dtS", ("public", "tbl1", "table", POSTGRES_USER), ("pg_catalog", "pg_class", "table", POSTGRES_USER)), + (r"\dvS", ("public", "vw1", "view", POSTGRES_USER), ("pg_catalog", "pg_views", "view", POSTGRES_USER)), + (r"\dmS", ("public", "mvw1", "materialized view", POSTGRES_USER), None), + (r"\dsS", ("public", "tbl2_id2_seq", "sequence", POSTGRES_USER), None), + (r"\diS", ("public", "id_text", "index", POSTGRES_USER), ("pg_catalog", "pg_class_oid_index", "index", POSTGRES_USER)), + ], +) +@dbtest +def test_slash_d_relation_system_variants(executor, command, user_object, system_object): + rows = executor(command)[1] + + assert user_object in rows + if system_object: + assert system_object in rows + + +@dbtest +def test_slash_dt_pattern_includes_system_tables(executor): + rows = executor(r"\dt pg_class")[1] + + assert rows == [("pg_catalog", "pg_class", "table", POSTGRES_USER)] + + +@dbtest +def test_slash_dt_system_respects_schema_visibility(executor): + rows = executor(r"\dtS")[1] + + assert ("schema1", "s1_tbl1", "table", POSTGRES_USER) not in rows + + +@dbtest +def test_slash_dt_pattern_includes_toast_tables(executor): + rows = executor(r"\dt pg_toast.*")[1] + + assert rows + assert all(schema == "pg_toast" and relation_type == "TOAST table" for schema, _, relation_type, _ in rows) + + @dbtest def test_slash_dt_verbose(executor): """List all tables in public schema in verbose mode.""" From 3dc72842f2385ad513ee1a4516abeed717f40545 Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Fri, 10 Jul 2026 23:58:06 +0530 Subject: [PATCH 2/7] refactor: reuse include_system_relations condition instead of duplicating --- pgspecial/dbcommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgspecial/dbcommands.py b/pgspecial/dbcommands.py index 13b8d0d..464ee4e 100644 --- a/pgspecial/dbcommands.py +++ b/pgspecial/dbcommands.py @@ -490,7 +490,7 @@ def list_objects(cur, pattern, verbose, relkinds, show_system=False): if schema_pattern: params["schema_pattern"] = SQL(" AND n.nspname ~ {}").format(schema_pattern) - elif show_system or pattern: + elif include_system_relations: params["schema_pattern"] = SQL(" AND pg_catalog.pg_table_is_visible(c.oid) ") else: params["schema_pattern"] = SQL( From 2fb545d8c45fd4e3bada1fc9645d0428d3511e43 Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Sat, 11 Jul 2026 12:43:50 +0530 Subject: [PATCH 3/7] test: strengthen coverage for system relation commands - add a partitioned table and index to the shared database fixture - verify `\diS` includes partitioned indexes - verify `\dtS+` returns verbose relation metadata --- tests/dbutils.py | 2 ++ tests/test_specials.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/dbutils.py b/tests/dbutils.py index 0e16647..b5229a0 100644 --- a/tests/dbutils.py +++ b/tests/dbutils.py @@ -63,6 +63,8 @@ def setup_db(conn): cur.execute("create table schema2.tbl2(id2 serial, txt2 text)") cur.execute("create table schema1.tbl2(id2 serial, txt2 text)") cur.execute("create table schema1.s1_tbl1(id1 integer, txt1 text)") + cur.execute("create table schema1.partitioned_tbl(id integer) partition by range (id)") + cur.execute("create index partitioned_idx on schema1.partitioned_tbl(id)") cur.execute("create table tbl3(c3 circle, exclude using gist (c3 with &&))") cur.execute('create table "Inh1"(value1 integer) inherits (tbl1)') cur.execute("create table inh2(value2 integer) inherits (tbl1, tbl2)") diff --git a/tests/test_specials.py b/tests/test_specials.py index f9098e8..58a83c7 100755 --- a/tests/test_specials.py +++ b/tests/test_specials.py @@ -555,6 +555,23 @@ def test_slash_d_relation_system_variants(executor, command, user_object, system assert system_object in rows +@dbtest +def test_slash_di_system_includes_partitioned_indexes(executor): + rows = executor(r"\diS schema1.partitioned_idx")[1] + + assert rows == [("schema1", "partitioned_idx", "partitioned index", POSTGRES_USER)] + + +@dbtest +def test_slash_dt_system_verbose(executor): + _, rows, headers, status = executor(r"\dtS+ tbl1") + + assert headers == objects_listing_headers + assert len(rows) == 1 + assert rows[0][:4] == ("public", "tbl1", "table", POSTGRES_USER) + assert status == "SELECT 1" + + @dbtest def test_slash_dt_pattern_includes_system_tables(executor): rows = executor(r"\dt pg_class")[1] From f425c76d070de4aa6efca5199a3b4e61ec0676c0 Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Sat, 11 Jul 2026 13:22:59 +0530 Subject: [PATCH 4/7] test: skip partitioned index coverage on PostgreSQL 10 - remove partitioned-index objects from the shared database setup - create and clean up the objects within the relevant test - skip the test when partitioned indexes are unsupported --- tests/dbutils.py | 2 -- tests/test_specials.py | 11 +++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/dbutils.py b/tests/dbutils.py index b5229a0..0e16647 100644 --- a/tests/dbutils.py +++ b/tests/dbutils.py @@ -63,8 +63,6 @@ def setup_db(conn): cur.execute("create table schema2.tbl2(id2 serial, txt2 text)") cur.execute("create table schema1.tbl2(id2 serial, txt2 text)") cur.execute("create table schema1.s1_tbl1(id1 integer, txt1 text)") - cur.execute("create table schema1.partitioned_tbl(id integer) partition by range (id)") - cur.execute("create index partitioned_idx on schema1.partitioned_tbl(id)") cur.execute("create table tbl3(c3 circle, exclude using gist (c3 with &&))") cur.execute('create table "Inh1"(value1 integer) inherits (tbl1)') cur.execute("create table inh2(value2 integer) inherits (tbl1, tbl2)") diff --git a/tests/test_specials.py b/tests/test_specials.py index 58a83c7..b9b7986 100755 --- a/tests/test_specials.py +++ b/tests/test_specials.py @@ -556,8 +556,15 @@ def test_slash_d_relation_system_variants(executor, command, user_object, system @dbtest -def test_slash_di_system_includes_partitioned_indexes(executor): - rows = executor(r"\diS schema1.partitioned_idx")[1] +@pytest.mark.skipif(SERVER_VERSION < 110000, reason="Partitioned indexes require PostgreSQL 11 or newer.") +def test_slash_di_system_includes_partitioned_indexes(executor, connection): + with connection.cursor() as cursor: + cursor.execute("CREATE TABLE schema1.partitioned_tbl (id integer) PARTITION BY RANGE (id)") + cursor.execute("CREATE INDEX partitioned_idx ON schema1.partitioned_tbl (id)") + try: + rows = executor(r"\diS schema1.partitioned_idx")[1] + finally: + cursor.execute("DROP TABLE schema1.partitioned_tbl") assert rows == [("schema1", "partitioned_idx", "partitioned index", POSTGRES_USER)] From 57d52f3b5cb0947ce4ccd5d079a5bf5ed80832e4 Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Sat, 11 Jul 2026 20:01:24 +0530 Subject: [PATCH 5/7] feat: add system-object variant for foreign table listings - Route \dE and \dES through a shared private helper. - Omit system-schema exclusions for \dES and advertise its S modifier. - Add FDW integration coverage for \dES. --- pgspecial/dbcommands.py | 25 +++++++++++++++++++++---- tests/test_specials.py | 12 ++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/pgspecial/dbcommands.py b/pgspecial/dbcommands.py index 464ee4e..e197dc9 100644 --- a/pgspecial/dbcommands.py +++ b/pgspecial/dbcommands.py @@ -1973,8 +1973,17 @@ def shell_command(cur, pattern, verbose): return [(None, cur, headers, subprocess.call(params))] -@special_command("\\dE", "\\dE[+] [pattern]", "List foreign tables.", aliases=()) +@special_command("\\dE", "\\dE[S+] [pattern]", "List foreign tables.", aliases=()) def list_foreign_tables(cur, pattern, verbose): + return _list_foreign_tables(cur, pattern, verbose) + + +@special_command("\\dES", "\\dE[S+] [pattern]", "List user and system foreign tables.") +def list_foreign_tables_system(cur, pattern, verbose): + return _list_foreign_tables(cur, pattern, verbose, show_system=True) + + +def _list_foreign_tables(cur, pattern, verbose, show_system=False): params = {} query = SQL( """ @@ -1986,9 +1995,7 @@ def list_foreign_tables(cur, pattern, verbose): FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('f','') - AND n.nspname <> 'pg_catalog' - AND n.nspname <> 'information_schema' - AND n.nspname !~ '^pg_toast' + {schema_filter} AND pg_catalog.pg_table_is_visible(c.oid) {filter} ORDER BY 1,2; @@ -2004,6 +2011,16 @@ def list_foreign_tables(cur, pattern, verbose): else: params["verbose_cols"] = SQL("") + if show_system: + params["schema_filter"] = SQL("") + else: + params["schema_filter"] = SQL( + """ + AND n.nspname <> 'pg_catalog' + AND n.nspname <> 'information_schema' + AND n.nspname !~ '^pg_toast'""" + ) + if pattern: _, tbl_name = sql_name_pattern(pattern) params["filter"] = SQL(" AND c.relname OPERATOR(pg_catalog.~) {} ").format(f"^({tbl_name})$") diff --git a/tests/test_specials.py b/tests/test_specials.py index b9b7986..1ef88ea 100755 --- a/tests/test_specials.py +++ b/tests/test_specials.py @@ -1218,6 +1218,18 @@ def test_slash_dE(executor): assert results == expected +@fdw_test +def test_slash_dE_system(executor): + with foreign_db_environ(): + results = executor(r"\dES") + title = None + rows = [("public", "foreign_foo", "foreign table", "postgres")] + headers = ["Schema", "Name", "Type", "Owner"] + status = "SELECT 1" + expected = [title, rows, headers, status] + assert results == expected + + @fdw_test def test_slash_dE_with_pattern(executor): with foreign_db_environ(): From c325b09646c9a6839272b71bcecb778c71d4cdee Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Sun, 12 Jul 2026 19:55:57 +0530 Subject: [PATCH 6/7] fix: respect schema-qualified patterns when listing foreign tables - route `\dE` and `\dES` through the shared relation-listing implementation - add FDW coverage for a schema-qualified foreign table outside the search path --- pgspecial/dbcommands.py | 58 ++--------------------------------------- tests/test_specials.py | 14 ++++++++++ 2 files changed, 16 insertions(+), 56 deletions(-) diff --git a/pgspecial/dbcommands.py b/pgspecial/dbcommands.py index e197dc9..fabcd55 100644 --- a/pgspecial/dbcommands.py +++ b/pgspecial/dbcommands.py @@ -1975,66 +1975,12 @@ def shell_command(cur, pattern, verbose): @special_command("\\dE", "\\dE[S+] [pattern]", "List foreign tables.", aliases=()) def list_foreign_tables(cur, pattern, verbose): - return _list_foreign_tables(cur, pattern, verbose) + return list_objects(cur, pattern, verbose, ["f", ""]) @special_command("\\dES", "\\dE[S+] [pattern]", "List user and system foreign tables.") def list_foreign_tables_system(cur, pattern, verbose): - return _list_foreign_tables(cur, pattern, verbose, show_system=True) - - -def _list_foreign_tables(cur, pattern, verbose, show_system=False): - params = {} - query = SQL( - """ - SELECT n.nspname as schema, - c.relname as name, - CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' WHEN 'i' THEN 'index' WHEN 'S' THEN 'sequence' WHEN 's' THEN 'special' WHEN 'f' THEN 'foreign table' WHEN 'p' THEN 'table' WHEN 'I' THEN 'index' END as type, - pg_catalog.pg_get_userbyid(c.relowner) as owner - {verbose_cols} - FROM pg_catalog.pg_class c - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('f','') - {schema_filter} - AND pg_catalog.pg_table_is_visible(c.oid) - {filter} - ORDER BY 1,2; - """ - ) - - if verbose: - params["verbose_cols"] = SQL( - """ - , pg_catalog.pg_size_pretty(pg_catalog.pg_table_size(c.oid)) as size, - pg_catalog.obj_description(c.oid, 'pg_class') as description """ - ) - else: - params["verbose_cols"] = SQL("") - - if show_system: - params["schema_filter"] = SQL("") - else: - params["schema_filter"] = SQL( - """ - AND n.nspname <> 'pg_catalog' - AND n.nspname <> 'information_schema' - AND n.nspname !~ '^pg_toast'""" - ) - - if pattern: - _, tbl_name = sql_name_pattern(pattern) - params["filter"] = SQL(" AND c.relname OPERATOR(pg_catalog.~) {} ").format(f"^({tbl_name})$") - else: - params["filter"] = SQL("") - - formatted_query = query.format(**params) - log.debug(formatted_query.as_string(cur)) - cur.execute(formatted_query) - if cur.description: - headers = [titleize(x.name) for x in cur.description] - return [(None, cur, headers, cur.statusmessage)] - else: - return [(None, None, None, cur.statusmessage)] + return list_objects(cur, pattern, verbose, ["f", ""], show_system=True) def titleize(column): diff --git a/tests/test_specials.py b/tests/test_specials.py index 1ef88ea..1581e5a 100755 --- a/tests/test_specials.py +++ b/tests/test_specials.py @@ -1230,6 +1230,20 @@ def test_slash_dE_system(executor): assert results == expected +@fdw_test +def test_slash_dE_system_with_schema_pattern(executor, connection): + with foreign_db_environ(): + with connection.cursor() as cursor: + cursor.execute( + "CREATE FOREIGN TABLE schema1.foreign_foo (a int, b text) " + "SERVER foreign_db_server OPTIONS (schema_name 'public', table_name 'foreign_foo')" + ) + + results = executor(r"\dES schema1.foreign_foo") + + assert results[1] == [("schema1", "foreign_foo", "foreign table", POSTGRES_USER)] + + @fdw_test def test_slash_dE_with_pattern(executor): with foreign_db_environ(): From 0eddbabe9ed0688f7199865d518226048f8fb25c Mon Sep 17 00:00:00 2001 From: Devadathan M B Date: Sun, 12 Jul 2026 20:08:16 +0530 Subject: [PATCH 7/7] fix: correct \dES help syntax --- pgspecial/dbcommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgspecial/dbcommands.py b/pgspecial/dbcommands.py index fabcd55..1e620da 100644 --- a/pgspecial/dbcommands.py +++ b/pgspecial/dbcommands.py @@ -1978,7 +1978,7 @@ def list_foreign_tables(cur, pattern, verbose): return list_objects(cur, pattern, verbose, ["f", ""]) -@special_command("\\dES", "\\dE[S+] [pattern]", "List user and system foreign tables.") +@special_command("\\dES", "\\dES[+] [pattern]", "List user and system foreign tables.") def list_foreign_tables_system(cur, pattern, verbose): return list_objects(cur, pattern, verbose, ["f", ""], show_system=True)