From ddda1f5d20686bf389c54b14fc81243969786461 Mon Sep 17 00:00:00 2001
From: Surya Poondla <suryapoondla4@gmail.com>
Date: Wed, 1 Jul 2026 17:30:02 -0700
Subject: [PATCH] WIP: pgtrashcan contrib extension

Add contrib/pgtrashcan, a new extension that intercepts DROP TABLE
via a ProcessUtility hook and moves the relation to a reserved
pgtrashcan schema instead of destroying it. The relation can later
be flashed back or permanently deleted.

The extension is a fork of Peter Eisentraut's 2014 pgtrashcan
(https://github.com/petere/pgtrashcan), extended with:

  1. Index and owned-sequence support. Indexes (including primary
     keys) and sequences owned by the table travel with it into
     the trash schema and come back on restore.

  2. Metadata tracking. A protected catalog table records every
     dropped object with its original schema, name, OID, drop
     time, dropped_by user, and dependency links. Definitions of
     objects that cannot safely follow the table (foreign keys,
     triggers, views, matviews, rules, RLS policies) are captured
     into a JSONB column.

  3. CASCADE support. DROP TABLE ... CASCADE walks pg_depend and
     pg_constraint to move FK-dependent child tables into the
     trash schema atomically with the parent; dependent views and
     matviews are captured (as above) rather than moved.

  4. Restore, purge, and list API. Tables can be flashed back by
     name or OID, purged permanently (individually or wholesale),
     and moved into a schema other than the original if the
     original no longer exists or a rename is needed to avoid
     collision.

  5. Session opt-out via a boolean GUC, equivalent to Oracle's
     ALTER SESSION SET recyclebin = OFF.

Files added:
  contrib/pgtrashcan/{Makefile,meson.build,pgtrashcan.c,
                     pgtrashcan.control,pgtrashcan--1.0.sql,
                     README.md,sql/pgtrashcan.sql,
                     expected/pgtrashcan.out}
  doc/src/sgml/pgtrashcan.sgml

Files modified:
  contrib/Makefile, contrib/meson.build         (subdir wiring)
  doc/src/sgml/contrib.sgml, filelist.sgml      (doc wiring)
---
 contrib/Makefile                           |    1 +
 contrib/meson.build                        |    1 +
 contrib/pgtrashcan/Makefile                |   23 +
 contrib/pgtrashcan/README.md               |  117 +
 contrib/pgtrashcan/expected/pgtrashcan.out | 2068 ++++++++
 contrib/pgtrashcan/meson.build             |   34 +
 contrib/pgtrashcan/pgtrashcan--1.0.sql     | 1228 +++++
 contrib/pgtrashcan/pgtrashcan.c            | 5031 ++++++++++++++++++++
 contrib/pgtrashcan/pgtrashcan.control      |    4 +
 contrib/pgtrashcan/sql/pgtrashcan.sql      | 1402 ++++++
 doc/src/sgml/contrib.sgml                  |    1 +
 doc/src/sgml/filelist.sgml                 |    1 +
 doc/src/sgml/pgtrashcan.sgml               |  395 ++
 13 files changed, 10306 insertions(+)
 create mode 100644 contrib/pgtrashcan/Makefile
 create mode 100644 contrib/pgtrashcan/README.md
 create mode 100644 contrib/pgtrashcan/expected/pgtrashcan.out
 create mode 100644 contrib/pgtrashcan/meson.build
 create mode 100644 contrib/pgtrashcan/pgtrashcan--1.0.sql
 create mode 100644 contrib/pgtrashcan/pgtrashcan.c
 create mode 100644 contrib/pgtrashcan/pgtrashcan.control
 create mode 100644 contrib/pgtrashcan/sql/pgtrashcan.sql
 create mode 100644 doc/src/sgml/pgtrashcan.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 7d91fe77db3..b02291342a9 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -42,6 +42,7 @@ SUBDIRS = \
 		pg_trgm		\
 		pgrowlocks	\
 		pgstattuple	\
+		pgtrashcan	\
 		pg_visibility	\
 		pg_walinspect	\
 		postgres_fdw	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ebb7f83d8c5..a5d16605866 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -58,6 +58,7 @@ subdir('pg_surgery')
 subdir('pg_trgm')
 subdir('pg_visibility')
 subdir('pg_walinspect')
+subdir('pgtrashcan')
 subdir('postgres_fdw')
 subdir('seg')
 subdir('sepgsql')
diff --git a/contrib/pgtrashcan/Makefile b/contrib/pgtrashcan/Makefile
new file mode 100644
index 00000000000..296a4b0768e
--- /dev/null
+++ b/contrib/pgtrashcan/Makefile
@@ -0,0 +1,23 @@
+# contrib/pgtrashcan/Makefile
+
+MODULE_big = pgtrashcan
+OBJS = \
+	$(WIN32RES) \
+	pgtrashcan.o
+
+EXTENSION = pgtrashcan
+DATA = pgtrashcan--1.0.sql
+PGFILEDESC = "pgtrashcan - intercept DROP TABLE and move tables to a trash schema"
+
+REGRESS = pgtrashcan
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pgtrashcan
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pgtrashcan/README.md b/contrib/pgtrashcan/README.md
new file mode 100644
index 00000000000..2cf20a24e6f
--- /dev/null
+++ b/contrib/pgtrashcan/README.md
@@ -0,0 +1,117 @@
+# pgtrashcan
+
+`pgtrashcan` is a PostgreSQL extension that intercepts `DROP TABLE` and moves
+the relation to a reserved `pgtrashcan` schema instead of immediately
+destroying it. The table can later be restored ("flashed back") to its
+original schema, or permanently deleted ("purged").
+
+It is loosely modelled on Oracle's recyclebin (`FLASHBACK TABLE ... TO BEFORE
+DROP`) and Snowflake's / BigQuery's `UNDROP TABLE`.
+
+This extension was originally written by Peter Eisentraut in 2014. The
+present fork extends it with `CASCADE` handling, a restore / purge API,
+JSONB metadata capture for objects that cannot safely be moved
+(foreign keys, triggers, views, rules, RLS policies), and a 58-test
+regression suite.
+
+## Build and install
+
+```
+make USE_PGXS=1 PG_CONFIG=/path/to/pg_config
+make USE_PGXS=1 PG_CONFIG=/path/to/pg_config install
+```
+
+Inside the PostgreSQL source tree, drop this directory into `contrib/` and
+plain `make` works.
+
+`pgtrashcan` installs a `ProcessUtility_hook`, so the shared library must be
+preloaded:
+
+```
+# postgresql.conf
+shared_preload_libraries = 'pgtrashcan'
+```
+
+Restart the server, then in each database that should use it:
+
+```
+CREATE EXTENSION pgtrashcan;
+```
+
+## A short tour
+
+```sql
+-- Drop something
+postgres=# CREATE TABLE inventory (id int primary key, item text);
+postgres=# INSERT INTO inventory VALUES (1, 'router');
+postgres=# DROP TABLE inventory;
+
+-- It's not gone -- it's in the pgtrashcan schema
+postgres=# SELECT trash_name, orig_schema, orig_name, drop_time
+           FROM pgtrashcan_list_dropped();
+   trash_name    | orig_schema | orig_name |          drop_time
+-----------------+-------------+-----------+------------------------------
+ trash_$16385    | public      | inventory | 2026-06-29 14:12:33.421+00
+
+-- Restore it
+postgres=# SELECT pgtrashcan_restore('inventory');
+postgres=# SELECT * FROM inventory;
+ id |  item
+----+--------
+  1 | router
+
+-- Or purge it permanently
+postgres=# DROP TABLE inventory;
+postgres=# SELECT pgtrashcan_purge('inventory');
+```
+
+## What survives the drop, what does not
+
+Moved to `pgtrashcan` with the table and fully restored on flashback:
+
+- the table heap (including TOAST)
+- indexes (including the primary key)
+- sequences owned by the table (`SERIAL` / `GENERATED ... AS IDENTITY`)
+
+Permanently dropped at `DROP` time; definitions captured into
+`pgtrashcan._trash_catalog.metadata` (JSONB) and emitted as `NOTICE` on
+restore for manual reconstruction:
+
+- foreign-key constraints (both directions)
+- triggers
+- views and materialized views that referenced the table
+- rules
+- RLS policies
+
+`DROP TABLE ... CASCADE` walks `pg_depend` to also move FK-dependent child
+tables. `DROP TABLE ... RESTRICT` (the default) errors out if dependents
+exist, matching standard PostgreSQL behaviour.
+
+Temporary tables, partitioned tables, and foreign tables are not handled by
+the extension and pass through to the standard `DROP` path.
+
+## Configuration
+
+| GUC                              | Default | Scope         | Purpose                                              |
+|----------------------------------|---------|---------------|------------------------------------------------------|
+| `pgtrashcan.enabled`             | `on`    | `PGC_USERSET` | Per-session opt-out; mirrors Oracle's `recyclebin`.  |
+| `pgtrashcan.internal_operation`  | `off`   | `PGC_SUSET`   | Internal-only; gates catalog DML for restore/purge.  |
+
+## Full reference
+
+See `doc/src/sgml/pgtrashcan.sgml` for the complete reference: catalog
+schema, every SQL function, behaviour edge cases, limitations.
+
+## Running the regression suite
+
+```
+make USE_PGXS=1 installcheck PG_CONFIG=/path/to/pg_config
+```
+
+Requires `shared_preload_libraries = 'pgtrashcan'` in the target cluster's
+`postgresql.conf` and a server restart.
+
+## Credits
+
+- Peter Eisentraut, original 2014 `pgtrashcan` (the hook scaffolding and the
+  trash-can UX this extension still builds on).
diff --git a/contrib/pgtrashcan/expected/pgtrashcan.out b/contrib/pgtrashcan/expected/pgtrashcan.out
new file mode 100644
index 00000000000..0e35da0da5a
--- /dev/null
+++ b/contrib/pgtrashcan/expected/pgtrashcan.out
@@ -0,0 +1,2068 @@
+-- pgtrashcan regression test suite
+-- Tests: DROP TABLE interception, restore, purge, CASCADE, metadata, protections
+--
+-- NOTE: This test relies on shared_preload_libraries = 'pgtrashcan'
+-- being set in postgresql.conf. The pg_regress framework creates a
+-- temporary database for testing.
+--
+-- TEST SECTIONS:
+--   Section 1: Core - Basic DROP and Restore
+--   Section 2: Indexes and Sequences
+--   Section 3: CASCADE Behavior
+--   Section 4: Metadata Capture
+--   Section 5: Restore Advanced (versions, schemas, conflicts)
+--   Section 6: Purge Operations
+--   Section 7: DROP TABLE Variants (IF EXISTS, multi-table, schemas)
+--   Section 8: Protection Mechanisms
+--   Section 9: Listing Functions
+--   Section 10: Final State
+-- Setup: create the extension (installs schema + catalog + functions)
+CREATE EXTENSION IF NOT EXISTS pgtrashcan;
+-- Suppress NOTICE messages from restore/purge functions (they contain
+-- dynamic content like OIDs and timestamps that would make output non-deterministic)
+SET client_min_messages = warning;
+-- ########################################
+-- SECTION 1: Core - Basic DROP and Restore
+-- ########################################
+-- ========================================
+-- TEST 1: Basic DROP TABLE moves to pgtrashcan
+-- ========================================
+CREATE TABLE test_basic (id int, val text);
+INSERT INTO test_basic VALUES (1, 'hello');
+DROP TABLE test_basic;
+-- Verify table is in trash catalog
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_basic' ORDER BY orig_name;
+ orig_name  | orig_schema | orig_obj_type 
+------------+-------------+---------------
+ test_basic | public      | table
+(1 row)
+
+-- ========================================
+-- TEST 2: Restore by name + data survives
+-- ========================================
+SELECT pgtrashcan_restore('test_basic') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify data survived
+SELECT * FROM test_basic;
+ id |  val  
+----+-------
+  1 | hello
+(1 row)
+
+-- Verify catalog entry removed
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_basic';
+ remaining 
+-----------
+         0
+(1 row)
+
+-- ========================================
+-- TEST 3: TOAST data survives drop/restore
+-- ========================================
+DROP TABLE test_basic;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+CREATE TABLE toast_test (id int PRIMARY KEY, large_text text);
+INSERT INTO toast_test VALUES (1, repeat('x', 10000));
+DROP TABLE toast_test;
+SELECT pgtrashcan_restore('toast_test') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify large data survived via TOAST
+SELECT id, length(large_text) AS text_length FROM toast_test;
+ id | text_length 
+----+-------------
+  1 |       10000
+(1 row)
+
+DROP TABLE toast_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 4: DROP from pgtrashcan is permanent
+-- ========================================
+CREATE TABLE test_perm (id int);
+DROP TABLE test_perm;
+-- Verify in trash first
+SELECT count(*) AS in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_perm';
+ in_trash 
+----------
+        1
+(1 row)
+
+-- Get trash_name and drop it permanently
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'test_perm' LIMIT 1;
+    EXECUTE format('DROP TABLE pgtrashcan.%I', v_tname);
+END $$;
+-- Verify gone from catalog
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_perm';
+ remaining 
+-----------
+         0
+(1 row)
+
+-- ########################################
+-- SECTION 2: Indexes and Sequences
+-- ########################################
+-- ========================================
+-- TEST 5: Indexes are preserved on drop/restore
+-- ========================================
+CREATE TABLE test_idx (id int PRIMARY KEY, email text UNIQUE, name text);
+CREATE INDEX idx_test_name ON test_idx(name);
+INSERT INTO test_idx VALUES (1, 'a@b.com', 'Alice');
+DROP TABLE test_idx;
+-- Check indexes tracked in catalog
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_idx', 'test_idx_pkey', 'test_idx_email_key', 'idx_test_name')
+ORDER BY orig_obj_type, orig_name;
+     orig_name      | orig_obj_type 
+--------------------+---------------
+ idx_test_name      | index
+ test_idx_email_key | index
+ test_idx_pkey      | index
+ test_idx           | table
+(4 rows)
+
+-- Restore
+SELECT pgtrashcan_restore('test_idx') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify indexes restored
+SELECT indexname FROM pg_indexes
+WHERE tablename = 'test_idx' AND schemaname = 'public'
+ORDER BY indexname;
+     indexname      
+--------------------
+ idx_test_name
+ test_idx_email_key
+ test_idx_pkey
+(3 rows)
+
+-- Verify data
+SELECT * FROM test_idx;
+ id |  email  | name  
+----+---------+-------
+  1 | a@b.com | Alice
+(1 row)
+
+-- Cleanup
+DROP TABLE test_idx;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 6: Index name conflict detection + resolution via rename
+-- ========================================
+CREATE TABLE idx_tbl1 (id int, value text);
+CREATE INDEX shared_idx ON idx_tbl1(value);
+DROP TABLE idx_tbl1;
+-- Create another table reusing the same index name
+CREATE TABLE idx_tbl2 (id int, value text);
+CREATE INDEX shared_idx ON idx_tbl2(value);
+-- Restore should fail due to index name conflict
+DO $$
+BEGIN
+    PERFORM pgtrashcan_restore('idx_tbl1');
+    RAISE WARNING 'UNEXPECTED: restore should have failed but succeeded';
+EXCEPTION
+    WHEN OTHERS THEN
+        RAISE WARNING 'OK: index conflict correctly detected';
+END $$;
+WARNING:  OK: index conflict correctly detected
+-- Rename the conflicting index in trash to resolve
+DO $$
+DECLARE v_idx_tname text;
+BEGIN
+    SELECT trash_name INTO v_idx_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'shared_idx' LIMIT 1;
+    PERFORM pgtrashcan_rename_in_trash(v_idx_tname, 'shared_idx_old');
+END $$;
+-- Now restore should succeed
+SELECT pgtrashcan_restore('idx_tbl1') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify both tables have their indexes
+SELECT tablename, indexname FROM pg_indexes
+WHERE schemaname = 'public' AND tablename IN ('idx_tbl1', 'idx_tbl2')
+ORDER BY tablename, indexname;
+ tablename |   indexname    
+-----------+----------------
+ idx_tbl1  | shared_idx_old
+ idx_tbl2  | shared_idx
+(2 rows)
+
+DROP TABLE idx_tbl1, idx_tbl2;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 7: SERIAL sequence moves with table
+-- ========================================
+CREATE TABLE serial_test (id SERIAL PRIMARY KEY, val text);
+INSERT INTO serial_test (val) VALUES ('row1');
+DROP TABLE serial_test;
+-- Sequence should be tracked
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('serial_test', 'serial_test_id_seq')
+ORDER BY orig_obj_type, orig_name;
+     orig_name      | orig_obj_type 
+--------------------+---------------
+ serial_test_id_seq | sequence
+ serial_test        | table
+(2 rows)
+
+-- Restore and verify sequence works
+SELECT pgtrashcan_restore('serial_test') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+INSERT INTO serial_test (val) VALUES ('row2');
+SELECT id, val FROM serial_test ORDER BY id;
+ id | val  
+----+------
+  1 | row1
+  2 | row2
+(2 rows)
+
+DROP TABLE serial_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 3: CASCADE Behavior
+-- ########################################
+-- ========================================
+-- TEST 8: RESTRICT fails with FK dependents
+-- TEST 9: CASCADE drops FK constraint, child table survives
+-- (paired: test 8 leaves test_parent + test_child for test 9)
+-- ========================================
+CREATE TABLE test_parent (id int PRIMARY KEY);
+CREATE TABLE test_child (id int, pid int REFERENCES test_parent(id));
+-- Should fail: child depends on parent
+DROP TABLE test_parent;
+ERROR:  cannot drop table public.test_parent because other objects depend on it
+DETAIL:  constraint test_child_pid_fkey on table public.test_child depends on table public.test_parent
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- Verify parent not in trash (drop failed)
+SELECT count(*) AS parent_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_parent';
+ parent_in_trash 
+-----------------
+               0
+(1 row)
+
+-- Now CASCADE: only parent goes to trash, child survives with FK dropped
+DROP TABLE test_parent CASCADE;
+-- Only parent should be in trash (child table stays, only FK constraint dropped)
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_parent', 'test_child') AND orig_obj_type = 'table'
+ORDER BY orig_name;
+  orig_name  | orig_obj_type 
+-------------+---------------
+ test_parent | table
+(1 row)
+
+-- Child table should still exist in public schema
+SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = 'test_child';
+ tablename  
+------------
+ test_child
+(1 row)
+
+-- FK constraint should be gone from child
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'test_child'::regclass AND contype = 'f';
+ fk_count 
+----------
+        0
+(1 row)
+
+-- FK metadata should be captured on parent
+SELECT orig_name, metadata ? 'foreign_keys_dropped' AS has_fk_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_parent';
+  orig_name  | has_fk_metadata 
+-------------+-----------------
+ test_parent | t
+(1 row)
+
+DROP TABLE test_child;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 10: CASCADE with multiple FK children - children survive
+-- ========================================
+CREATE TABLE cascade_parent (id int PRIMARY KEY, name text);
+CREATE TABLE cascade_child1 (id int PRIMARY KEY, parent_id int REFERENCES cascade_parent(id));
+CREATE TABLE cascade_child2 (id int PRIMARY KEY, parent_id int REFERENCES cascade_parent(id));
+INSERT INTO cascade_parent VALUES (1, 'Parent');
+INSERT INTO cascade_child1 VALUES (1, 1);
+INSERT INTO cascade_child2 VALUES (1, 1);
+DROP TABLE cascade_parent CASCADE;
+-- Only parent should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('cascade_parent', 'cascade_child1', 'cascade_child2') AND orig_obj_type = 'table'
+ORDER BY orig_name;
+   orig_name    | orig_obj_type 
+----------------+---------------
+ cascade_parent | table
+(1 row)
+
+-- Both children should still exist with their data intact
+SELECT * FROM cascade_child1;
+ id | parent_id 
+----+-----------
+  1 |         1
+(1 row)
+
+SELECT * FROM cascade_child2;
+ id | parent_id 
+----+-----------
+  1 |         1
+(1 row)
+
+-- FK constraints should be gone from both children
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid IN ('cascade_child1'::regclass, 'cascade_child2'::regclass) AND contype = 'f';
+ fk_count 
+----------
+        0
+(1 row)
+
+DROP TABLE cascade_child1, cascade_child2;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 11: RESTRICT fails with view dependents
+-- TEST 12: CASCADE - view permanently dropped, metadata captured
+-- (paired: test 11 leaves view_base + view_dep for test 12)
+-- ========================================
+CREATE TABLE view_base (id int PRIMARY KEY, val text);
+CREATE VIEW view_dep AS SELECT * FROM view_base WHERE id > 0;
+-- RESTRICT should fail (view depends on table)
+DROP TABLE view_base;
+ERROR:  cannot drop table public.view_base because other objects depend on it
+DETAIL:  view public.view_dep depends on table public.view_base
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- CASCADE drops view, moves table
+DROP TABLE view_base CASCADE;
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'view_base';
+ orig_name | has_view_metadata 
+-----------+-------------------
+ view_base | t
+(1 row)
+
+-- New: ddl field is a runnable CREATE statement
+SELECT (metadata->'views_dropped'->0->>'ddl' LIKE 'CREATE OR REPLACE VIEW %AS%') AS view_ddl_looks_runnable
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'view_base';
+ view_ddl_looks_runnable 
+-------------------------
+ t
+(1 row)
+
+-- View should be gone
+SELECT count(*) AS views_remaining FROM pg_views
+WHERE viewname = 'view_dep' AND schemaname = 'public';
+ views_remaining 
+-----------------
+               0
+(1 row)
+
+SELECT pgtrashcan_restore('view_base') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- View NOT auto-restored
+SELECT count(*) AS views_after_restore FROM pg_views
+WHERE viewname = 'view_dep' AND schemaname = 'public';
+ views_after_restore 
+---------------------
+                   0
+(1 row)
+
+DROP TABLE view_base;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 13: CASCADE - materialized views permanently dropped
+-- ========================================
+CREATE TABLE matview_base (id int PRIMARY KEY, name text, price numeric);
+CREATE VIEW matview_regular AS SELECT * FROM matview_base WHERE price > 0;
+CREATE MATERIALIZED VIEW matview_expensive AS SELECT * FROM matview_base WHERE price > 100;
+INSERT INTO matview_base VALUES (1, 'Item 1', 50), (2, 'Item 2', 150);
+DROP TABLE matview_base CASCADE;
+-- Metadata should capture both regular and materialized views
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'matview_base';
+  orig_name   | has_view_metadata 
+--------------+-------------------
+ matview_base | t
+(1 row)
+
+-- New: every view's ddl field is a runnable CREATE statement
+SELECT bool_and(
+    (v->>'ddl' LIKE 'CREATE MATERIALIZED VIEW %') OR
+    (v->>'ddl' LIKE 'CREATE OR REPLACE VIEW %')
+) AS all_view_ddls_runnable
+FROM pgtrashcan._trash_catalog tc,
+     jsonb_array_elements(tc.metadata->'views_dropped') AS v
+WHERE tc.orig_name = 'matview_base';
+ all_view_ddls_runnable 
+------------------------
+ t
+(1 row)
+
+-- Both views gone
+SELECT count(*) AS regular_views FROM pg_views
+WHERE viewname = 'matview_regular' AND schemaname = 'public';
+ regular_views 
+---------------
+             0
+(1 row)
+
+SELECT count(*) AS mat_views FROM pg_matviews
+WHERE matviewname = 'matview_expensive' AND schemaname = 'public';
+ mat_views 
+-----------
+         0
+(1 row)
+
+SELECT pgtrashcan_restore('matview_base') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify data survived
+SELECT * FROM matview_base ORDER BY id;
+ id |  name  | price 
+----+--------+-------
+  1 | Item 1 |    50
+  2 | Item 2 |   150
+(2 rows)
+
+DROP TABLE matview_base;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 14: CASCADE - nested 3-level view chain
+-- ========================================
+CREATE TABLE chain_base (id int PRIMARY KEY, val text);
+CREATE VIEW chain_v1 AS SELECT id, val FROM chain_base;
+CREATE VIEW chain_v2 AS SELECT id FROM chain_v1;
+CREATE VIEW chain_v3 AS SELECT id FROM chain_v2;
+DROP TABLE chain_base CASCADE;
+-- Table should be in trash
+SELECT count(*) AS chain_base_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'chain_base';
+ chain_base_in_trash 
+---------------------
+                   1
+(1 row)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 15: CASCADE - multi-table view (drop one table, other survives)
+-- ========================================
+CREATE TABLE multi_base_a (id int PRIMARY KEY, a_val text);
+CREATE TABLE multi_base_b (id int PRIMARY KEY, b_val text);
+INSERT INTO multi_base_a VALUES (1, 'a_test');
+INSERT INTO multi_base_b VALUES (1, 'b_test');
+CREATE VIEW multi_table_view AS
+    SELECT a.id, a.a_val, b.b_val
+    FROM multi_base_a a JOIN multi_base_b b ON a.id = b.id;
+-- Drop ONLY table_a with CASCADE
+DROP TABLE multi_base_a CASCADE;
+-- table_b should still exist with data intact
+SELECT count(*) AS b_exists FROM pg_tables
+WHERE tablename = 'multi_base_b' AND schemaname = 'public';
+ b_exists 
+----------
+        1
+(1 row)
+
+SELECT * FROM multi_base_b;
+ id | b_val  
+----+--------
+  1 | b_test
+(1 row)
+
+-- View should be gone
+SELECT count(*) AS view_remaining FROM pg_views
+WHERE viewname = 'multi_table_view' AND schemaname = 'public';
+ view_remaining 
+----------------
+              0
+(1 row)
+
+-- Metadata captured on the dropped table
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'multi_base_a';
+  orig_name   | has_view_metadata 
+--------------+-------------------
+ multi_base_a | t
+(1 row)
+
+-- Restore table_a
+SELECT pgtrashcan_restore('multi_base_a') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+SELECT * FROM multi_base_a;
+ id | a_val  
+----+--------
+  1 | a_test
+(1 row)
+
+DROP TABLE multi_base_a, multi_base_b;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 16: CASCADE - mixed objects (SERIAL + FK child + view)
+-- ========================================
+CREATE TABLE mixed_parent (id SERIAL PRIMARY KEY, name text);
+CREATE TABLE mixed_child (id int PRIMARY KEY, parent_id int REFERENCES mixed_parent(id));
+CREATE VIEW mixed_view AS SELECT * FROM mixed_parent WHERE id > 0;
+INSERT INTO mixed_parent (name) VALUES ('Test');
+INSERT INTO mixed_child VALUES (1, 1);
+DROP TABLE mixed_parent CASCADE;
+-- Only parent table and its sequence should be in trash; child survives, view permanently dropped
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name LIKE 'mixed_%'
+ORDER BY orig_obj_type, orig_name;
+      orig_name      | orig_obj_type 
+---------------------+---------------
+ mixed_parent_pkey   | index
+ mixed_parent_id_seq | sequence
+ mixed_parent        | table
+(3 rows)
+
+-- Child table should still exist with data intact
+SELECT * FROM mixed_child;
+ id | parent_id 
+----+-----------
+  1 |         1
+(1 row)
+
+-- FK constraint should be gone from child
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'mixed_child'::regclass AND contype = 'f';
+ fk_count 
+----------
+        0
+(1 row)
+
+-- View metadata should be captured on parent
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'mixed_parent';
+  orig_name   | has_view_metadata 
+--------------+-------------------
+ mixed_parent | t
+(1 row)
+
+-- Restore parent and verify SERIAL sequence ownership is restored
+SELECT pgtrashcan_restore('mixed_parent') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+SELECT pg_get_serial_sequence('mixed_parent', 'id') IS NOT NULL AS seq_ownership_restored;
+ seq_ownership_restored 
+------------------------
+ t
+(1 row)
+
+-- Sequence should work after restore
+INSERT INTO mixed_parent (name) VALUES ('After Restore');
+SELECT id, name FROM mixed_parent ORDER BY id;
+ id |     name      
+----+---------------
+  1 | Test
+  2 | After Restore
+(2 rows)
+
+DROP TABLE mixed_child;
+DROP TABLE mixed_parent;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 17: Multi-table DROP with FK between them (RESTRICT mode)
+-- Both in same DROP statement - should succeed without CASCADE
+-- ========================================
+CREATE TABLE test_multi_fk_parent (id int PRIMARY KEY);
+CREATE TABLE test_multi_fk_child (id int, pid int REFERENCES test_multi_fk_parent(id));
+INSERT INTO test_multi_fk_parent VALUES (1), (2);
+INSERT INTO test_multi_fk_child VALUES (1, 1), (2, 2);
+-- This should NOT error even without CASCADE, because both tables are in the same DROP
+DROP TABLE test_multi_fk_parent, test_multi_fk_child;
+-- Both should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_fk_parent', 'test_multi_fk_child')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+      orig_name       | orig_obj_type 
+----------------------+---------------
+ test_multi_fk_child  | table
+ test_multi_fk_parent | table
+(2 rows)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 18: Multi-table DROP CASCADE with FK between them + external table
+-- ========================================
+CREATE TABLE test_cascade_multi_p (id int PRIMARY KEY, name text);
+CREATE TABLE test_cascade_multi_c (id int PRIMARY KEY, pid int REFERENCES test_cascade_multi_p(id));
+CREATE TABLE test_cascade_multi_ext (id int, pid int REFERENCES test_cascade_multi_p(id));
+INSERT INTO test_cascade_multi_p VALUES (1, 'parent');
+INSERT INTO test_cascade_multi_c VALUES (1, 1);
+INSERT INTO test_cascade_multi_ext VALUES (1, 1);
+-- CASCADE: parent and child in same DROP, ext_table has FK to parent but not in DROP
+DROP TABLE test_cascade_multi_p, test_cascade_multi_c CASCADE;
+-- Both parent and child should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_cascade_multi_p', 'test_cascade_multi_c', 'test_cascade_multi_ext')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+      orig_name       | orig_obj_type 
+----------------------+---------------
+ test_cascade_multi_c | table
+ test_cascade_multi_p | table
+(2 rows)
+
+-- External table should survive with FK dropped
+SELECT * FROM test_cascade_multi_ext;
+ id | pid 
+----+-----
+  1 |   1
+(1 row)
+
+-- FK on external table should be gone
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'test_cascade_multi_ext'::regclass AND contype = 'f';
+ fk_count 
+----------
+        0
+(1 row)
+
+DROP TABLE test_cascade_multi_ext;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 4: Metadata Capture
+-- ########################################
+-- ========================================
+-- TEST 19: FK metadata captured (outgoing FK on dropped table)
+-- ========================================
+CREATE TABLE fk_ref (id int PRIMARY KEY);
+CREATE TABLE fk_test (id int PRIMARY KEY, ref_id int,
+    CONSTRAINT fk_to_ref FOREIGN KEY (ref_id) REFERENCES fk_ref(id));
+INSERT INTO fk_ref VALUES (1);
+INSERT INTO fk_test VALUES (1, 1);
+-- Drop child (has outgoing FK)
+DROP TABLE fk_test;
+-- FK metadata stored
+SELECT orig_name, metadata ? 'foreign_keys_dropped' AS has_fk_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'fk_test';
+ orig_name | has_fk_metadata 
+-----------+-----------------
+ fk_test   | t
+(1 row)
+
+-- New FK metadata fields: columns list, ddl, human-readable on_delete
+SELECT
+    (metadata->'foreign_keys_dropped'->0->>'ddl' LIKE 'ALTER TABLE %ADD CONSTRAINT %FOREIGN KEY%') AS ddl_looks_runnable,
+    jsonb_array_length(metadata->'foreign_keys_dropped'->0->'columns') AS col_count,
+    metadata->'foreign_keys_dropped'->0->>'on_delete' AS on_delete_action
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'fk_test';
+ ddl_looks_runnable | col_count | on_delete_action 
+--------------------+-----------+------------------
+ t                  |         1 | NO ACTION
+(1 row)
+
+-- Restore and verify FK is gone
+SELECT pgtrashcan_restore('fk_test') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+SELECT conname FROM pg_constraint
+WHERE conrelid = 'fk_test'::regclass AND contype = 'f';
+ conname 
+---------
+(0 rows)
+
+DROP TABLE fk_test, fk_ref;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 20: Trigger metadata captured
+-- ========================================
+CREATE TABLE trig_test (id int PRIMARY KEY, val text, updated_at timestamptz);
+CREATE OR REPLACE FUNCTION test_update_ts() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+CREATE TRIGGER trg_update BEFORE UPDATE ON trig_test
+    FOR EACH ROW EXECUTE FUNCTION test_update_ts();
+DROP TABLE trig_test;
+SELECT orig_name, metadata ? 'triggers_dropped' AS has_trigger_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'trig_test';
+ orig_name | has_trigger_metadata 
+-----------+----------------------
+ trig_test | t
+(1 row)
+
+SELECT pgtrashcan_restore('trig_test') IS NOT NULL AS restored;
+WARNING:  1 trigger function(s) may no longer exist: public.test_update_ts()
+These triggers will fail when fired. Drop or recreate them manually.
+ restored 
+----------
+ t
+(1 row)
+
+-- Trigger should be gone
+SELECT tgname FROM pg_trigger
+WHERE tgrelid = 'trig_test'::regclass AND NOT tgisinternal;
+   tgname   
+------------
+ trg_update
+(1 row)
+
+DROP TABLE trig_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 21: Rule metadata captured
+-- ========================================
+CREATE TABLE rule_test (id int PRIMARY KEY, val text);
+CREATE RULE notify_rule AS ON INSERT TO rule_test DO ALSO NOTIFY rule_channel;
+DROP TABLE rule_test;
+SELECT orig_name, metadata ? 'rules_dropped' AS has_rule_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'rule_test';
+ orig_name | has_rule_metadata 
+-----------+-------------------
+ rule_test | t
+(1 row)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 22: RLS policy metadata captured
+-- ========================================
+CREATE TABLE policy_test (id int PRIMARY KEY, user_id int);
+ALTER TABLE policy_test ENABLE ROW LEVEL SECURITY;
+CREATE POLICY user_pol ON policy_test FOR SELECT USING (user_id = 1);
+DROP TABLE policy_test;
+SELECT orig_name, metadata ? 'policies_dropped' AS has_policy_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'policy_test';
+  orig_name  | has_policy_metadata 
+-------------+---------------------
+ policy_test | t
+(1 row)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 23: Complete scenario - all 5 metadata types on one table
+-- ========================================
+CREATE TABLE ref_for_complete (id int PRIMARY KEY);
+INSERT INTO ref_for_complete VALUES (1);
+CREATE TABLE complete_test (id SERIAL PRIMARY KEY, ref_id int, val text, updated_at timestamptz);
+ALTER TABLE complete_test ADD CONSTRAINT fk_complete
+    FOREIGN KEY (ref_id) REFERENCES ref_for_complete(id);
+CREATE OR REPLACE FUNCTION complete_update_ts() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+CREATE TRIGGER trg_complete BEFORE UPDATE ON complete_test
+    FOR EACH ROW EXECUTE FUNCTION complete_update_ts();
+CREATE VIEW complete_view AS SELECT * FROM complete_test WHERE val IS NOT NULL;
+CREATE RULE complete_rule AS ON DELETE TO complete_test
+    WHERE val = 'protected' DO INSTEAD NOTHING;
+ALTER TABLE complete_test ENABLE ROW LEVEL SECURITY;
+CREATE POLICY complete_pol ON complete_test FOR SELECT USING (ref_id = 1);
+INSERT INTO complete_test (ref_id, val) VALUES (1, 'test');
+DROP TABLE complete_test CASCADE;
+-- All 5 metadata keys should exist
+SELECT orig_name,
+    metadata ? 'foreign_keys_dropped' AS has_fk,
+    metadata ? 'triggers_dropped' AS has_triggers,
+    metadata ? 'views_dropped' AS has_views,
+    metadata ? 'rules_dropped' AS has_rules,
+    metadata ? 'policies_dropped' AS has_policies
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'complete_test';
+   orig_name   | has_fk | has_triggers | has_views | has_rules | has_policies 
+---------------+--------+--------------+-----------+-----------+--------------
+ complete_test | t      | t            | t         | t         | t
+(1 row)
+
+-- ========================================
+-- TEST 24: Post-restore verification - all 5 types absent from system catalogs
+-- ========================================
+SELECT pgtrashcan_restore('complete_test') IS NOT NULL AS restored;
+WARNING:  1 trigger function(s) may no longer exist: public.complete_update_ts()
+These triggers will fail when fired. Drop or recreate them manually.
+ restored 
+----------
+ t
+(1 row)
+
+-- FK absent
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'complete_test'::regclass AND contype = 'f';
+ fk_count 
+----------
+        0
+(1 row)
+
+-- Trigger absent
+SELECT count(*) AS trigger_count FROM pg_trigger
+WHERE tgrelid = 'complete_test'::regclass AND NOT tgisinternal;
+ trigger_count 
+---------------
+             1
+(1 row)
+
+-- View absent
+SELECT count(*) AS view_count FROM pg_views
+WHERE viewname = 'complete_view' AND schemaname = 'public';
+ view_count 
+------------
+          0
+(1 row)
+
+-- Rule absent (excluding _RETURN)
+SELECT count(*) AS rule_count FROM pg_rewrite r
+JOIN pg_class c ON r.ev_class = c.oid
+WHERE c.relname = 'complete_test'
+AND c.relnamespace = 'public'::regnamespace
+AND r.rulename != '_RETURN';
+ rule_count 
+------------
+          0
+(1 row)
+
+-- Policy absent
+SELECT count(*) AS policy_count FROM pg_policy
+WHERE polrelid = 'complete_test'::regclass;
+ policy_count 
+--------------
+            0
+(1 row)
+
+DROP TABLE complete_test, ref_for_complete;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 5: Restore Advanced
+-- ########################################
+-- ========================================
+-- TEST 25: Multiple drops of same table name (versioning)
+-- ========================================
+CREATE TABLE test_basic (id int, val text);
+INSERT INTO test_basic VALUES (1, 'hello');
+DROP TABLE test_basic;
+CREATE TABLE test_basic (id int, val text, extra text);
+INSERT INTO test_basic VALUES (2, 'world', 'v2');
+DROP TABLE test_basic;
+-- Both versions in trash
+SELECT count(*) AS versions FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_basic' AND orig_obj_type = 'table';
+ versions 
+----------
+        2
+(1 row)
+
+-- Restore most recent (has extra column)
+SELECT pgtrashcan_restore('test_basic') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+SELECT * FROM test_basic;
+ id |  val  | extra 
+----+-------+-------
+  2 | world | v2
+(1 row)
+
+DROP TABLE test_basic;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 26: Conflict detection on restore
+-- ========================================
+CREATE TABLE conflict_t (id int, val text);
+INSERT INTO conflict_t VALUES (1, 'original');
+DROP TABLE conflict_t;
+-- Create new table with same name
+CREATE TABLE conflict_t (id int, val text);
+-- Restore should fail - name conflict (use DO block to get deterministic output)
+DO $$
+BEGIN
+    PERFORM pgtrashcan_restore('conflict_t');
+    RAISE WARNING 'UNEXPECTED: restore should have failed but succeeded';
+EXCEPTION
+    WHEN OTHERS THEN
+        RAISE WARNING 'OK: conflict correctly detected';
+END $$;
+WARNING:  OK: conflict correctly detected
+-- Clean up
+DROP TABLE conflict_t;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 27: Rename in trash for conflict resolution
+-- ========================================
+CREATE TABLE rename_test (id int, val text);
+INSERT INTO rename_test VALUES (1, 'original');
+DROP TABLE rename_test;
+CREATE TABLE rename_test (id int, val text);
+INSERT INTO rename_test VALUES (2, 'new');
+-- Rename in trash
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'rename_test' LIMIT 1;
+    PERFORM pgtrashcan_rename_in_trash(v_tname, 'rename_test_old');
+END $$;
+-- Now restore should work
+SELECT pgtrashcan_restore('rename_test_old') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+SELECT * FROM rename_test ORDER BY id;
+ id | val 
+----+-----
+  2 | new
+(1 row)
+
+SELECT * FROM rename_test_old ORDER BY id;
+ id |   val    
+----+----------
+  1 | original
+(1 row)
+
+DROP TABLE rename_test, rename_test_old;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 28: Restore with explicit schema hint
+-- ========================================
+CREATE TABLE schema_restore_test (id int, val text);
+INSERT INTO schema_restore_test VALUES (1, 'data');
+DROP TABLE schema_restore_test;
+-- Restore using 2-arg form with explicit schema
+SELECT pgtrashcan_restore('schema_restore_test', 'public') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify data
+SELECT * FROM schema_restore_test;
+ id | val  
+----+------
+  1 | data
+(1 row)
+
+DROP TABLE schema_restore_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 29: Restore with OID hint (restoring older version)
+-- ========================================
+CREATE TABLE versioned_test (id int);
+INSERT INTO versioned_test VALUES (1);
+DROP TABLE versioned_test;  -- version 1
+CREATE TABLE versioned_test (id int, extra text);
+INSERT INTO versioned_test VALUES (2, 'v2');
+DROP TABLE versioned_test;  -- version 2
+-- Both versions in trash
+SELECT count(*) AS versions FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'versioned_test' AND orig_obj_type = 'table';
+ versions 
+----------
+        2
+(1 row)
+
+-- Rename older version so both can be restored without conflict
+DO $$
+DECLARE v_old_oid oid;
+BEGIN
+    SELECT orig_oid INTO v_old_oid
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'versioned_test' AND orig_obj_type = 'table'
+    ORDER BY drop_time ASC
+    LIMIT 1;
+
+    PERFORM pgtrashcan_rename_in_trash(
+        (SELECT trash_name FROM pgtrashcan._trash_catalog WHERE orig_oid = v_old_oid),
+        'versioned_test_old'
+    );
+END $$;
+-- Restore most recent (v2 with extra column)
+SELECT pgtrashcan_restore('versioned_test') IS NOT NULL AS restored_v2;
+ restored_v2 
+-------------
+ t
+(1 row)
+
+SELECT * FROM versioned_test;
+ id | extra 
+----+-------
+  2 | v2
+(1 row)
+
+-- Restore old version (v1, single column)
+SELECT pgtrashcan_restore('versioned_test_old') IS NOT NULL AS restored_v1;
+ restored_v1 
+-------------
+ t
+(1 row)
+
+SELECT * FROM versioned_test_old;
+ id 
+----
+  1
+(1 row)
+
+DROP TABLE versioned_test, versioned_test_old;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 30: Restore from non-public schema
+-- ========================================
+CREATE SCHEMA myapp;
+CREATE TABLE myapp.myapp_table (id int PRIMARY KEY, val text);
+INSERT INTO myapp.myapp_table VALUES (1, 'from myapp');
+DROP TABLE myapp.myapp_table;
+-- Should be in trash with orig_schema = myapp
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'myapp_table';
+  orig_name  | orig_schema | orig_obj_type 
+-------------+-------------+---------------
+ myapp_table | myapp       | table
+(1 row)
+
+-- Restore back to original schema using schema hint
+SELECT pgtrashcan_restore('myapp_table', 'myapp') IS NOT NULL AS restored;
+ restored 
+----------
+ t
+(1 row)
+
+-- Verify back in myapp schema with data
+SELECT * FROM myapp.myapp_table;
+ id |    val     
+----+------------
+  1 | from myapp
+(1 row)
+
+DROP TABLE myapp.myapp_table;
+DROP SCHEMA myapp;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 31: Multiple schemas - same table name disambiguation
+-- ========================================
+CREATE SCHEMA schema_a;
+CREATE SCHEMA schema_b;
+CREATE TABLE schema_a.shared_name (id int);
+CREATE TABLE schema_b.shared_name (id int);
+INSERT INTO schema_a.shared_name VALUES (1);
+INSERT INTO schema_b.shared_name VALUES (2);
+DROP TABLE schema_a.shared_name;
+DROP TABLE schema_b.shared_name;
+-- Both in trash
+SELECT orig_name, orig_schema FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'shared_name' AND orig_obj_type = 'table'
+ORDER BY orig_schema;
+  orig_name  | orig_schema 
+-------------+-------------
+ shared_name | schema_a
+ shared_name | schema_b
+(2 rows)
+
+-- Restore from specific schema
+SELECT pgtrashcan_restore('shared_name', 'schema_a') IS NOT NULL AS restored_a;
+ restored_a 
+------------
+ t
+(1 row)
+
+SELECT * FROM schema_a.shared_name;
+ id 
+----
+  1
+(1 row)
+
+SELECT pgtrashcan_restore('shared_name', 'schema_b') IS NOT NULL AS restored_b;
+ restored_b 
+------------
+ t
+(1 row)
+
+SELECT * FROM schema_b.shared_name;
+ id 
+----
+  2
+(1 row)
+
+DROP TABLE schema_a.shared_name, schema_b.shared_name;
+DROP SCHEMA schema_a;
+DROP SCHEMA schema_b;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 6: Purge Operations
+-- ########################################
+-- ========================================
+-- TEST 32: Purge by name
+-- ========================================
+CREATE TABLE purge_test (id int);
+DROP TABLE purge_test;
+SELECT count(*) AS in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_test';
+ in_trash 
+----------
+        1
+(1 row)
+
+SELECT pgtrashcan_purge('purge_test') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+SELECT count(*) AS after_purge FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_test';
+ after_purge 
+-------------
+           0
+(1 row)
+
+-- ========================================
+-- TEST 33: Purge by OID
+-- ========================================
+CREATE TABLE purge_oid_test (id int, val text);
+INSERT INTO purge_oid_test VALUES (1, 'test');
+DROP TABLE purge_oid_test;
+-- Get OID and purge by it
+DO $$
+DECLARE v_oid oid;
+BEGIN
+    SELECT orig_oid INTO v_oid FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'purge_oid_test' AND orig_obj_type = 'table';
+    PERFORM pgtrashcan_purge_by_oid(v_oid);
+END $$;
+-- Should be gone
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_oid_test';
+ remaining 
+-----------
+         0
+(1 row)
+
+-- ========================================
+-- TEST 34: Purge with schema hint
+-- ========================================
+CREATE SCHEMA purge_ns;
+CREATE TABLE purge_ns.purge_schema_test (id int);
+DROP TABLE purge_ns.purge_schema_test;
+-- Purge using 2-arg form with explicit schema
+SELECT pgtrashcan_purge('purge_schema_test', 'purge_ns') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_schema_test';
+ remaining 
+-----------
+         0
+(1 row)
+
+DROP SCHEMA purge_ns;
+-- ========================================
+-- TEST 35: Purge all
+-- ========================================
+CREATE TABLE purge_all_t1 (id int);
+CREATE TABLE purge_all_t2 (id int);
+DROP TABLE purge_all_t1;
+DROP TABLE purge_all_t2;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- Verify empty
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog;
+ remaining 
+-----------
+         0
+(1 row)
+
+-- ########################################
+-- SECTION 7: DROP TABLE Variants
+-- ########################################
+-- ========================================
+-- TEST 36: DROP TABLE in non-public schema
+-- ========================================
+CREATE SCHEMA testns;
+CREATE TABLE testns.ns_table (id int, val text);
+CREATE INDEX ns_idx ON testns.ns_table(val);
+INSERT INTO testns.ns_table VALUES (1, 'ns_data');
+DROP TABLE testns.ns_table;
+-- Should be in trash with orig_schema = testns
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('ns_table', 'ns_idx')
+ORDER BY orig_obj_type, orig_name;
+ orig_name | orig_schema | orig_obj_type 
+-----------+-------------+---------------
+ ns_idx    | testns      | index
+ ns_table  | testns      | table
+(2 rows)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+DROP SCHEMA testns;
+-- ========================================
+-- TEST 37: DROP TABLE IF EXISTS - single nonexistent table
+-- ========================================
+-- Should emit a NOTICE and do nothing (no crash, no error)
+DROP TABLE IF EXISTS this_table_does_not_exist;
+-- Verify trash catalog unchanged
+SELECT count(*) AS count_after_if_exists FROM pgtrashcan._trash_catalog;
+ count_after_if_exists 
+-----------------------
+                     0
+(1 row)
+
+-- ========================================
+-- TEST 38: DROP TABLE IF EXISTS - single existing table
+-- ========================================
+CREATE TABLE test_if_exists_single (id int, val text);
+INSERT INTO test_if_exists_single VALUES (1, 'exists');
+DROP TABLE IF EXISTS test_if_exists_single;
+-- Verify table was moved to trash (not permanently dropped)
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_if_exists_single';
+       orig_name       | orig_schema | orig_obj_type 
+-----------------------+-------------+---------------
+ test_if_exists_single | public      | table
+(1 row)
+
+-- Clean up
+SELECT pgtrashcan_purge('test_if_exists_single') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 39: DROP TABLE IF EXISTS - multi-table, first doesn't exist
+-- ========================================
+CREATE TABLE test_multi_real (id int, val text);
+INSERT INTO test_multi_real VALUES (1, 'should be in trash');
+DROP TABLE IF EXISTS nonexistent_table_xyz, test_multi_real;
+-- Verify real table was moved to trash (NOT permanently dropped)
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_multi_real';
+    orig_name    | orig_schema | orig_obj_type 
+-----------------+-------------+---------------
+ test_multi_real | public      | table
+(1 row)
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_real') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 40: DROP TABLE IF EXISTS - multi-table, second doesn't exist
+-- ========================================
+CREATE TABLE test_multi_real2 (id int, val text);
+INSERT INTO test_multi_real2 VALUES (2, 'also should be in trash');
+DROP TABLE IF EXISTS test_multi_real2, another_nonexistent_xyz;
+-- Verify real table was moved to trash
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_multi_real2';
+    orig_name     | orig_schema | orig_obj_type 
+------------------+-------------+---------------
+ test_multi_real2 | public      | table
+(1 row)
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_real2') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 41: DROP TABLE IF EXISTS - multi-table, all exist
+-- ========================================
+CREATE TABLE test_multi_all1 (id int);
+CREATE TABLE test_multi_all2 (id int);
+CREATE TABLE test_multi_all3 (id int);
+DROP TABLE IF EXISTS test_multi_all1, test_multi_all2, test_multi_all3;
+-- Verify all three were moved to trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_all1', 'test_multi_all2', 'test_multi_all3')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+    orig_name    | orig_obj_type 
+-----------------+---------------
+ test_multi_all1 | table
+ test_multi_all2 | table
+ test_multi_all3 | table
+(3 rows)
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_all1') IS NOT NULL AS purged1;
+ purged1 
+---------
+ t
+(1 row)
+
+SELECT pgtrashcan_purge('test_multi_all2') IS NOT NULL AS purged2;
+ purged2 
+---------
+ t
+(1 row)
+
+SELECT pgtrashcan_purge('test_multi_all3') IS NOT NULL AS purged3;
+ purged3 
+---------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 42: DROP TABLE IF EXISTS - multi-table, none exist
+-- ========================================
+DROP TABLE IF EXISTS ghost1, ghost2, ghost3;
+-- Verify trash catalog unchanged
+SELECT count(*) AS count_after_all_ghosts FROM pgtrashcan._trash_catalog;
+ count_after_all_ghosts 
+------------------------
+                      0
+(1 row)
+
+-- ========================================
+-- TEST 43: DROP TABLE IF EXISTS - multi-table, middle missing
+-- ========================================
+CREATE TABLE test_multi_a (id int);
+CREATE TABLE test_multi_c (id int);
+DROP TABLE IF EXISTS test_multi_a, nonexistent_middle_xyz, test_multi_c;
+-- Verify both existing tables were moved to trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_a', 'test_multi_c')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+  orig_name   | orig_obj_type 
+--------------+---------------
+ test_multi_a | table
+ test_multi_c | table
+(2 rows)
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_a') IS NOT NULL AS purged_a;
+ purged_a 
+----------
+ t
+(1 row)
+
+SELECT pgtrashcan_purge('test_multi_c') IS NOT NULL AS purged_c;
+ purged_c 
+----------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 44: DROP TABLE IF EXISTS with CASCADE
+-- ========================================
+CREATE TABLE if_exists_cascade_base (id int PRIMARY KEY, val text);
+CREATE VIEW if_exists_cascade_view AS SELECT * FROM if_exists_cascade_base WHERE id > 0;
+INSERT INTO if_exists_cascade_base VALUES (1, 'test');
+-- IF EXISTS + CASCADE: base moves to trash, view permanently dropped
+DROP TABLE IF EXISTS if_exists_cascade_base CASCADE;
+-- Base should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'if_exists_cascade_base';
+       orig_name        | orig_obj_type 
+------------------------+---------------
+ if_exists_cascade_base | table
+(1 row)
+
+-- View should be gone
+SELECT count(*) AS view_count FROM pg_views
+WHERE viewname = 'if_exists_cascade_view' AND schemaname = 'public';
+ view_count 
+------------
+          0
+(1 row)
+
+-- View definition should be in metadata
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'if_exists_cascade_base';
+       orig_name        | has_view_metadata 
+------------------------+-------------------
+ if_exists_cascade_base | t
+(1 row)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 45: DROP TABLE multi-table without IF EXISTS
+-- ========================================
+CREATE TABLE test_multi_no_ie_1 (id int);
+CREATE TABLE test_multi_no_ie_2 (id int);
+DROP TABLE test_multi_no_ie_1, test_multi_no_ie_2;
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_no_ie_1', 'test_multi_no_ie_2')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+     orig_name      | orig_obj_type 
+--------------------+---------------
+ test_multi_no_ie_1 | table
+ test_multi_no_ie_2 | table
+(2 rows)
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_no_ie_1') IS NOT NULL AS purged1;
+ purged1 
+---------
+ t
+(1 row)
+
+SELECT pgtrashcan_purge('test_multi_no_ie_2') IS NOT NULL AS purged2;
+ purged2 
+---------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 8: Protection Mechanisms
+-- ########################################
+-- ========================================
+-- TEST 46: pgtrashcan schema protection (DML + DDL + schema drop)
+-- ========================================
+-- Cannot create tables in pgtrashcan schema
+CREATE TABLE pgtrashcan.user_junk (id int);
+ERROR:  cannot create objects in trash schema
+HINT:  pgtrashcan schema is reserved for system use only
+-- Cannot insert into catalog directly (blocked by trigger)
+INSERT INTO pgtrashcan._trash_catalog (trash_name, orig_name, orig_schema, orig_oid, orig_obj_type)
+VALUES ('fake', 'fake', 'public', 0, 'table');
+ERROR:  direct modification of trash catalog table is not allowed
+HINT:  Use pgtrashcan functions (restore, purge, rename) to manage trash
+-- To test UPDATE/DELETE blocking, we need a row in the catalog first.
+-- Drop a table to get a real row, then try to modify it directly.
+CREATE TABLE dml_protect_test (id int);
+DROP TABLE dml_protect_test;
+-- Cannot update catalog directly (blocked by trigger)
+UPDATE pgtrashcan._trash_catalog SET orig_name = 'hacked' WHERE orig_name = 'dml_protect_test';
+ERROR:  direct modification of trash catalog table is not allowed
+HINT:  Use pgtrashcan functions (restore, purge, rename) to manage trash
+-- Cannot delete from catalog directly (blocked by trigger)
+DELETE FROM pgtrashcan._trash_catalog WHERE orig_name = 'dml_protect_test';
+ERROR:  direct modification of trash catalog table is not allowed
+HINT:  Use pgtrashcan functions (restore, purge, rename) to manage trash
+-- Cannot truncate catalog (blocked by ProcessUtility hook)
+TRUNCATE pgtrashcan._trash_catalog;
+ERROR:  direct modification of trash catalog table is not allowed
+HINT:  Use pgtrashcan functions (restore, purge, rename) to manage trash
+-- Clean up the test row via purge
+SELECT pgtrashcan_purge('dml_protect_test') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- Cannot drop pgtrashcan schema while extension is installed
+DROP SCHEMA pgtrashcan CASCADE;
+ERROR:  cannot drop trash schema "pgtrashcan"
+DETAIL:  The "pgtrashcan" schema is managed by pgtrashcan extension
+HINT:  Use pgtrashcan_purge_all() to empty trash, or DROP EXTENSION pgtrashcan CASCADE to uninstall
+-- ########################################
+-- SECTION 9: Listing Functions
+-- ########################################
+-- ========================================
+-- TEST 47: pgtrashcan_list_dropped() - basic listing
+-- ========================================
+CREATE TABLE list_test_a (id int, val text);
+CREATE TABLE list_test_b (id int);
+INSERT INTO list_test_a VALUES (1, 'hello');
+DROP TABLE list_test_a;
+DROP TABLE list_test_b;
+-- Both should appear in list_dropped
+SELECT orig_name, orig_schema, obj_type
+FROM pgtrashcan_list_dropped()
+ORDER BY orig_name;
+  orig_name  | orig_schema | obj_type 
+-------------+-------------+----------
+ list_test_a | public      | table
+ list_test_b | public      | table
+(2 rows)
+
+-- is_dependent should be false for both
+SELECT orig_name, is_dependent
+FROM pgtrashcan_list_dropped()
+ORDER BY orig_name;
+  orig_name  | is_dependent 
+-------------+--------------
+ list_test_a | f
+ list_test_b | f
+(2 rows)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 48: pgtrashcan_list_dropped_grouped() - standalone tables
+-- ========================================
+CREATE TABLE grouped_t1 (id int);
+CREATE TABLE grouped_t2 (id int);
+DROP TABLE grouped_t1;
+DROP TABLE grouped_t2;
+-- Both should show as standalone (separate drops, no CASCADE relationship)
+SELECT drop_group, relationship, orig_name
+FROM pgtrashcan_list_dropped_grouped()
+ORDER BY orig_name;
+ drop_group | relationship | orig_name  
+------------+--------------+------------
+          1 | standalone   | grouped_t1
+          2 | standalone   | grouped_t2
+(2 rows)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 49: pgtrashcan_list_dropped_grouped() - CASCADE parent with FK child
+-- ========================================
+CREATE TABLE grouped_parent (id int PRIMARY KEY);
+CREATE TABLE grouped_child (id int, pid int REFERENCES grouped_parent(id));
+INSERT INTO grouped_parent VALUES (1);
+INSERT INTO grouped_child VALUES (1, 1);
+DROP TABLE grouped_parent CASCADE;
+-- Only parent in trash, child survives - parent should show as standalone
+SELECT drop_group, relationship, orig_name
+FROM pgtrashcan_list_dropped_grouped()
+ORDER BY orig_name;
+ drop_group | relationship |   orig_name    
+------------+--------------+----------------
+          1 | standalone   | grouped_parent
+(1 row)
+
+DROP TABLE grouped_child;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 50: pgtrashcan_list_dropped_grouped() - multi-table DROP with FK
+-- Both tables in same DROP statement, no cascade_parent_oid
+-- ========================================
+CREATE TABLE grouped_mp (id int PRIMARY KEY);
+CREATE TABLE grouped_mc (id int, pid int REFERENCES grouped_mp(id));
+INSERT INTO grouped_mp VALUES (1);
+INSERT INTO grouped_mc VALUES (1, 1);
+DROP TABLE grouped_mp, grouped_mc;
+-- Both in trash as standalone (same DROP statement, no CASCADE relationship)
+SELECT drop_group, relationship, orig_name
+FROM pgtrashcan_list_dropped_grouped()
+ORDER BY orig_name;
+ drop_group | relationship | orig_name  
+------------+--------------+------------
+          2 | standalone   | grouped_mc
+          1 | standalone   | grouped_mp
+(2 rows)
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 10: Protection: ALTER/RENAME on pgtrashcan Objects
+-- ########################################
+-- ========================================
+-- TEST 52: Block direct SET SCHEMA on trash object
+-- ========================================
+CREATE TABLE alter_protect_test (id int, val text);
+INSERT INTO alter_protect_test VALUES (1, 'data');
+DROP TABLE alter_protect_test;
+-- Attempt direct SET SCHEMA - should be blocked (insufficient_privilege)
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'alter_protect_test' LIMIT 1;
+    EXECUTE format('ALTER TABLE pgtrashcan.%I SET SCHEMA public', v_tname);
+    RAISE WARNING 'FAIL: SET SCHEMA on trash object was NOT blocked';
+EXCEPTION WHEN insufficient_privilege THEN
+    RAISE WARNING 'PASS: SET SCHEMA correctly blocked: %', SQLERRM;
+END $$;
+WARNING:  PASS: SET SCHEMA correctly blocked: cannot move trash objects directly
+-- Table must NOT have leaked to public (the definitive protection check)
+SELECT count(*) AS leaked_to_public FROM pg_tables
+WHERE schemaname = 'public' AND tablename = 'alter_protect_test';
+ leaked_to_public 
+------------------
+                0
+(1 row)
+
+-- Catalog entry must still exist
+SELECT count(*) AS still_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'alter_protect_test';
+ still_in_trash 
+----------------
+              1
+(1 row)
+
+SELECT pgtrashcan_purge('alter_protect_test') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 53: Block direct RENAME on trash object
+-- ========================================
+CREATE TABLE rename_protect_test (id int);
+DROP TABLE rename_protect_test;
+-- Attempt direct RENAME - should be blocked (insufficient_privilege)
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'rename_protect_test' LIMIT 1;
+    EXECUTE format('ALTER TABLE pgtrashcan.%I RENAME TO something_else', v_tname);
+    RAISE WARNING 'FAIL: RENAME on trash object was NOT blocked';
+EXCEPTION WHEN insufficient_privilege THEN
+    RAISE WARNING 'PASS: RENAME correctly blocked: %', SQLERRM;
+END $$;
+WARNING:  PASS: RENAME correctly blocked: cannot rename trash objects directly
+-- The rename target must NOT exist in pgtrashcan (protection definitive check)
+SELECT count(*) AS rename_leaked FROM pg_tables
+WHERE schemaname = 'pgtrashcan' AND tablename = 'something_else';
+ rename_leaked 
+---------------
+             0
+(1 row)
+
+-- Catalog entry must still exist
+SELECT count(*) AS still_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'rename_protect_test';
+ still_in_trash 
+----------------
+              1
+(1 row)
+
+SELECT pgtrashcan_purge('rename_protect_test') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ========================================
+-- TEST 54: pgtrashcan_move_to_schema() - simple table
+-- ========================================
+CREATE SCHEMA move_target_schema;
+CREATE TABLE move_to_schema_test (id int, val text);
+INSERT INTO move_to_schema_test VALUES (1, 'hello'), (2, 'world');
+DROP TABLE move_to_schema_test;
+-- Move to a different schema (not original)
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'move_to_schema_test' LIMIT 1;
+    PERFORM pgtrashcan_move_to_schema(v_tname, 'move_target_schema');
+END $$;
+-- Table should now be in move_target_schema with original name
+SELECT count(*) AS row_count FROM move_target_schema.move_to_schema_test;
+ row_count 
+-----------
+         2
+(1 row)
+
+-- Catalog entry should be gone
+SELECT count(*) AS catalog_count FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'move_to_schema_test';
+ catalog_count 
+---------------
+             0
+(1 row)
+
+-- Clean up
+DROP TABLE move_target_schema.move_to_schema_test;
+SELECT pgtrashcan_purge('move_to_schema_test') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+DROP SCHEMA move_target_schema;
+-- ========================================
+-- TEST 55: pgtrashcan_move_to_schema() - table with index and sequence
+-- ========================================
+CREATE SCHEMA move_archive_schema;
+CREATE TABLE move_with_deps_test (id SERIAL PRIMARY KEY, name text);
+CREATE INDEX idx_move_with_deps ON move_with_deps_test(name);
+INSERT INTO move_with_deps_test (name) VALUES ('alice'), ('bob');
+DROP TABLE move_with_deps_test;
+-- Move table (with its index and sequence) to archive schema
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'move_with_deps_test' AND orig_obj_type = 'table' LIMIT 1;
+    PERFORM pgtrashcan_move_to_schema(v_tname, 'move_archive_schema');
+END $$;
+-- Table should be in archive schema with original name and data intact
+SELECT count(*) AS row_count FROM move_archive_schema.move_with_deps_test;
+ row_count 
+-----------
+         2
+(1 row)
+
+-- Catalog should have no entries for this table or its dependents
+SELECT count(*) AS catalog_count FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'move_with_deps_test'
+   OR orig_name IN ('idx_move_with_deps', 'move_with_deps_test_id_seq');
+ catalog_count 
+---------------
+             0
+(1 row)
+
+-- Index should exist in archive schema
+SELECT count(*) AS idx_count FROM pg_indexes
+WHERE schemaname = 'move_archive_schema' AND tablename = 'move_with_deps_test';
+ idx_count 
+-----------
+         2
+(1 row)
+
+-- Clean up
+DROP TABLE move_archive_schema.move_with_deps_test;
+SELECT pgtrashcan_purge('move_with_deps_test') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+DROP SCHEMA move_archive_schema;
+-- ########################################
+-- SECTION 11: Temp Table Pass-Through
+-- ########################################
+-- ========================================
+-- TEST 56: DROP on temp table passes through (not trashed)
+-- ========================================
+CREATE TEMP TABLE temp_drop_test (id int, val text);
+INSERT INTO temp_drop_test VALUES (1, 'ephemeral');
+-- Should emit NOTICE and permanently drop (not moved to trash)
+DROP TABLE temp_drop_test;
+-- Must NOT appear in trash catalog
+SELECT count(*) AS temp_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'temp_drop_test';
+ temp_in_trash 
+---------------
+             0
+(1 row)
+
+-- Must be gone entirely
+SELECT count(*) AS temp_exists FROM pg_tables
+WHERE tablename = 'temp_drop_test';
+ temp_exists 
+-------------
+           0
+(1 row)
+
+-- ========================================
+-- TEST 57: Mixed DROP - regular + temp table
+-- ========================================
+CREATE TABLE regular_for_mix (id int);
+CREATE TEMP TABLE temp_for_mix (id int);
+INSERT INTO regular_for_mix VALUES (1);
+INSERT INTO temp_for_mix VALUES (2);
+-- Mixed drop: regular goes to trash, temp is permanently dropped
+DROP TABLE regular_for_mix, temp_for_mix;
+-- Regular table should be in trash
+SELECT count(*) AS regular_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'regular_for_mix';
+ regular_in_trash 
+------------------
+                1
+(1 row)
+
+-- Temp table must NOT be in trash
+SELECT count(*) AS temp_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'temp_for_mix';
+ temp_in_trash 
+---------------
+             0
+(1 row)
+
+-- Temp table must be gone entirely
+SELECT count(*) AS temp_exists FROM pg_tables
+WHERE tablename = 'temp_for_mix';
+ temp_exists 
+-------------
+           0
+(1 row)
+
+SELECT pgtrashcan_purge('regular_for_mix') IS NOT NULL AS purged;
+ purged 
+--------
+ t
+(1 row)
+
+-- ########################################
+-- SECTION 12: GUC pgtrashcan.enabled (session toggle)
+-- ########################################
+-- ========================================
+-- TEST 58a: GUC off -> DROP TABLE permanently drops (no pgtrashcan entry)
+-- ========================================
+CREATE TABLE guc_test (id int);
+SET pgtrashcan.enabled = false;
+DROP TABLE guc_test;
+SELECT count(*) AS trash_entries
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'guc_test';
+ trash_entries 
+---------------
+             0
+(1 row)
+
+SELECT count(*) AS still_in_pg_class
+FROM pg_class
+WHERE relname = 'guc_test' AND relnamespace = 'public'::regnamespace;
+ still_in_pg_class 
+-------------------
+                 0
+(1 row)
+
+RESET pgtrashcan.enabled;
+-- ========================================
+-- TEST 58b: GUC off -> permanent purge from pgtrashcan still works
+-- ========================================
+CREATE TABLE guc_keep (id int);
+DROP TABLE guc_keep;                                         -- normal: goes to pgtrashcan
+SELECT count(*) AS before_purge
+FROM pgtrashcan._trash_catalog WHERE orig_name = 'guc_keep';  -- 1
+ before_purge 
+--------------
+            1
+(1 row)
+
+SET pgtrashcan.enabled = false;
+SELECT pgtrashcan_purge('guc_keep') IS NOT NULL AS purged;   -- still works
+ purged 
+--------
+ t
+(1 row)
+
+SELECT count(*) AS after_purge
+FROM pgtrashcan._trash_catalog WHERE orig_name = 'guc_keep';  -- 0
+ after_purge 
+-------------
+           0
+(1 row)
+
+RESET pgtrashcan.enabled;
+-- ========================================
+-- TEST 58c: GUC off -> pgtrashcan protections still active
+-- ========================================
+SET pgtrashcan.enabled = false;
+DO $$ BEGIN
+    BEGIN
+        DROP TABLE pgtrashcan._trash_catalog;
+        RAISE WARNING 'pgtrashcan: catalog drop unexpectedly allowed';
+    EXCEPTION WHEN insufficient_privilege THEN
+        RAISE WARNING 'pgtrashcan: catalog drop blocked even when GUC is false';
+    END;
+END $$;
+WARNING:  pgtrashcan: catalog drop blocked even when GUC is false
+RESET pgtrashcan.enabled;
+-- ########################################
+-- SECTION 13: Final State
+-- ########################################
+-- ========================================
+-- TEST 58: Final state check - trash must be empty
+-- ========================================
+SELECT count(*) AS final_count FROM pgtrashcan._trash_catalog;
+ final_count 
+-------------
+           0
+(1 row)
+
diff --git a/contrib/pgtrashcan/meson.build b/contrib/pgtrashcan/meson.build
new file mode 100644
index 00000000000..a0481198428
--- /dev/null
+++ b/contrib/pgtrashcan/meson.build
@@ -0,0 +1,34 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+pgtrashcan_sources = files(
+  'pgtrashcan.c',
+)
+
+if host_system == 'windows'
+  pgtrashcan_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pgtrashcan',
+    '--FILEDESC', 'pgtrashcan - intercept DROP TABLE and move tables to a trash schema',])
+endif
+
+pgtrashcan = shared_module('pgtrashcan',
+  pgtrashcan_sources,
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pgtrashcan
+
+install_data(
+  'pgtrashcan--1.0.sql',
+  'pgtrashcan.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pgtrashcan',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pgtrashcan',
+    ],
+  },
+}
diff --git a/contrib/pgtrashcan/pgtrashcan--1.0.sql b/contrib/pgtrashcan/pgtrashcan--1.0.sql
new file mode 100644
index 00000000000..c02cf6ca8d3
--- /dev/null
+++ b/contrib/pgtrashcan/pgtrashcan--1.0.sql
@@ -0,0 +1,1228 @@
+/* contrib/pgtrashcan/pgtrashcan--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pgtrashcan" to load this file. \quit
+
+-- ************************************************************************
+-- INFRASTRUCTURE SETUP
+--
+-- The pgtrashcan schema and catalog table are defined here in SQL.
+-- This is the source of truth for the schema definition.
+--
+-- The C code (pgtrashcan.c) only verifies that the schema and catalog table
+-- exist (ensure_trash_catalog_exists()). It does NOT recreate them.
+-- This file is the single source of truth for the schema definition.
+-- For version upgrades, create an upgrade script (e.g., pgtrashcan--1.0--2.0.sql)
+-- that uses ALTER TABLE ADD COLUMN etc. The C code uses SPI_fnumber() for
+-- column lookups, so missing columns will produce clear errors if the
+-- upgrade script hasn't been run.
+-- ************************************************************************
+
+-- Create pgtrashcan schema (owned by current user, typically the database owner)
+CREATE SCHEMA IF NOT EXISTS pgtrashcan;
+
+-- Create the catalog table that tracks all trashed objects
+CREATE TABLE IF NOT EXISTS pgtrashcan._trash_catalog (
+    trash_name NAME PRIMARY KEY,
+    orig_name NAME NOT NULL,
+    orig_schema NAME NOT NULL,
+    orig_oid OID NOT NULL,
+    orig_obj_type TEXT NOT NULL CHECK (orig_obj_type IN ('table', 'index', 'sequence')),
+    drop_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+    dropped_by NAME NOT NULL DEFAULT current_user,
+    dependent_on_oid OID,
+    cascade_parent_oid OID,
+    metadata JSONB
+);
+
+-- Indexes for fast lookups
+CREATE INDEX IF NOT EXISTS idx_trash_catalog_orig_name
+    ON pgtrashcan._trash_catalog(orig_name, drop_time DESC);
+
+CREATE INDEX IF NOT EXISTS idx_trash_catalog_orig_schema
+    ON pgtrashcan._trash_catalog(orig_schema);
+
+CREATE INDEX IF NOT EXISTS idx_trash_catalog_orig_oid
+    ON pgtrashcan._trash_catalog(orig_oid);
+
+CREATE INDEX IF NOT EXISTS idx_trash_catalog_dependent_on
+    ON pgtrashcan._trash_catalog(dependent_on_oid) WHERE dependent_on_oid IS NOT NULL;
+
+-- C trigger function to protect catalog from direct user DML
+-- Checks pgtrashcan.internal_operation GUC (PGC_SUSET = superuser-only)
+CREATE FUNCTION pgtrashcan_protect_catalog()
+RETURNS TRIGGER AS 'pgtrashcan'
+LANGUAGE C;
+
+CREATE TRIGGER protect_trash_catalog
+    BEFORE INSERT OR UPDATE OR DELETE
+    ON pgtrashcan._trash_catalog
+    FOR EACH ROW
+    EXECUTE FUNCTION pgtrashcan_protect_catalog();
+
+-- ************************************************************************
+-- FUNCTION DEFINITIONS
+-- ************************************************************************
+
+-- Function to list all dropped tables with full metadata
+CREATE OR REPLACE FUNCTION pgtrashcan_list_dropped()
+RETURNS TABLE(
+    trash_name name,
+    orig_name name,
+    orig_schema name,
+    orig_oid oid,
+    obj_type text,
+    drop_time timestamptz,
+    dropped_by name,
+    is_dependent boolean,
+    cascade_parent_oid oid
+)
+LANGUAGE SQL
+AS $$
+    SELECT
+        trash_name,
+        orig_name,
+        orig_schema,
+        orig_oid,
+        orig_obj_type,
+        drop_time,
+        dropped_by,
+        dependent_on_oid IS NOT NULL AS is_dependent,
+        cascade_parent_oid
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_obj_type = 'table'  -- Only show tables, not dependent objects
+    ORDER BY drop_time DESC;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_list_dropped() IS
+'Lists all dropped tables currently in the trash with their metadata';
+
+-- Function to list dropped tables grouped by CASCADE drop operations
+CREATE OR REPLACE FUNCTION pgtrashcan_list_dropped_grouped()
+RETURNS TABLE(
+    drop_group int,
+    relationship text,
+    trash_name name,
+    orig_name name,
+    orig_schema name,
+    orig_oid oid,
+    drop_time timestamptz,
+    dropped_by name
+)
+LANGUAGE SQL
+AS $$
+    WITH RECURSIVE roots AS (
+        /*
+         * Determine the "root" for each table:
+         * - If cascade_parent_oid IS NULL → this table is either standalone or a CASCADE root
+         * - If cascade_parent_oid points to a row that still exists → the root is the
+         *   top-level ancestor (follow the chain up)
+         * - If cascade_parent_oid points to a purged row → orphan, treat as standalone
+         *
+         * We use a recursive CTE to walk up the cascade_parent_oid chain to find the
+         * ultimate root (the table the user explicitly dropped).
+         */
+        SELECT
+            t.orig_oid,
+            t.trash_name,
+            t.orig_name,
+            t.orig_schema,
+            t.drop_time,
+            t.dropped_by,
+            t.cascade_parent_oid,
+            CASE
+                -- No parent → standalone or root
+                WHEN t.cascade_parent_oid IS NULL THEN t.orig_oid
+                -- Parent exists in catalog → use parent's orig_oid as group key
+                -- (for deeper nesting, we find the ultimate root below)
+                WHEN EXISTS (
+                    SELECT 1 FROM pgtrashcan._trash_catalog p
+                    WHERE p.orig_oid = t.cascade_parent_oid AND p.orig_obj_type = 'table'
+                ) THEN NULL  -- will be resolved by recursive CTE
+                -- Parent was purged → orphan, treat as standalone
+                ELSE t.orig_oid
+            END AS immediate_root
+        FROM pgtrashcan._trash_catalog t
+        WHERE t.orig_obj_type = 'table'
+    ),
+    -- Recursive CTE to find the ultimate root for each table
+    ancestry AS (
+        -- Base case: tables that are already roots (no parent, or orphaned)
+        SELECT
+            orig_oid,
+            orig_oid AS root_oid,
+            0 AS depth
+        FROM roots
+        WHERE immediate_root IS NOT NULL
+
+        UNION ALL
+
+        -- Recursive case: follow cascade_parent_oid up the chain
+        SELECT
+            r.orig_oid,
+            CASE
+                -- If parent has no parent → parent is the root
+                WHEN p.cascade_parent_oid IS NULL THEN p.orig_oid
+                -- If parent's parent doesn't exist in catalog → parent is the root (orphan chain)
+                WHEN NOT EXISTS (
+                    SELECT 1 FROM pgtrashcan._trash_catalog pp
+                    WHERE pp.orig_oid = p.cascade_parent_oid AND pp.orig_obj_type = 'table'
+                ) THEN p.orig_oid
+                -- Otherwise keep going up (handled by next recursion)
+                ELSE NULL
+            END AS root_oid,
+            a.depth + 1
+        FROM roots r
+        JOIN pgtrashcan._trash_catalog p ON p.orig_oid = r.cascade_parent_oid AND p.orig_obj_type = 'table'
+        JOIN ancestry a ON a.orig_oid = r.cascade_parent_oid
+        WHERE r.immediate_root IS NULL
+          AND a.root_oid IS NOT NULL
+          AND a.depth < 50  -- safety limit for pathological chains
+    ),
+    resolved AS (
+        SELECT
+            r.orig_oid,
+            r.trash_name,
+            r.orig_name,
+            r.orig_schema,
+            r.drop_time,
+            r.dropped_by,
+            r.cascade_parent_oid,
+            COALESCE(a.root_oid, r.orig_oid) AS root_oid
+        FROM roots r
+        LEFT JOIN ancestry a ON a.orig_oid = r.orig_oid
+    ),
+    grouped AS (
+        SELECT
+            DENSE_RANK() OVER (ORDER BY root_oid) AS drop_group,
+            CASE
+                WHEN cascade_parent_oid IS NULL THEN
+                    CASE
+                        -- Root with children → 'parent'
+                        WHEN EXISTS (
+                            SELECT 1 FROM pgtrashcan._trash_catalog c
+                            WHERE c.cascade_parent_oid = resolved.orig_oid AND c.orig_obj_type = 'table'
+                        ) THEN 'parent'
+                        ELSE 'standalone'
+                    END
+                WHEN NOT EXISTS (
+                    SELECT 1 FROM pgtrashcan._trash_catalog p
+                    WHERE p.orig_oid = resolved.cascade_parent_oid AND p.orig_obj_type = 'table'
+                ) THEN 'orphan'
+                ELSE 'child'
+            END AS relationship,
+            trash_name,
+            orig_name,
+            orig_schema,
+            orig_oid,
+            drop_time,
+            dropped_by
+        FROM resolved
+    )
+    SELECT
+        drop_group::int,
+        relationship,
+        trash_name,
+        orig_name,
+        orig_schema,
+        orig_oid,
+        drop_time,
+        dropped_by
+    FROM grouped
+    ORDER BY drop_group, relationship = 'parent' DESC, relationship = 'standalone' DESC, orig_name;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_list_dropped_grouped() IS
+'Lists all dropped tables grouped by CASCADE drop operations. Shows drop_group number (tables dropped together share a group), relationship (parent/child/standalone/orphan), and standard metadata. Orphans are children whose CASCADE parent was purged.';
+
+-- Function to restore a table by its original OID with enhanced conflict detection
+CREATE OR REPLACE FUNCTION pgtrashcan_restore_by_oid(p_orig_oid oid)
+RETURNS text
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $$
+DECLARE
+    v_trash_name name;
+    v_orig_schema name;
+    v_orig_name name;
+    v_obj_type text;
+    v_dependent_on oid;
+    v_cascade_parent oid;
+    v_drop_time timestamptz;
+    v_conflicts text[];
+    v_objects_to_restore text[];
+    v_result text;
+    v_metadata jsonb;  -- to store FK metadata
+    r RECORD;
+BEGIN
+    -- Session-level advisory lock to prevent concurrent restore of same object
+    -- This allows same-transaction usage while preventing cross-session conflicts
+    IF NOT pg_try_advisory_lock(p_orig_oid::bigint) THEN
+        RAISE EXCEPTION 'Another session is currently restoring OID %.', p_orig_oid
+            USING HINT = 'Please wait and try again.';
+    END IF;
+
+    -- Use exception handling to ensure lock is always released
+    BEGIN
+
+    -- Query catalog for main table
+    SELECT trash_name, orig_schema, orig_name, orig_obj_type, dependent_on_oid, cascade_parent_oid, drop_time, metadata
+    INTO v_trash_name, v_orig_schema, v_orig_name, v_obj_type, v_dependent_on, v_cascade_parent, v_drop_time, v_metadata
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_oid = p_orig_oid;
+
+    IF NOT FOUND THEN
+        RAISE EXCEPTION 'Object with OID % not found in trash', p_orig_oid
+            USING HINT = 'Use pgtrashcan_list_dropped() to see available objects.';
+    END IF;
+
+    -- Only allow restoring tables directly (not indexes, sequences, etc.)
+    IF v_obj_type != 'table' THEN
+        RAISE EXCEPTION 'Cannot directly restore object of type "%" (OID: %). Only tables can be restored directly.',
+            v_obj_type, p_orig_oid
+            USING HINT = format('This %s will be automatically restored when its parent table is restored.', v_obj_type);
+    END IF;
+
+    -- Check if it's a dependent object (index, sequence) vs a CASCADE'd table
+    IF v_dependent_on IS NOT NULL THEN
+        RAISE EXCEPTION 'Cannot directly restore dependent object of type "%" (OID: %). This object belongs to table with OID %.',
+            v_obj_type, p_orig_oid, v_dependent_on
+            USING HINT = format('This %s will be automatically restored when its parent table is restored.', v_obj_type);
+    END IF;
+
+    -- Check if it's a CASCADE'd table (dropped via DROP CASCADE)
+    IF v_cascade_parent IS NOT NULL THEN
+        RAISE NOTICE 'This table was moved to trash via CASCADE when parent table (OID: %) was dropped.', v_cascade_parent;
+        RAISE NOTICE 'Foreign keys to parent were permanently dropped and will not be recreated.';
+    END IF;
+
+    -- Build list of all objects that will be restored
+    v_objects_to_restore := ARRAY[]::text[];
+    v_objects_to_restore := v_objects_to_restore || format('Table: %I.%I (from %I)', v_orig_schema, v_orig_name, v_trash_name);
+
+    -- Add dependent objects to the list
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = p_orig_oid
+        ORDER BY orig_obj_type, orig_name
+    LOOP
+        v_objects_to_restore := v_objects_to_restore ||
+            format('%s: %I.%I (from %I)',
+                   CASE r.orig_obj_type
+                       WHEN 'index' THEN 'Index'
+                       WHEN 'sequence' THEN 'Sequence'
+                       ELSE initcap(r.orig_obj_type)
+                   END,
+                   v_orig_schema, r.orig_name, r.trash_name);
+    END LOOP;
+
+    -- Comprehensive conflict detection
+    v_conflicts := ARRAY[]::text[];
+
+    -- Check if target table name conflicts
+    IF EXISTS (
+        SELECT 1
+        FROM pg_class c
+        JOIN pg_namespace n ON c.relnamespace = n.oid
+        WHERE n.nspname = v_orig_schema
+          AND c.relname = v_orig_name
+          AND c.relkind = 'r'
+    ) THEN
+        v_conflicts := v_conflicts || format('Table: %I.%I already exists', v_orig_schema, v_orig_name);
+    END IF;
+
+    -- Check if dependent object names conflict
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = p_orig_oid
+    LOOP
+        -- Check for index conflicts
+        IF r.orig_obj_type = 'index' AND EXISTS (
+            SELECT 1
+            FROM pg_class c
+            JOIN pg_namespace n ON c.relnamespace = n.oid
+            WHERE n.nspname = v_orig_schema
+              AND c.relname = r.orig_name
+              AND c.relkind = 'i'
+        ) THEN
+            v_conflicts := v_conflicts || format('Index: %I.%I already exists', v_orig_schema, r.orig_name);
+        END IF;
+
+        -- Check for sequence conflicts
+        IF r.orig_obj_type = 'sequence' AND EXISTS (
+            SELECT 1
+            FROM pg_class c
+            JOIN pg_namespace n ON c.relnamespace = n.oid
+            WHERE n.nspname = v_orig_schema
+              AND c.relname = r.orig_name
+              AND c.relkind = 'S'
+        ) THEN
+            v_conflicts := v_conflicts || format('Sequence: %I.%I already exists', v_orig_schema, r.orig_name);
+        END IF;
+    END LOOP;
+
+    -- If conflicts exist, provide detailed report and abort
+    IF array_length(v_conflicts, 1) > 0 THEN
+        RAISE EXCEPTION E'Cannot restore due to naming conflicts:\n\nObjects to restore:\n  %\n\nConflicting objects in target schema:\n  %\n\nSolutions:\n1. Drop conflicting objects in target schema first\n2. Use pgtrashcan_rename_in_trash(''trash_name'', ''new_name'') to rename conflicting objects in trash before restoring',
+            array_to_string(v_objects_to_restore, E'\n  '),
+            array_to_string(v_conflicts, E'\n  ')
+            USING HINT = 'Use pgtrashcan_rename_in_trash() to rename any conflicting table, index, or sequence in trash';
+    END IF;
+
+    /*
+     * Note: We rely on PostgreSQL's native locking for protection:
+     * - ALTER TABLE SET SCHEMA acquires AccessExclusiveLock on the table
+     * - This prevents concurrent operations on the same table
+     * - Different tables can be restored in parallel (different OID locks)
+     * - No additional schema-level locks needed
+     */
+
+    -- Enable internal_operation to allow ALTER TABLE and RENAME on pgtrashcan objects.
+    -- These are SECURITY DEFINER operations, not user-initiated changes.
+    SET LOCAL pgtrashcan.internal_operation = true;
+
+    -- Move table back to original schema (keeping trash name initially)
+    EXECUTE format('ALTER TABLE pgtrashcan.%I SET SCHEMA %I',
+                   v_trash_name, v_orig_schema);
+
+    -- Rename table to original name
+    IF v_orig_name != v_trash_name THEN
+        EXECUTE format('ALTER TABLE %I.%I RENAME TO %I',
+                       v_orig_schema, v_trash_name, v_orig_name);
+    END IF;
+
+    -- Rename dependent objects back to original names
+    -- Only process indexes and sequences (these move automatically with the table)
+    -- Do NOT process tables/views that reference this table (user must restore them separately)
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = p_orig_oid
+          AND orig_obj_type IN ('index', 'sequence')  -- Only own dependents, not referencing objects
+        ORDER BY orig_obj_type, orig_name
+    LOOP
+        -- These dependent objects (indexes, sequences) are automatically moved with the table
+        -- We just need to rename them back to original names
+        IF r.orig_name != r.trash_name THEN
+            EXECUTE format('ALTER %s %I.%I RENAME TO %I',
+                          CASE r.orig_obj_type
+                              WHEN 'index' THEN 'INDEX'
+                              WHEN 'sequence' THEN 'SEQUENCE'
+                              ELSE UPPER(r.orig_obj_type)
+                          END,
+                          v_orig_schema, r.trash_name, r.orig_name);
+        END IF;
+    END LOOP;
+
+    -- Delete catalog entries for this table and its own dependents (indexes, sequences)
+    -- Do NOT delete entries for other tables/views that were CASCADE'd - they remain in pgtrashcan
+    SET LOCAL pgtrashcan.internal_operation = true;
+    DELETE FROM pgtrashcan._trash_catalog
+    WHERE orig_oid = p_orig_oid
+       OR (dependent_on_oid = p_orig_oid AND orig_obj_type IN ('index', 'sequence'));
+
+    v_result := format('Successfully restored table %s.%s (OID: %s, dropped at: %s)',
+                      v_orig_schema, v_orig_name, p_orig_oid, v_drop_time);
+
+    -- Show detailed warnings about dropped foreign keys
+    IF v_metadata IS NOT NULL AND v_metadata ? 'foreign_keys_dropped' THEN
+        DECLARE
+            v_fk_array jsonb;
+            v_fk_count int;
+            v_fk_item jsonb;
+            v_fk_details text[];
+        BEGIN
+            v_fk_array := v_metadata->'foreign_keys_dropped';
+            v_fk_count := jsonb_array_length(v_fk_array);
+
+            IF v_fk_count > 0 THEN
+                v_fk_details := ARRAY[]::text[];
+
+                -- Build detailed list of dropped FKs
+                FOR i IN 0 .. v_fk_count - 1 LOOP
+                    v_fk_item := v_fk_array->i;
+
+                    IF (v_fk_item->>'constraint_type') = 'outgoing' THEN
+                        v_fk_details := v_fk_details || format(
+                            '  - %s: %s',
+                            v_fk_item->>'constraint_name',
+                            v_fk_item->>'ddl'
+                        );
+                    ELSE
+                        v_fk_details := v_fk_details || format(
+                            '  - %s (on %s.%s): %s',
+                            v_fk_item->>'constraint_name',
+                            v_fk_item->>'source_schema',
+                            v_fk_item->>'source_table',
+                            v_fk_item->>'ddl'
+                        );
+                    END IF;
+                END LOOP;
+
+                -- Raise detailed notice about dropped FKs
+                RAISE NOTICE E'Table restored without % foreign key constraint(s) that were permanently dropped:\n%',
+                    v_fk_count,
+                    array_to_string(v_fk_details, E'\n');
+            END IF;
+        EXCEPTION
+            WHEN OTHERS THEN
+                -- If FK warning fails, don't block restore
+                RAISE WARNING 'Could not display FK warning details: %', SQLERRM;
+        END;
+    END IF;
+
+    -- Show informational notice about triggers preserved with the table
+    IF v_metadata IS NOT NULL AND v_metadata ? 'triggers_dropped' THEN
+        DECLARE
+            v_trigger_array jsonb;
+            v_trigger_count int;
+            v_trigger_item jsonb;
+            v_trigger_details text[];
+            v_missing_funcs text[];
+            v_func_name text;
+        BEGIN
+            v_trigger_array := v_metadata->'triggers_dropped';
+            v_trigger_count := jsonb_array_length(v_trigger_array);
+
+            IF v_trigger_count > 0 THEN
+                v_trigger_details := ARRAY[]::text[];
+                v_missing_funcs := ARRAY[]::text[];
+
+                FOR i IN 0 .. v_trigger_count - 1 LOOP
+                    v_trigger_item := v_trigger_array->i;
+                    v_func_name := v_trigger_item->>'function';
+
+                    v_trigger_details := v_trigger_details || format(
+                        '  - %s: %s %s %s trigger calling %s',
+                        v_trigger_item->>'trigger_name',
+                        v_trigger_item->>'timing',
+                        v_trigger_item->>'event',
+                        v_trigger_item->>'level',
+                        v_func_name
+                    );
+
+                    -- Check if trigger function still exists
+                    IF v_func_name IS NOT NULL AND NOT EXISTS (
+                        SELECT 1 FROM pg_proc WHERE proname = split_part(v_func_name, '(', 1)
+                    ) THEN
+                        v_missing_funcs := v_missing_funcs || v_func_name;
+                    END IF;
+                END LOOP;
+
+                RAISE NOTICE E'Table restored with % trigger(s) preserved:\n%',
+                    v_trigger_count,
+                    array_to_string(v_trigger_details, E'\n');
+
+                IF array_length(v_missing_funcs, 1) > 0 THEN
+                    RAISE WARNING E'% trigger function(s) may no longer exist: %\nThese triggers will fail when fired. Drop or recreate them manually.',
+                        array_length(v_missing_funcs, 1),
+                        array_to_string(v_missing_funcs, ', ');
+                END IF;
+            END IF;
+        EXCEPTION
+            WHEN OTHERS THEN
+                RAISE WARNING 'Could not display trigger details: %', SQLERRM;
+        END;
+    END IF;
+
+    -- Show detailed warnings about dropped views
+    IF v_metadata IS NOT NULL AND v_metadata ? 'views_dropped' THEN
+        DECLARE
+            v_view_array jsonb;
+            v_view_count int;
+            v_view_item jsonb;
+            v_view_details text[];
+        BEGIN
+            v_view_array := v_metadata->'views_dropped';
+            v_view_count := jsonb_array_length(v_view_array);
+
+            IF v_view_count > 0 THEN
+                v_view_details := ARRAY[]::text[];
+
+                -- Build detailed list of dropped views in replay-friendly order
+                -- (collect_views_recursive stores leaves first; reverse so nested
+                --  views come after the views they depend on).
+                FOR i IN REVERSE (v_view_count - 1) .. 0 LOOP
+                    v_view_item := v_view_array->i;
+
+                    v_view_details := v_view_details || format(
+                        '  - %s.%s (%s):%s    %s',
+                        v_view_item->>'view_schema',
+                        v_view_item->>'view_name',
+                        v_view_item->>'view_type',
+                        E'\n',
+                        v_view_item->>'ddl'
+                    );
+                END LOOP;
+
+                -- Raise detailed notice about dropped views
+                RAISE NOTICE E'Table restored without % view(s)/materialized view(s) that were permanently dropped:\n%',
+                    v_view_count,
+                    array_to_string(v_view_details, E'\n');
+            END IF;
+        EXCEPTION
+            WHEN OTHERS THEN
+                -- If view warning fails, don't block restore
+                RAISE WARNING 'Could not display view warning details: %', SQLERRM;
+        END;
+    END IF;
+
+    -- Show detailed warnings about dropped rules
+    IF v_metadata IS NOT NULL AND v_metadata ? 'rules_dropped' THEN
+        DECLARE
+            v_rule_array jsonb;
+            v_rule_count int;
+            v_rule_item jsonb;
+            v_rule_details text[];
+        BEGIN
+            v_rule_array := v_metadata->'rules_dropped';
+            v_rule_count := jsonb_array_length(v_rule_array);
+
+            IF v_rule_count > 0 THEN
+                v_rule_details := ARRAY[]::text[];
+
+                FOR i IN 0 .. v_rule_count - 1 LOOP
+                    v_rule_item := v_rule_array->i;
+
+                    v_rule_details := v_rule_details || format(
+                        '  - %s (%s event):%s    %s',
+                        v_rule_item->>'rule_name',
+                        v_rule_item->>'event',
+                        E'\n',
+                        v_rule_item->>'definition'
+                    );
+                END LOOP;
+
+                RAISE NOTICE E'Table restored without % rule(s) that were permanently dropped:\n%',
+                    v_rule_count,
+                    array_to_string(v_rule_details, E'\n');
+            END IF;
+        EXCEPTION
+            WHEN OTHERS THEN
+                -- If rule warning fails, don't block restore
+                RAISE WARNING 'Could not display rule warning details: %', SQLERRM;
+        END;
+    END IF;
+
+    -- Show detailed warnings about dropped RLS policies
+    IF v_metadata IS NOT NULL AND v_metadata ? 'policies_dropped' THEN
+        DECLARE
+            v_policy_array jsonb;
+            v_policy_count int;
+            v_policy_item jsonb;
+            v_policy_details text[];
+        BEGIN
+            v_policy_array := v_metadata->'policies_dropped';
+            v_policy_count := jsonb_array_length(v_policy_array);
+
+            IF v_policy_count > 0 THEN
+                v_policy_details := ARRAY[]::text[];
+
+                FOR i IN 0 .. v_policy_count - 1 LOOP
+                    v_policy_item := v_policy_array->i;
+
+                    v_policy_details := v_policy_details || format(
+                        '  - %s: FOR %s TO %s %s%s%s',
+                        v_policy_item->>'policy_name',
+                        v_policy_item->>'command',
+                        v_policy_item->>'roles',
+                        v_policy_item->>'permissive',
+                        CASE WHEN v_policy_item->>'using' != '' THEN E'\n    USING (' || (v_policy_item->>'using') || ')' ELSE '' END,
+                        CASE WHEN v_policy_item->>'check' != '' THEN E'\n    WITH CHECK (' || (v_policy_item->>'check') || ')' ELSE '' END
+                    );
+                END LOOP;
+
+                RAISE NOTICE E'Table restored without % RLS polic%s that were permanently dropped:\n%',
+                    v_policy_count,
+                    CASE WHEN v_policy_count = 1 THEN 'y' ELSE 'ies' END,
+                    array_to_string(v_policy_details, E'\n');
+            END IF;
+        EXCEPTION
+            WHEN OTHERS THEN
+                RAISE WARNING 'Could not display policy warning details: %', SQLERRM;
+        END;
+    END IF;
+
+    -- Release the session-level advisory lock
+    PERFORM pg_advisory_unlock(p_orig_oid::bigint);
+
+    RAISE NOTICE '%', v_result;
+    RETURN v_result;
+
+    EXCEPTION
+        WHEN OTHERS THEN
+            -- Always release lock on error
+            PERFORM pg_advisory_unlock(p_orig_oid::bigint);
+            RAISE;
+    END;
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_restore_by_oid(oid) IS
+'Restores a dropped table by its original OID. Automatically restores dependent objects (indexes, sequences). Use pgtrashcan_rename_in_trash() first if there are naming conflicts.';
+
+-- Function to restore a table by name (with conflict resolution)
+CREATE OR REPLACE FUNCTION pgtrashcan_restore(
+    p_table_name text,
+    p_schema_name text DEFAULT NULL,
+    p_oid_hint oid DEFAULT NULL
+)
+RETURNS text
+LANGUAGE plpgsql
+AS $$
+DECLARE
+    v_count int;
+    v_trash_name name;
+    v_orig_schema name;
+    v_orig_oid oid;
+    v_drop_time timestamptz;
+    r RECORD;
+BEGIN
+    -- Step 1: Find all matching entries (all tables, including dependents)
+    SELECT COUNT(*)
+    INTO v_count
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_name = p_table_name::name
+      AND orig_obj_type = 'table'
+      -- Allow both independent and dependent tables to be restored
+      AND (p_schema_name IS NULL OR orig_schema = p_schema_name)
+      AND (p_oid_hint IS NULL OR orig_oid = p_oid_hint);
+
+    -- Step 2: No results
+    IF v_count = 0 THEN
+        RAISE EXCEPTION 'Table "%" not found in trash%',
+            p_table_name,
+            CASE WHEN p_schema_name IS NOT NULL
+                 THEN format(' in schema "%s"', p_schema_name)
+                 ELSE ''
+            END
+            USING HINT = 'Use pgtrashcan_list_dropped() to see available objects.';
+    END IF;
+
+    -- Step 3: Exactly one result - restore it
+    IF v_count = 1 THEN
+        SELECT trash_name, orig_schema, orig_oid, drop_time
+        INTO v_trash_name, v_orig_schema, v_orig_oid, v_drop_time
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_name = p_table_name::name
+          AND orig_obj_type = 'table'
+          AND (p_schema_name IS NULL OR orig_schema = p_schema_name)
+          AND (p_oid_hint IS NULL OR orig_oid = p_oid_hint);
+
+        RETURN pgtrashcan_restore_by_oid(v_orig_oid);
+    END IF;
+
+    -- Step 4: Multiple results
+    -- Check if they're from different schemas
+    SELECT COUNT(DISTINCT orig_schema)
+    INTO v_count
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_name = p_table_name::name
+      AND orig_obj_type = 'table';
+
+    IF v_count > 1 AND p_schema_name IS NULL THEN
+        -- Multiple schemas, user must specify
+        RAISE EXCEPTION 'Multiple tables named "%" found in trash from different schemas. Specify schema name.', p_table_name
+            USING HINT = format('Available schemas: %s. Use: SELECT pgtrashcan_restore(''%s'', ''<schema_name>'');',
+                (SELECT string_agg(DISTINCT orig_schema, ', ' ORDER BY orig_schema)
+                 FROM pgtrashcan._trash_catalog
+                 WHERE orig_name = p_table_name::name AND orig_obj_type = 'table'),
+                p_table_name);
+    END IF;
+
+    -- Step 5: Multiple from same schema - restore latest by default unless user specified OID
+    IF p_oid_hint IS NULL THEN
+        -- Show options and restore latest
+        RAISE NOTICE 'Multiple versions of "%" found in trash:', p_table_name;
+
+        FOR r IN
+            SELECT orig_oid, orig_schema, drop_time, dropped_by
+            FROM pgtrashcan._trash_catalog
+            WHERE orig_name = p_table_name::name
+              AND orig_obj_type = 'table'
+              AND (p_schema_name IS NULL OR orig_schema = p_schema_name)
+            ORDER BY drop_time DESC
+        LOOP
+            RAISE NOTICE '  OID: %, Schema: %, Dropped at: %, By: %',
+                r.orig_oid, r.orig_schema, r.drop_time, r.dropped_by;
+        END LOOP;
+
+        RAISE NOTICE 'Restoring most recent version. To restore a specific version, use: SELECT pgtrashcan_restore(''%'', ''%'', <oid>);',
+            p_table_name, COALESCE(p_schema_name, 'schema_name');
+
+        -- Restore most recent
+        SELECT orig_oid
+        INTO v_orig_oid
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_name = p_table_name::name
+          AND orig_obj_type = 'table'
+          AND (p_schema_name IS NULL OR orig_schema = p_schema_name)
+        ORDER BY drop_time DESC
+        LIMIT 1;
+
+        RETURN pgtrashcan_restore_by_oid(v_orig_oid);
+    ELSE
+        -- User specified OID - restore that one
+        RETURN pgtrashcan_restore_by_oid(p_oid_hint);
+    END IF;
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_restore(text, text, oid) IS
+'Restores a dropped table by name. Handles schema conflicts and multiple versions. Use pgtrashcan_rename_in_trash() first if there are naming conflicts.';
+
+-- Function to permanently delete objects from trash by name
+CREATE OR REPLACE FUNCTION pgtrashcan_purge(
+    p_table_name text,
+    p_schema_name text DEFAULT NULL,
+    p_oid_hint oid DEFAULT NULL
+)
+RETURNS text
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $$
+DECLARE
+    v_trash_name name;
+    v_orig_schema name;
+    v_orig_oid oid;
+    v_count int;
+    v_result text;
+    v_objects_purged text[];
+    r RECORD;
+BEGIN
+    -- Count matching entries
+    IF p_oid_hint IS NOT NULL THEN
+        -- Direct OID lookup
+        SELECT count(*) INTO v_count
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_oid = p_oid_hint AND orig_obj_type = 'table';
+    ELSIF p_schema_name IS NOT NULL THEN
+        -- Schema + name lookup
+        SELECT count(*) INTO v_count
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_name = p_table_name::name
+          AND orig_schema = p_schema_name
+          AND orig_obj_type = 'table';
+    ELSE
+        -- Name only lookup
+        SELECT count(*) INTO v_count
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_name = p_table_name
+          AND orig_obj_type = 'table';
+    END IF;
+
+    IF v_count = 0 THEN
+        RAISE EXCEPTION 'Table "%" not found in trash', p_table_name
+            USING HINT = 'Use pgtrashcan_list_dropped() to see available objects.';
+    END IF;
+
+    IF v_count > 1 AND p_oid_hint IS NULL THEN
+        RAISE EXCEPTION 'Multiple versions of "%" found in trash. Specify schema or OID.', p_table_name
+            USING HINT = 'Use: SELECT * FROM pgtrashcan_list_dropped() WHERE orig_name = ''' || p_table_name || '''; then use pgtrashcan_purge_by_oid(oid) or specify schema.';
+    END IF;
+
+    -- Get the entry to purge
+    IF p_oid_hint IS NOT NULL THEN
+        SELECT trash_name, orig_schema, orig_oid
+        INTO v_trash_name, v_orig_schema, v_orig_oid
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_oid = p_oid_hint AND orig_obj_type = 'table';
+    ELSIF p_schema_name IS NOT NULL THEN
+        SELECT trash_name, orig_schema, orig_oid
+        INTO v_trash_name, v_orig_schema, v_orig_oid
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_name = p_table_name::name
+          AND orig_schema = p_schema_name
+          AND orig_obj_type = 'table';
+    ELSE
+        -- Single match - use latest
+        SELECT trash_name, orig_schema, orig_oid
+        INTO v_trash_name, v_orig_schema, v_orig_oid
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_name = p_table_name::name
+          AND orig_obj_type = 'table'
+        ORDER BY drop_time DESC
+        LIMIT 1;
+    END IF;
+
+    -- Build list of objects that will be purged
+    v_objects_purged := ARRAY[]::text[];
+    v_objects_purged := v_objects_purged || format('Table: %s (from %s.%s)', p_table_name, v_orig_schema, v_trash_name);
+
+    -- Add dependent objects to the list
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = v_orig_oid
+        ORDER BY orig_obj_type, orig_name
+    LOOP
+        v_objects_purged := v_objects_purged ||
+            format('%s: %s (from %s)',
+                   CASE r.orig_obj_type
+                       WHEN 'index' THEN 'Index'
+                       WHEN 'sequence' THEN 'Sequence'
+                       ELSE initcap(r.orig_obj_type)
+                   END,
+                   r.orig_name, r.trash_name);
+    END LOOP;
+
+    -- Permanently drop the table from pgtrashcan (CASCADE will drop dependent objects)
+    EXECUTE format('DROP TABLE IF EXISTS pgtrashcan.%I CASCADE', v_trash_name);
+
+    -- Delete catalog entries (parent + all dependents)
+    SET LOCAL pgtrashcan.internal_operation = true;
+    DELETE FROM pgtrashcan._trash_catalog
+    WHERE orig_oid = v_orig_oid OR dependent_on_oid = v_orig_oid;
+
+    v_result := format('Permanently deleted table "%s" (OID: %s) and %s dependent objects from trash',
+                      p_table_name, v_orig_oid, array_length(v_objects_purged, 1) - 1);
+
+    RAISE NOTICE 'Purged objects: %', array_to_string(v_objects_purged, ', ');
+    RAISE NOTICE '%', v_result;
+    RETURN v_result;
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_purge(text, text, oid) IS
+'Permanently deletes a table and its dependent objects from trash (cannot be recovered). Parameters: table_name (required), schema_name (optional), oid_hint (optional).';
+
+-- Function to permanently delete objects from trash by OID
+CREATE OR REPLACE FUNCTION pgtrashcan_purge_by_oid(p_orig_oid oid)
+RETURNS text
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $$
+DECLARE
+    v_trash_name name;
+    v_orig_schema name;
+    v_orig_name name;
+    v_obj_type text;
+    v_dependent_on oid;
+    v_cascade_parent oid;
+    v_drop_time timestamptz;
+    v_objects_purged text[];
+    v_result text;
+    r RECORD;
+BEGIN
+    -- Query catalog for main table
+    SELECT trash_name, orig_schema, orig_name, orig_obj_type, dependent_on_oid, cascade_parent_oid, drop_time
+    INTO v_trash_name, v_orig_schema, v_orig_name, v_obj_type, v_dependent_on, v_cascade_parent, v_drop_time
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_oid = p_orig_oid;
+
+    IF NOT FOUND THEN
+        RAISE EXCEPTION 'Object with OID % not found in trash', p_orig_oid
+            USING HINT = 'Use pgtrashcan_list_dropped() to see available objects.';
+    END IF;
+
+    -- Only allow purging tables directly (not indexes, sequences)
+    IF v_obj_type != 'table' THEN
+        RAISE EXCEPTION 'Cannot directly purge object of type "%" (OID: %). Only tables can be purged directly.',
+            v_obj_type, p_orig_oid
+            USING HINT = format('This %s will be automatically purged when its parent table is purged.', v_obj_type);
+    END IF;
+
+    -- Check if it's a CASCADE'd table (warn user but allow purging)
+    IF v_cascade_parent IS NOT NULL THEN
+        RAISE NOTICE 'This table was moved to trash via CASCADE when parent table (OID: %) was dropped.', v_cascade_parent;
+    END IF;
+
+    -- Build list of objects that will be purged
+    v_objects_purged := ARRAY[]::text[];
+    v_objects_purged := v_objects_purged || format('Table: %s.%s (from %s)', v_orig_schema, v_orig_name, v_trash_name);
+
+    -- Add dependent objects to the list
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = p_orig_oid
+        ORDER BY orig_obj_type, orig_name
+    LOOP
+        v_objects_purged := v_objects_purged ||
+            format('%s: %s.%s (from %s)',
+                   CASE r.orig_obj_type
+                       WHEN 'index' THEN 'Index'
+                       WHEN 'sequence' THEN 'Sequence'
+                       ELSE initcap(r.orig_obj_type)
+                   END,
+                   v_orig_schema, r.orig_name, r.trash_name);
+    END LOOP;
+
+    -- Permanently drop the table from pgtrashcan (CASCADE will drop dependent objects)
+    EXECUTE format('DROP TABLE IF EXISTS pgtrashcan.%I CASCADE', v_trash_name);
+
+    -- Delete catalog entries (parent + all dependents)
+    SET LOCAL pgtrashcan.internal_operation = true;
+    DELETE FROM pgtrashcan._trash_catalog
+    WHERE orig_oid = p_orig_oid OR dependent_on_oid = p_orig_oid;
+
+    v_result := format('Permanently deleted table "%s.%s" (OID: %s, dropped at: %s) and %s dependent objects from trash',
+                      v_orig_schema, v_orig_name, p_orig_oid, v_drop_time, array_length(v_objects_purged, 1) - 1);
+
+    RAISE NOTICE 'Purged objects: %', array_to_string(v_objects_purged, ', ');
+    RAISE NOTICE '%', v_result;
+    RETURN v_result;
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_purge_by_oid(oid) IS
+'Permanently deletes a table and its dependent objects from trash by OID (cannot be recovered).';
+
+-- Enhanced function to purge all objects from trash with detailed reporting
+CREATE OR REPLACE FUNCTION pgtrashcan_purge_all()
+RETURNS text
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $$
+DECLARE
+    v_count int;
+    v_objects_purged text[];
+    r RECORD;
+BEGIN
+    -- Count tables to purge (both independent and dependent)
+    SELECT count(*) INTO v_count
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_obj_type = 'table';
+
+    IF v_count = 0 THEN
+        RETURN 'pgtrashcan schema is empty - no objects to purge';
+    END IF;
+
+    -- Build list of all objects that will be purged
+    v_objects_purged := ARRAY[]::text[];
+    FOR r IN
+        SELECT orig_schema, orig_name, trash_name, drop_time, dropped_by
+        FROM pgtrashcan._trash_catalog
+        WHERE orig_obj_type = 'table'
+        ORDER BY drop_time DESC
+    LOOP
+        v_objects_purged := v_objects_purged ||
+            format('Table: %s.%s (dropped %s by %s)', r.orig_schema, r.orig_name, r.drop_time::text, r.dropped_by);
+    END LOOP;
+
+    -- Drop all tables in pgtrashcan schema (except catalog)
+    FOR r IN
+        SELECT tablename
+        FROM pg_tables
+        WHERE schemaname = 'pgtrashcan'
+        AND tablename != '_trash_catalog'
+        ORDER BY tablename
+    LOOP
+        EXECUTE format('DROP TABLE IF EXISTS pgtrashcan.%I CASCADE', r.tablename);
+    END LOOP;
+
+    -- Clear catalog (use DELETE instead of TRUNCATE to avoid extension protection)
+    SET LOCAL pgtrashcan.internal_operation = true;
+    DELETE FROM pgtrashcan._trash_catalog;
+
+    RAISE NOTICE 'Purged all objects: %', array_to_string(v_objects_purged, ', ');
+    RAISE NOTICE 'Permanently deleted % table(s) and all their dependent objects from trash', v_count;
+    RETURN format('Permanently deleted %s table(s) and all their dependent objects from trash', v_count);
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_purge_all() IS
+'Permanently deletes ALL objects from trash (cannot be recovered). Use with caution!';
+
+-- Function to change the restore name for objects in trash (for conflict resolution)
+-- This only updates orig_name (the name it will be restored as), not the physical trash name.
+CREATE OR REPLACE FUNCTION pgtrashcan_rename_in_trash(
+    p_trash_name text,
+    p_new_restore_name text
+)
+RETURNS text
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $$
+DECLARE
+    v_orig_obj_type text;
+    v_old_orig_name name;
+    v_result text;
+BEGIN
+    -- Find the object in catalog
+    SELECT orig_obj_type, orig_name
+    INTO v_orig_obj_type, v_old_orig_name
+    FROM pgtrashcan._trash_catalog
+    WHERE trash_name = p_trash_name;
+
+    IF NOT FOUND THEN
+        RAISE EXCEPTION 'Object "%" not found in trash catalog', p_trash_name
+            USING HINT = 'Use pgtrashcan_list_dropped() to see available objects.';
+    END IF;
+
+    -- Update only the orig_name (the name it will be restored as)
+    SET LOCAL pgtrashcan.internal_operation = true;
+    UPDATE pgtrashcan._trash_catalog
+    SET orig_name = p_new_restore_name
+    WHERE trash_name = p_trash_name;
+
+    v_result := format('Updated %s "%s": will now restore as "%s" (was "%s")',
+                      v_orig_obj_type, p_trash_name, p_new_restore_name, v_old_orig_name);
+
+    RAISE NOTICE '%', v_result;
+    RETURN v_result;
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_rename_in_trash(text, text) IS
+'Changes the restore name for an object in trash. The physical trash name stays unchanged (unique by OID). Use this to resolve naming conflicts before restore.';
+
+-- Function to move a trashed table to a user-specified schema (instead of original schema)
+-- Useful for "archive" use cases where you want the data but not in the original schema.
+-- This is the only legitimate way to move a table out of pgtrashcan to a non-original schema.
+CREATE OR REPLACE FUNCTION pgtrashcan_move_to_schema(
+    p_trash_name text,
+    p_target_schema text
+)
+RETURNS text
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $$
+DECLARE
+    v_orig_name name;
+    v_orig_schema name;
+    v_orig_oid oid;
+    v_obj_type text;
+    v_result text;
+    r RECORD;
+BEGIN
+    -- Look up by trash_name
+    SELECT orig_name, orig_schema, orig_oid, orig_obj_type
+    INTO v_orig_name, v_orig_schema, v_orig_oid, v_obj_type
+    FROM pgtrashcan._trash_catalog
+    WHERE trash_name = p_trash_name;
+
+    IF NOT FOUND THEN
+        RAISE EXCEPTION 'Object "%" not found in trash catalog', p_trash_name
+            USING HINT = 'Use pgtrashcan_list_dropped() to see available objects.';
+    END IF;
+
+    -- Only tables can be moved directly; indexes/sequences move automatically
+    IF v_obj_type != 'table' THEN
+        RAISE EXCEPTION 'Cannot directly move object of type "%". Only tables can be moved.',
+            v_obj_type
+            USING HINT = format('This %s is a dependent object and will move with its parent table.', v_obj_type);
+    END IF;
+
+    -- Prevent moving a dependent index/sequence parent (should not happen for tables, but guard anyway)
+    IF EXISTS (
+        SELECT 1 FROM pgtrashcan._trash_catalog
+        WHERE trash_name = p_trash_name AND dependent_on_oid IS NOT NULL
+    ) THEN
+        RAISE EXCEPTION 'Cannot directly move dependent object "%".', p_trash_name
+            USING HINT = 'Move the parent table instead.';
+    END IF;
+
+    -- Check target schema exists
+    IF NOT EXISTS (
+        SELECT 1 FROM pg_namespace WHERE nspname = p_target_schema
+    ) THEN
+        RAISE EXCEPTION 'Target schema "%" does not exist', p_target_schema;
+    END IF;
+
+    -- Block moves into pgtrashcan schema (would re-trash it)
+    IF p_target_schema = 'pgtrashcan' THEN
+        RAISE EXCEPTION 'Cannot move table to pgtrashcan schema. Use pgtrashcan_purge() to delete or pgtrashcan_restore() to restore.';
+    END IF;
+
+    -- Check for table naming conflict in target schema
+    IF EXISTS (
+        SELECT 1 FROM pg_class c
+        JOIN pg_namespace n ON c.relnamespace = n.oid
+        WHERE n.nspname = p_target_schema
+          AND c.relname = v_orig_name
+          AND c.relkind = 'r'
+    ) THEN
+        RAISE EXCEPTION 'Table "%.%" already exists in target schema',
+            p_target_schema, v_orig_name
+            USING HINT = 'Rename the trashed object first with pgtrashcan_rename_in_trash() to avoid the conflict.';
+    END IF;
+
+    -- Check for dependent object naming conflicts in target schema
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = v_orig_oid
+          AND orig_obj_type IN ('index', 'sequence')
+    LOOP
+        IF r.orig_obj_type = 'index' AND EXISTS (
+            SELECT 1 FROM pg_class c
+            JOIN pg_namespace n ON c.relnamespace = n.oid
+            WHERE n.nspname = p_target_schema
+              AND c.relname = r.orig_name
+              AND c.relkind = 'i'
+        ) THEN
+            RAISE EXCEPTION 'Index "%.%" already exists in target schema',
+                p_target_schema, r.orig_name
+                USING HINT = 'Rename the conflicting index in trash first.';
+        END IF;
+
+        IF r.orig_obj_type = 'sequence' AND EXISTS (
+            SELECT 1 FROM pg_class c
+            JOIN pg_namespace n ON c.relnamespace = n.oid
+            WHERE n.nspname = p_target_schema
+              AND c.relname = r.orig_name
+              AND c.relkind = 'S'
+        ) THEN
+            RAISE EXCEPTION 'Sequence "%.%" already exists in target schema',
+                p_target_schema, r.orig_name
+                USING HINT = 'Rename the conflicting sequence in trash first.';
+        END IF;
+    END LOOP;
+
+    -- Enable internal_operation to bypass the pgtrashcan schema ALTER/RENAME protections.
+    -- These are SECURITY DEFINER operations, not user-initiated changes.
+    SET LOCAL pgtrashcan.internal_operation = true;
+
+    -- Move table from pgtrashcan to target schema (table keeps its trash_$<oid> name initially)
+    EXECUTE format('ALTER TABLE pgtrashcan.%I SET SCHEMA %I', p_trash_name, p_target_schema);
+
+    -- Rename from trash_$<oid> back to original name in the target schema
+    IF v_orig_name::text != p_trash_name THEN
+        EXECUTE format('ALTER TABLE %I.%I RENAME TO %I',
+                       p_target_schema, p_trash_name, v_orig_name);
+    END IF;
+
+    -- Rename dependent objects (indexes, sequences) back to original names.
+    -- These move automatically with the table via SET SCHEMA.
+    FOR r IN
+        SELECT trash_name, orig_name, orig_obj_type
+        FROM pgtrashcan._trash_catalog
+        WHERE dependent_on_oid = v_orig_oid
+          AND orig_obj_type IN ('index', 'sequence')
+        ORDER BY orig_obj_type, orig_name
+    LOOP
+        IF r.orig_name != r.trash_name THEN
+            EXECUTE format('ALTER %s %I.%I RENAME TO %I',
+                          CASE r.orig_obj_type
+                              WHEN 'index' THEN 'INDEX'
+                              WHEN 'sequence' THEN 'SEQUENCE'
+                              ELSE UPPER(r.orig_obj_type)
+                          END,
+                          p_target_schema, r.trash_name, r.orig_name);
+        END IF;
+    END LOOP;
+
+    -- Clean up catalog entries for this table and its dependent indexes/sequences
+    DELETE FROM pgtrashcan._trash_catalog
+    WHERE orig_oid = v_orig_oid
+       OR (dependent_on_oid = v_orig_oid AND orig_obj_type IN ('index', 'sequence'));
+
+    v_result := format('Moved table "%s" (originally "%s"."%s") to schema "%s" as "%s"',
+                       p_trash_name, v_orig_schema, v_orig_name, p_target_schema, v_orig_name);
+    RAISE NOTICE '%', v_result;
+    RETURN v_result;
+END;
+$$;
+
+COMMENT ON FUNCTION pgtrashcan_move_to_schema(text, text) IS
+'Moves a trashed table to a specified schema (instead of its original schema). The table is restored with its original name. Use this for archive/migration workflows. Catalog entries are cleaned up. This is the only safe way to move a trash object to a non-original schema.';
+
diff --git a/contrib/pgtrashcan/pgtrashcan.c b/contrib/pgtrashcan/pgtrashcan.c
new file mode 100644
index 00000000000..708e7bb1dba
--- /dev/null
+++ b/contrib/pgtrashcan/pgtrashcan.c
@@ -0,0 +1,5031 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgtrashcan.c
+ *	  Intercept DROP TABLE and move the relation to the pgtrashcan
+ *	  schema, allowing later restore (flashback) or explicit purge.
+ *
+ * Copyright (c) 2014, Peter Eisentraut
+ * Portions Copyright (c) 2026, pgtrashcan contributors
+ *
+ * IDENTIFICATION
+ *	  contrib/pgtrashcan/pgtrashcan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include <postgres.h>
+#include <access/htup_details.h>
+#include <access/xact.h>
+#include <catalog/namespace.h>
+#include <catalog/pg_database.h>
+#include <catalog/pg_namespace.h>
+#include <catalog/pg_class.h>
+#include <catalog/pg_index.h>
+#include <catalog/indexing.h>
+#include <catalog/pg_depend.h>
+#include <catalog/pg_constraint.h>
+#include <commands/defrem.h>
+#include <commands/tablecmds.h>
+#include <commands/extension.h>
+#include <commands/trigger.h>
+#include <common/hashfn.h>
+#include <executor/spi.h>
+#include <fmgr.h>
+#include <miscadmin.h>
+#include <storage/lwlock.h>
+#include <tcop/utility.h>
+#include <utils/builtins.h>
+#include <utils/lsyscache.h>
+#include <utils/memutils.h>
+#include <utils/rel.h>
+#include <utils/relcache.h>
+#include <utils/syscache.h>
+#include <utils/timestamp.h>
+#include <utils/fmgroids.h>
+#include <utils/guc.h>
+#include <parser/scansup.h>
+
+PG_MODULE_MAGIC;
+
+#define TRASH_SCHEMA "pgtrashcan"
+#define TRASH_CATALOG "_trash_catalog"
+
+/*
+ * Struct types for dynamic lists used in place of fixed-size arrays.
+ * Each wraps information needed to process objects after SPI queries finish.
+ */
+
+/* FK constraint info for drop_foreign_keys_permanently */
+typedef struct FKInfo
+{
+	char *conname;
+	char *schema;
+	char *table;
+} FKInfo;
+
+/* Trigger info for drop_triggers_permanently */
+typedef struct TriggerInfo
+{
+	char *tgname;
+} TriggerInfo;
+
+/* View info for drop_views_permanently and recursive view detection */
+typedef struct ViewInfo
+{
+	Oid   oid;
+	char *schema;
+	char *name;
+	char  relkind;
+	char *depends_on;  /* name of parent view (NULL if depends directly on table) */
+} ViewInfo;
+
+/* Rule info for drop_rules_permanently */
+typedef struct RuleInfo
+{
+	char *rulename;
+} RuleInfo;
+
+/* Policy info for drop_policies_permanently */
+typedef struct PolicyInfo
+{
+	char *polname;
+} PolicyInfo;
+
+/* Dependent object info for move_dependent_objects_cascade */
+typedef struct DepInfo
+{
+	Oid   oid;
+	char  relkind;
+} DepInfo;
+
+/* Object type enum for type-safe internal identification */
+typedef enum TrashObjectType {
+	TRASH_OBJ_TABLE,
+	TRASH_OBJ_INDEX,
+	TRASH_OBJ_SEQUENCE,
+	TRASH_OBJ_VIEW,
+	TRASH_OBJ_MATVIEW
+} TrashObjectType;
+
+static const char *
+trash_obj_type_str(TrashObjectType type)
+{
+	switch (type) {
+		case TRASH_OBJ_TABLE:    return "table";
+		case TRASH_OBJ_INDEX:    return "index";
+		case TRASH_OBJ_SEQUENCE: return "sequence";
+		case TRASH_OBJ_VIEW:     return "view";
+		case TRASH_OBJ_MATVIEW:  return "matview";
+	}
+	return "unknown";
+}
+
+static TrashObjectType
+relkind_to_trash_type(char relkind)
+{
+	switch (relkind) {
+		case 'r': return TRASH_OBJ_TABLE;
+		case 'i': return TRASH_OBJ_INDEX;
+		case 'S': return TRASH_OBJ_SEQUENCE;
+		case 'v': return TRASH_OBJ_VIEW;
+		case 'm': return TRASH_OBJ_MATVIEW;
+		default:
+			elog(ERROR, "unexpected relkind '%c' in relkind_to_trash_type", relkind);
+			return TRASH_OBJ_TABLE;
+	}
+}
+
+static ProcessUtility_hook_type prev_ProcessUtility = NULL;
+
+/* protection flag to prevent reentrancy for DDL operations (ProcessUtility hook bypass) */
+static bool pgtrashcan_in_progress = false;
+
+/* GUC variable to control access to catalog DML operations (enforced by trigger) */
+static bool pgtrashcan_internal_operation = false;
+
+/* GUC variable to toggle the entire trash-can behavior. When false, DROP TABLE
+ * on non-pgtrashcan tables falls through to standard PostgreSQL. pgtrashcan-schema
+ * protections (catalog DML block, DROP SCHEMA block, etc.) remain active. */
+static bool pgtrashcan_enabled = true;
+
+static void pgtrashcan_ProcessUtility(PlannedStmt *pstmt, const char *queryString,
+							          bool readOnlyTree,
+							          ProcessUtilityContext context,
+							          ParamListInfo params,
+							          QueryEnvironment *queryEnv,
+							          DestReceiver *dest,
+							          QueryCompletion *qc);
+
+/* Function Declarations */
+
+/* Public API - Extension Entry Points */
+void _PG_init(void);
+
+/* C trigger function for catalog DML protection */
+PG_FUNCTION_INFO_V1(pgtrashcan_protect_catalog);
+
+/* Schema & Catalog Management */
+static void create_trashcan_schema(void);
+static void ensure_trash_catalog_exists(void);
+static void insert_catalog_entry(Oid relid, const char *orig_schema, const char *orig_name,
+								 TrashObjectType obj_type, Oid dependent_on_oid, Oid cascade_parent_oid, const char *trash_name);
+static void delete_catalog_entry_by_trash_name(const char *trash_name);
+static void update_catalog_with_complete_metadata(const char *trash_name, const char *fk_metadata_json,
+												  const char *trigger_metadata_json, const char *view_metadata_json,
+												  const char *rule_metadata_json, const char *policy_metadata_json);
+
+/* Naming & Transformation */
+static char *compose_trash_name(const char *orig_name, Oid orig_oid);
+static void rename_in_trash(Oid relid, const char *orig_name, const char *trash_name, bool is_index);
+
+/* Foreign Key Handling */
+static char *detect_and_build_fk_metadata_json(Oid relid, const char *orig_schema, const char *orig_name);
+static const char *fk_action_name(char c);
+static void drop_foreign_keys_permanently(Oid relid, List *drop_oids);
+
+/* Trigger Handling */
+static char *detect_and_build_trigger_metadata_json(Oid relid);
+/* Triggers/rules/policies now preserved with table, not dropped */
+
+/* View Handling */
+static char *detect_and_build_view_metadata_json(Oid relid);
+static void drop_views_permanently(Oid relid);
+static List *collect_views_recursive(Oid relid, const char *depends_on_name, List **visited, int depth, MemoryContext caller_ctx);
+
+/* Rule Handling */
+static char *detect_and_build_rule_metadata_json(Oid relid);
+static void drop_rules_permanently(Oid relid);
+
+/* RLS Policy Handling */
+static char *detect_and_build_policy_metadata_json(Oid relid);
+static void drop_policies_permanently(Oid relid);
+
+/* CASCADE Support */
+static void move_dependent_objects_cascade(Oid parent_oid, const char *parent_schema, const char *parent_name);
+static void move_single_object_to_trash(Oid relid, const char *orig_schema, const char *orig_name, char relkind, Oid parent_oid);
+static void check_dependent_objects(Oid relid, const char *relname, const char *nspname, List *drop_oids);
+static void check_incoming_fk_dependencies(Oid relid, const char *relname, const char *nspname, List *drop_oids);
+static bool is_sequence_owned(Oid seq_oid);
+
+/* Helper Functions */
+static char *escape_json_string(const char *str);
+static bool extension_exists(void);
+
+/*
+ * fk_action_name - Decode pg_constraint confdeltype/confupdtype chars
+ * to the human-readable ON DELETE / ON UPDATE action names.
+ */
+static const char *
+fk_action_name(char c)
+{
+	switch (c)
+	{
+		case 'a': return "NO ACTION";
+		case 'r': return "RESTRICT";
+		case 'c': return "CASCADE";
+		case 'n': return "SET NULL";
+		case 'd': return "SET DEFAULT";
+		default:  return "NO ACTION";
+	}
+}
+
+/* pgtrashcan schema name */
+static const char *trashcan_nspname = TRASH_SCHEMA;
+
+/*
+ * escape_json_string - Escape a string for embedding in JSON
+ *
+ * Escapes all characters required by RFC 8259 (JSON spec):
+ * - Backslash -> \\
+ * - Double quote -> \"
+ * - Newline -> \n
+ * - Tab -> \t
+ * - Carriage return -> \r
+ * - Backspace -> \b
+ * - Form feed -> \f
+ * - All other control characters (0x00-0x1F) -> \uXXXX
+ *
+ * Returns a newly palloc'd string with JSON escaping applied.
+ */
+static char *
+escape_json_string(const char *str)
+{
+	StringInfoData buf;
+	const char *p;
+
+	if (str == NULL)
+		return pstrdup("");
+
+	initStringInfo(&buf);
+
+	for (p = str; *p; p++)
+	{
+		unsigned char ch = (unsigned char) *p;
+
+		switch (ch)
+		{
+			case '\\':
+				appendStringInfoString(&buf, "\\\\");
+				break;
+			case '"':
+				appendStringInfoString(&buf, "\\\"");
+				break;
+			case '\n':
+				appendStringInfoString(&buf, "\\n");
+				break;
+			case '\t':
+				appendStringInfoString(&buf, "\\t");
+				break;
+			case '\r':
+				appendStringInfoString(&buf, "\\r");
+				break;
+			case '\b':
+				appendStringInfoString(&buf, "\\b");
+				break;
+			case '\f':
+				appendStringInfoString(&buf, "\\f");
+				break;
+			default:
+				if (ch < 0x20)
+				{
+					/* All other control characters: escape as \u00XX */
+					appendStringInfo(&buf, "\\u%04x", ch);
+				}
+				else
+				{
+					appendStringInfoChar(&buf, *p);
+				}
+				break;
+		}
+	}
+
+	return buf.data;
+}
+
+void
+_PG_init(void)
+{
+	elog(DEBUG1, "pgtrashcan loaded");
+
+	DefineCustomBoolVariable(
+		"pgtrashcan.internal_operation",
+		"Allow internal catalog modifications by pgtrashcan",
+		NULL,
+		&pgtrashcan_internal_operation,
+		false,
+		PGC_SUSET,
+		0,
+		NULL, NULL, NULL);
+
+	DefineCustomBoolVariable(
+		"pgtrashcan.enabled",
+		"Whether DROP TABLE moves tables to pgtrashcan (true) or drops them normally (false)",
+		"When false, DROP TABLE behaves like stock PostgreSQL. pgtrashcan-schema protections remain active.",
+		&pgtrashcan_enabled,
+		true,
+		PGC_USERSET,
+		0,
+		NULL, NULL, NULL);
+
+	prev_ProcessUtility = ProcessUtility_hook;
+	if (!prev_ProcessUtility)
+		prev_ProcessUtility = standard_ProcessUtility;
+	ProcessUtility_hook = pgtrashcan_ProcessUtility;
+}
+
+/*
+ * pgtrashcan_protect_catalog - C trigger function to block direct DML
+ * on _trash_catalog. Checks the pgtrashcan.internal_operation GUC;
+ * if false, raises ERROR. The GUC is PGC_SUSET so only superusers
+ * (or SECURITY DEFINER functions owned by superuser) can set it.
+ */
+Datum
+pgtrashcan_protect_catalog(PG_FUNCTION_ARGS)
+{
+	TriggerData *trigdata = (TriggerData *) fcinfo->context;
+
+	if (!CALLED_AS_TRIGGER(fcinfo))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_FUNCTION_DEFINITION),
+				 errmsg("function \"pgtrashcan_protect_catalog\" must be called as a trigger")));
+
+	if (!pgtrashcan_internal_operation)
+		ereport(ERROR,
+			(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+			 errmsg("direct modification of trash catalog table is not allowed"),
+			 errhint("Use pgtrashcan functions (restore, purge, rename) to manage trash")));
+
+	if (TRIGGER_FIRED_BY_INSERT(trigdata->tg_event))
+		return PointerGetDatum(trigdata->tg_trigtuple);
+	else if (TRIGGER_FIRED_BY_UPDATE(trigdata->tg_event))
+		return PointerGetDatum(trigdata->tg_newtuple);
+	else if (TRIGGER_FIRED_BY_DELETE(trigdata->tg_event))
+		return PointerGetDatum(trigdata->tg_trigtuple);
+	else
+		ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("pgtrashcan_protect_catalog fired for unexpected trigger event")));
+
+	PG_RETURN_NULL();  /* unreachable, added to avoid compiler warning */
+}
+
+
+static RangeVar *
+makeRangeVarFromAnyName(List *names)
+{
+	RangeVar *r = makeNode(RangeVar);
+
+	switch (list_length(names))
+	{
+		case 1:
+			r->catalogname = NULL;
+			r->schemaname = NULL;
+			r->relname = strVal(linitial(names));
+			break;
+		case 2:
+			r->catalogname = NULL;
+			r->schemaname = strVal(linitial(names));
+			r->relname = strVal(lsecond(names));
+			break;
+		case 3:
+			r->catalogname = strVal(linitial(names));
+			r->schemaname = strVal(lsecond(names));
+			r->relname = strVal(lthird(names));
+			break;
+		default:
+			ereport(ERROR,
+					(errcode(ERRCODE_SYNTAX_ERROR),
+					 errmsg("improper qualified name (too many dotted names): %s",
+							NameListToString(names))));
+			break;
+	}
+
+	r->relpersistence = RELPERSISTENCE_PERMANENT;
+	r->location = -1;
+
+	return r;
+}
+
+/*
+ * extension_exists - Check if pgtrashcan extension is installed
+ *
+ * Returns true if the extension exists, false otherwise.
+ * Used to determine if we should protect the pgtrashcan schema.
+ * If extension was dropped, allow schema to be dropped too.
+ */
+static bool
+extension_exists(void)
+{
+	bool exists = false;
+	Oid ext_oid;
+
+	/* Check if pgtrashcan extension exists in pg_extension */
+	ext_oid = get_extension_oid("pgtrashcan", true);
+	exists = OidIsValid(ext_oid);
+
+	elog(DEBUG1, "pgtrashcan: extension_exists() = %s", exists ? "true" : "false");
+	return exists;
+}
+
+/*
+ * create_trashcan_schema - Ensure pgtrashcan schema exists (safety net)
+ *
+ * The schema is normally created by the SQL install script.
+ * This is a no-op if the schema already exists.
+ */
+static void
+create_trashcan_schema(void)
+{
+	HeapTuple   tuple;
+	Oid			datdba;
+
+	if (SearchSysCacheExists1(NAMESPACENAME, PointerGetDatum(trashcan_nspname)))
+		return;
+
+	tuple = SearchSysCache1(DATABASEOID, ObjectIdGetDatum(MyDatabaseId));
+	if (!HeapTupleIsValid(tuple))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_DATABASE),
+				 errmsg("database with OID %u does not exist", MyDatabaseId)));
+
+	datdba = ((Form_pg_database) GETSTRUCT(tuple))->datdba;
+	ReleaseSysCache(tuple);
+
+	NamespaceCreate(trashcan_nspname, datdba, false);
+
+	CommandCounterIncrement();
+}
+
+/*
+ * ensure_trash_catalog_exists - Verify catalog table exists
+ *
+ * The catalog table and schema are created by the SQL install script
+ * (pgtrashcan--1.0.sql). This function just verifies they exist and
+ * raises a clear error if not. No schema duplication.
+ */
+static void
+ensure_trash_catalog_exists(void)
+{
+	Oid schema_oid = get_namespace_oid(TRASH_SCHEMA, true);
+	if (!OidIsValid(schema_oid))
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("trash schema \"%s\" does not exist", TRASH_SCHEMA),
+			 errhint("Run CREATE EXTENSION pgtrashcan to initialize")));
+
+	if (!OidIsValid(get_relname_relid(TRASH_CATALOG, schema_oid)))
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("trash catalog table \"%s\".\"%s\" does not exist", TRASH_SCHEMA, TRASH_CATALOG),
+			 errhint("Run CREATE EXTENSION pgtrashcan to initialize")));
+}
+
+/*
+ * compose_trash_name - Generate unique trash name for the object: trash_$<oid>
+ *
+ * Uses PostgreSQL's truncate_identifier() for proper handling of
+ * multibyte characters and NAMEDATALEN limits.
+ */
+static char *
+compose_trash_name(const char *orig_name, Oid orig_oid)
+{
+	char trash_name[NAMEDATALEN];
+
+	/*
+	 * Format: trash_$<oid>
+	 *
+	 * "trash_$" (7 chars) + max OID "4294967295" (10 chars) = 17 chars max.
+	 * Always well within NAMEDATALEN-1 (63 chars). No truncation ever needed.
+	 *
+	 * OID uniqueness guarantees no trash name collisions.
+	 */
+	snprintf(trash_name, sizeof(trash_name), "trash_$%u", orig_oid);
+
+	elog(DEBUG1, "pgtrashcan: Generated trash_name='%s' from orig_name='%s' oid=%u",
+		 trash_name, orig_name, orig_oid);
+
+	return pstrdup(trash_name);
+}
+
+/*
+ * insert_catalog_entry - Record object in trash catalog
+ *
+ * Parameters:
+ *   relid - OID of the object
+ *   orig_schema - Original schema name
+ *   orig_name - Original object name
+ *   obj_type - Object type (TrashObjectType enum)
+ *   dependent_on_oid - OID of parent for true dependents (indexes, sequences); InvalidOid otherwise
+ *   cascade_parent_oid - OID of parent for CASCADE'd tables; InvalidOid otherwise
+ *   trash_name - Pre-generated trash name
+ */
+static void
+insert_catalog_entry(Oid relid,
+					const char *orig_schema,
+					const char *orig_name,
+					TrashObjectType obj_type,
+					Oid dependent_on_oid,
+					Oid cascade_parent_oid,
+					const char *trash_name)
+{
+	int ret;
+	StringInfoData sql;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in insert_catalog_entry")));
+
+	PG_TRY();
+	{
+		/* Set flags inside PG_TRY so PG_FINALLY always resets them */
+		pgtrashcan_in_progress = true;
+		SetConfigOption("pgtrashcan.internal_operation", "true", PGC_SUSET, PGC_S_SESSION);
+		initStringInfo(&sql);
+
+		/* Insert catalog entry */
+		appendStringInfo(&sql,
+			"INSERT INTO \"%s\".\"%s\" "
+			"(trash_name, orig_name, orig_schema, orig_oid, orig_obj_type, "
+			" drop_time, dropped_by, dependent_on_oid, cascade_parent_oid) "
+			"VALUES (%s, %s, %s, %u, %s, now(), current_user, %s, %s)",
+			TRASH_SCHEMA, TRASH_CATALOG,
+			quote_literal_cstr(trash_name),
+			quote_literal_cstr(orig_name),
+			quote_literal_cstr(orig_schema),
+			relid,
+			quote_literal_cstr(trash_obj_type_str(obj_type)),
+			OidIsValid(dependent_on_oid) ? psprintf("%u", dependent_on_oid) : "NULL",
+			OidIsValid(cascade_parent_oid) ? psprintf("%u", cascade_parent_oid) : "NULL");
+
+		ret = SPI_execute(sql.data, false, 0);
+		if (ret != SPI_OK_INSERT)
+			ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("failed to insert catalog entry for %s (OID %u)",
+							orig_name, relid)));
+
+		elog(DEBUG1, "pgtrashcan: Inserted catalog entry: trash_name=%s, orig_name=%s, type=%s, dependent_on=%u, cascade_parent=%u",
+			 trash_name, orig_name, trash_obj_type_str(obj_type), dependent_on_oid, cascade_parent_oid);
+
+		pfree(sql.data);
+
+		CommandCounterIncrement();
+	}
+	PG_FINALLY();
+	{
+		/* Always clean up SPI and reset flags, even on error */
+		SPI_finish();
+		pgtrashcan_in_progress = false;
+		SetConfigOption("pgtrashcan.internal_operation", "false", PGC_SUSET, PGC_S_SESSION);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * rename_in_trash - Rename object in pgtrashcan schema to trash_name
+ *
+ * Uses RenameRelationInternal() to directly update the catalog,
+ * bypassing the ProcessUtility hook pipeline.
+ */
+static void
+rename_in_trash(Oid relid, const char *orig_name, const char *trash_name, bool is_index)
+{
+	elog(DEBUG1, "pgtrashcan: Renaming '%s' to '%s' in pgtrashcan schema (OID %u)",
+		 orig_name, trash_name, relid);
+
+	RenameRelationInternal(relid, trash_name, true, is_index);
+
+	CommandCounterIncrement();
+}
+
+/*
+ * delete_catalog_entry_by_trash_name - Remove catalog entry
+ *
+ * Used when permanently dropping from pgtrashcan
+ */
+static void
+delete_catalog_entry_by_trash_name(const char *trash_name)
+{
+	int ret;
+	StringInfoData sql;
+	Oid parent_oid = InvalidOid;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed")));
+
+	PG_TRY();
+	{
+		/* Set flags inside PG_TRY so PG_FINALLY always resets them */
+		pgtrashcan_in_progress = true;
+		SetConfigOption("pgtrashcan.internal_operation", "true", PGC_SUSET, PGC_S_SESSION);
+
+		initStringInfo(&sql);
+
+		/* First, get the orig_oid of the table being dropped (to find dependents) */
+		appendStringInfo(&sql,
+			"SELECT orig_oid FROM \"%s\".\"%s\" WHERE trash_name = %s AND orig_obj_type = 'table'",
+			TRASH_SCHEMA, TRASH_CATALOG, quote_literal_cstr(trash_name));
+
+		ret = SPI_execute(sql.data, true, 1);
+		if (ret == SPI_OK_SELECT && SPI_processed > 0)
+		{
+			bool isnull;
+			TupleDesc tupdesc = SPI_tuptable->tupdesc;
+			int fno_orig_oid = SPI_fnumber(tupdesc, "orig_oid");
+			if (fno_orig_oid < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("column 'orig_oid' not found in query result")));
+			parent_oid = DatumGetObjectId(SPI_getbinval(SPI_tuptable->vals[0], tupdesc, fno_orig_oid, &isnull));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("orig_oid is NULL for trash_name '%s'", trash_name)));
+		}
+
+		/* Delete the entry itself and all dependent entries */
+		resetStringInfo(&sql);
+		if (OidIsValid(parent_oid))
+		{
+			/* Delete parent and all dependents */
+			appendStringInfo(&sql,
+				"DELETE FROM \"%s\".\"%s\" WHERE trash_name = %s OR dependent_on_oid = %u",
+				TRASH_SCHEMA, TRASH_CATALOG, quote_literal_cstr(trash_name), parent_oid);
+		}
+		else
+		{
+			/* Just delete the single entry (might be an index or sequence) */
+			appendStringInfo(&sql,
+				"DELETE FROM \"%s\".\"%s\" WHERE trash_name = %s",
+				TRASH_SCHEMA, TRASH_CATALOG, quote_literal_cstr(trash_name));
+		}
+
+		ret = SPI_execute(sql.data, false, 0);
+		if (ret != SPI_OK_DELETE)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to delete catalog entry for %s", trash_name)));
+
+		elog(DEBUG1, "pgtrashcan: Deleted catalog entry for trash_name=%s (and dependents)", trash_name);
+
+		pfree(sql.data);
+		CommandCounterIncrement();
+	}
+	PG_FINALLY();
+	{
+		/* Always clean up SPI and reset flags, even on error */
+		SPI_finish();
+		pgtrashcan_in_progress = false;
+		SetConfigOption("pgtrashcan.internal_operation", "false", PGC_SUSET, PGC_S_SESSION);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * detect_and_build_fk_metadata_json - Detect all FK constraints and build JSON metadata
+ *
+ * Queries pg_constraint for:
+ * - Outgoing FKs (where conrelid = relid): This table references others
+ * - Incoming FKs (where confrelid = relid): Other tables reference this table
+ *
+ * Returns a JSON string with FK definitions for storage in catalog metadata column
+ */
+static char *
+detect_and_build_fk_metadata_json(Oid relid, const char *orig_schema, const char *orig_name)
+{
+	StringInfoData json;
+	StringInfoData sql;
+	int ret;
+	bool first_fk = true;
+	SPITupleTable *tuptable;
+	int i;
+
+	initStringInfo(&json);
+	initStringInfo(&sql);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in detect_and_build_fk_metadata_json")));
+
+	PG_TRY();
+	{
+		appendStringInfoString(&json, "{\"foreign_keys_dropped\": [");
+
+		/*
+		 * Outgoing FKs — constraints owned by this table that reference another table.
+		 * The ALTER TABLE target in the reconstructed DDL is the trashed table itself
+		 * (orig_schema.orig_name), passed in by the caller.
+		 */
+		appendStringInfo(&sql,
+			"SELECT c.conname, "
+			"  n.nspname AS ref_schema, cl.relname AS ref_table, "
+			"  c.confdeltype, c.confupdtype, c.condeferrable, c.condeferred, "
+			"  (SELECT COALESCE(array_to_json(array_agg(a.attname ORDER BY k.ord))::text, '[]') "
+			"     FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) "
+			"     JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS cols_json, "
+			"  (SELECT COALESCE(array_to_json(array_agg(a.attname ORDER BY k.ord))::text, '[]') "
+			"     FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord) "
+			"     JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS refcols_json, "
+			"  pg_get_constraintdef(c.oid, true) AS defclause "
+			"FROM pg_constraint c "
+			"JOIN pg_class cl ON c.confrelid = cl.oid "
+			"JOIN pg_namespace n ON cl.relnamespace = n.oid "
+			"WHERE c.conrelid = %u AND c.contype = 'f'", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query outgoing foreign keys")));
+
+		tuptable = SPI_tuptable;
+		for (i = 0; i < SPI_processed; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			char *conname, *ref_schema, *ref_table;
+			char *cols_json, *refcols_json, *defclause;
+			char confdeltype, confupdtype;
+			bool condeferrable, condeferred;
+			StringInfoData ddl;
+
+			if (!first_fk)
+				appendStringInfoString(&json, ", ");
+			first_fk = false;
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "conname"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("conname is NULL in FK query")));
+			conname = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "ref_schema"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("ref_schema is NULL in FK query")));
+			ref_schema = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "ref_table"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("ref_table is NULL in FK query")));
+			ref_table = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "confdeltype"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("confdeltype is NULL in FK query")));
+			confdeltype = DatumGetChar(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "confupdtype"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("confupdtype is NULL in FK query")));
+			confupdtype = DatumGetChar(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "condeferrable"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("condeferrable is NULL in FK query")));
+			condeferrable = DatumGetBool(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "condeferred"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("condeferred is NULL in FK query")));
+			condeferred = DatumGetBool(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "cols_json"), &isnull);
+			cols_json = isnull ? pstrdup("[]") : TextDatumGetCString(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "refcols_json"), &isnull);
+			refcols_json = isnull ? pstrdup("[]") : TextDatumGetCString(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "defclause"), &isnull);
+			defclause = isnull ? pstrdup("") : TextDatumGetCString(datum);
+
+			/* Build the runnable DDL: ALTER TABLE <orig_schema>.<orig_name> ADD CONSTRAINT <conname> <defclause>; */
+			initStringInfo(&ddl);
+			appendStringInfo(&ddl, "ALTER TABLE %s.%s ADD CONSTRAINT %s %s;",
+				quote_identifier(orig_schema),
+				quote_identifier(orig_name),
+				quote_identifier(conname),
+				defclause);
+
+			{
+				char *esc_conname = escape_json_string(conname);
+				char *esc_ref_schema = escape_json_string(ref_schema);
+				char *esc_ref_table = escape_json_string(ref_table);
+				char *esc_ddl = escape_json_string(ddl.data);
+
+				appendStringInfo(&json,
+					"{\"constraint_name\": \"%s\", "
+					"\"constraint_type\": \"outgoing\", "
+					"\"referenced_schema\": \"%s\", "
+					"\"referenced_table\": \"%s\", "
+					"\"columns\": %s, "
+					"\"referenced_columns\": %s, "
+					"\"on_delete\": \"%s\", "
+					"\"on_update\": \"%s\", "
+					"\"deferrable\": %s, "
+					"\"deferred\": %s, "
+					"\"ddl\": \"%s\"}",
+					esc_conname,
+					esc_ref_schema,
+					esc_ref_table,
+					cols_json,
+					refcols_json,
+					fk_action_name(confdeltype),
+					fk_action_name(confupdtype),
+					condeferrable ? "true" : "false",
+					condeferred ? "true" : "false",
+					esc_ddl);
+
+				pfree(esc_conname);
+				pfree(esc_ref_schema);
+				pfree(esc_ref_table);
+				pfree(esc_ddl);
+			}
+
+			pfree(ddl.data);
+			pfree(cols_json);
+			pfree(refcols_json);
+			pfree(defclause);
+		}
+
+		/*
+		 * Incoming FKs — constraints on OTHER tables that reference this table.
+		 * Self-referencing FKs are already captured above as outgoing; exclude them here.
+		 * The ALTER TABLE target in the reconstructed DDL is the source (other) table.
+		 */
+		resetStringInfo(&sql);
+		appendStringInfo(&sql,
+			"SELECT c.conname, "
+			"  sn.nspname AS source_schema, scl.relname AS source_table, "
+			"  rn.nspname AS ref_schema,    rcl.relname AS ref_table, "
+			"  c.confdeltype, c.confupdtype, c.condeferrable, c.condeferred, "
+			"  (SELECT COALESCE(array_to_json(array_agg(a.attname ORDER BY k.ord))::text, '[]') "
+			"     FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) "
+			"     JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS cols_json, "
+			"  (SELECT COALESCE(array_to_json(array_agg(a.attname ORDER BY k.ord))::text, '[]') "
+			"     FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord) "
+			"     JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS refcols_json, "
+			"  pg_get_constraintdef(c.oid, true) AS defclause "
+			"FROM pg_constraint c "
+			"JOIN pg_class scl  ON c.conrelid  = scl.oid "
+			"JOIN pg_namespace sn ON scl.relnamespace = sn.oid "
+			"JOIN pg_class rcl  ON c.confrelid = rcl.oid "
+			"JOIN pg_namespace rn ON rcl.relnamespace = rn.oid "
+			"WHERE c.confrelid = %u AND c.contype = 'f' AND c.conrelid != c.confrelid", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query incoming foreign keys")));
+
+		tuptable = SPI_tuptable;
+		for (i = 0; i < SPI_processed; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			char *conname, *source_schema, *source_table, *ref_schema, *ref_table;
+			char *cols_json, *refcols_json, *defclause;
+			char confdeltype, confupdtype;
+			bool condeferrable, condeferred;
+			StringInfoData ddl;
+
+			if (!first_fk)
+				appendStringInfoString(&json, ", ");
+			first_fk = false;
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "conname"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("conname is NULL in incoming FK query")));
+			conname = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "source_schema"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("source_schema is NULL in incoming FK query")));
+			source_schema = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "source_table"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("source_table is NULL in incoming FK query")));
+			source_table = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "ref_schema"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("ref_schema is NULL in incoming FK query")));
+			ref_schema = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "ref_table"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("ref_table is NULL in incoming FK query")));
+			ref_table = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "confdeltype"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("confdeltype is NULL in incoming FK query")));
+			confdeltype = DatumGetChar(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "confupdtype"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("confupdtype is NULL in incoming FK query")));
+			confupdtype = DatumGetChar(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "condeferrable"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("condeferrable is NULL in incoming FK query")));
+			condeferrable = DatumGetBool(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "condeferred"), &isnull);
+			if (isnull)
+				ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("condeferred is NULL in incoming FK query")));
+			condeferred = DatumGetBool(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "cols_json"), &isnull);
+			cols_json = isnull ? pstrdup("[]") : TextDatumGetCString(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "refcols_json"), &isnull);
+			refcols_json = isnull ? pstrdup("[]") : TextDatumGetCString(datum);
+
+			datum = SPI_getbinval(tuple, tupdesc, SPI_fnumber(tupdesc, "defclause"), &isnull);
+			defclause = isnull ? pstrdup("") : TextDatumGetCString(datum);
+
+			/* ALTER TABLE target = source (live) table that owns the FK. */
+			initStringInfo(&ddl);
+			appendStringInfo(&ddl, "ALTER TABLE %s.%s ADD CONSTRAINT %s %s;",
+				quote_identifier(source_schema),
+				quote_identifier(source_table),
+				quote_identifier(conname),
+				defclause);
+
+			{
+				char *esc_conname = escape_json_string(conname);
+				char *esc_source_schema = escape_json_string(source_schema);
+				char *esc_source_table = escape_json_string(source_table);
+				char *esc_ref_schema = escape_json_string(ref_schema);
+				char *esc_ref_table = escape_json_string(ref_table);
+				char *esc_ddl = escape_json_string(ddl.data);
+
+				appendStringInfo(&json,
+					"{\"constraint_name\": \"%s\", "
+					"\"constraint_type\": \"incoming\", "
+					"\"source_schema\": \"%s\", "
+					"\"source_table\": \"%s\", "
+					"\"referenced_schema\": \"%s\", "
+					"\"referenced_table\": \"%s\", "
+					"\"columns\": %s, "
+					"\"referenced_columns\": %s, "
+					"\"on_delete\": \"%s\", "
+					"\"on_update\": \"%s\", "
+					"\"deferrable\": %s, "
+					"\"deferred\": %s, "
+					"\"ddl\": \"%s\"}",
+					esc_conname,
+					esc_source_schema,
+					esc_source_table,
+					esc_ref_schema,
+					esc_ref_table,
+					cols_json,
+					refcols_json,
+					fk_action_name(confdeltype),
+					fk_action_name(confupdtype),
+					condeferrable ? "true" : "false",
+					condeferred ? "true" : "false",
+					esc_ddl);
+
+				pfree(esc_conname);
+				pfree(esc_source_schema);
+				pfree(esc_source_table);
+				pfree(esc_ref_schema);
+				pfree(esc_ref_table);
+				pfree(esc_ddl);
+			}
+
+			pfree(ddl.data);
+			pfree(cols_json);
+			pfree(refcols_json);
+			pfree(defclause);
+		}
+
+		appendStringInfoString(&json, "]}");
+
+		pfree(sql.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	return json.data;
+}
+
+/*
+ * check_incoming_fk_dependencies - Check for incoming FK constraints and error if found
+ *
+ * This function enforces PostgreSQL's standard dependency checking behavior.
+ * If other tables have FKs referencing this table, the drop should fail
+ * unless CASCADE is specified.
+ *
+ * Parameters:
+ *   drop_oids - List of OIDs being dropped in the same DROP statement
+ *               These are excluded from dependency checks (PostgreSQL behavior)
+ */
+static void
+check_incoming_fk_dependencies(Oid relid, const char *relname, const char *nspname, List *drop_oids)
+{
+	StringInfoData sql;
+	StringInfoData exclusion;
+	int ret;
+	int incoming_count;
+	StringInfoData detail_msg;
+
+	initStringInfo(&sql);
+	initStringInfo(&exclusion);
+	initStringInfo(&detail_msg);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in check_incoming_fk_dependencies")));
+
+	PG_TRY();
+	{
+		/*
+		 * Build exclusion clause for tables in the same DROP statement
+		 * PostgreSQL allows DROP TABLE parent, child even when child has FK to parent
+		 */
+		if (drop_oids != NIL)
+		{
+			ListCell *lc;
+			bool first = true;
+
+			appendStringInfoString(&exclusion, " AND c.conrelid NOT IN (");
+			foreach(lc, drop_oids)
+			{
+				if (!first)
+					appendStringInfoString(&exclusion, ", ");
+				appendStringInfo(&exclusion, "%u", lfirst_oid(lc));
+				first = false;
+			}
+			appendStringInfoString(&exclusion, ")");
+		}
+
+		/*
+		 * Check for incoming FKs (other tables reference this table)
+		 * Exclude:
+		 * - Self-referencing FKs - those are internal to the table being dropped
+		 * - Tables in the same DROP statement (drop_oids list)
+		 */
+		appendStringInfo(&sql,
+			"SELECT c.conname, n.nspname || '.' || cl.relname AS referencing_table "
+			"FROM pg_constraint c "
+			"JOIN pg_class cl ON c.conrelid = cl.oid "
+			"JOIN pg_namespace n ON cl.relnamespace = n.oid "
+			"WHERE c.confrelid = %u AND c.contype = 'f' AND c.conrelid != c.confrelid "
+			"%s "  /* Exclusion clause */
+			"LIMIT 5", relid, exclusion.data);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query incoming foreign keys")));
+
+		incoming_count = SPI_processed;
+
+		if (incoming_count > 0)
+		{
+			SPITupleTable *tuptable = SPI_tuptable;
+			int i;
+
+			/* Build detailed error message listing the dependent constraints */
+			for (i = 0; i < incoming_count; i++)
+			{
+				HeapTuple tuple = tuptable->vals[i];
+				TupleDesc tupdesc = tuptable->tupdesc;
+				bool isnull;
+				char *conname;
+				char *ref_table;
+				Datum datum;
+				int fno_conname, fno_referencing_table;
+
+				/* Get column numbers */
+				fno_conname = SPI_fnumber(tupdesc, "conname");
+				fno_referencing_table = SPI_fnumber(tupdesc, "referencing_table");
+
+				if (fno_conname < 0)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("column 'conname' not found in FK dependency check")));
+				if (fno_referencing_table < 0)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("column 'referencing_table' not found in FK dependency check")));
+
+				/* Get constraint name (NAME type) */
+				datum = SPI_getbinval(tuple, tupdesc, fno_conname, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("conname is NULL in FK dependency check")));
+				conname = NameStr(*DatumGetName(datum));
+
+				/* Get referencing table (TEXT type - concatenated string) */
+				datum = SPI_getbinval(tuple, tupdesc, fno_referencing_table, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("referencing_table is NULL in FK dependency check")));
+				ref_table = TextDatumGetCString(datum);
+
+				if (i > 0)
+					appendStringInfoString(&detail_msg, "\n");
+				appendStringInfo(&detail_msg, "constraint %s on table %s depends on table %s.%s",
+								 conname, ref_table, nspname, relname);
+
+				pfree(ref_table);  /* Free the TextDatumGetCString allocation */
+			}
+
+			/*
+			 * Raise error - PG_FINALLY will call SPI_finish() before the error
+			 * propagates to the caller.
+			 */
+			ereport(ERROR,
+					(errcode(ERRCODE_DEPENDENT_OBJECTS_STILL_EXIST),
+					 errmsg("cannot drop table %s.%s because other objects depend on it",
+							nspname, relname),
+					 errdetail("%s", detail_msg.data),
+					 errhint("Use DROP ... CASCADE to drop the dependent objects too.")));
+		}
+
+		pfree(sql.data);
+		pfree(exclusion.data);
+		pfree(detail_msg.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+}
+
+/*
+ * check_dependent_objects - Check for any dependent objects and error if found
+ *
+ * This function enforces PostgreSQL's standard dependency checking behavior.
+ * If DROP RESTRICT is specified and there are dependent objects (views, matviews,
+ * tables with FKs, sequences), the drop should fail with an error message.
+ *
+ * This is called before moving the table to trash to match PostgreSQL's behavior.
+ *
+ * Parameters:
+ *   drop_oids - List of OIDs being dropped in the same DROP statement
+ *               These are excluded from dependency checks (PostgreSQL behavior)
+ */
+static void
+check_dependent_objects(Oid relid, const char *relname, const char *nspname, List *drop_oids)
+{
+	StringInfoData sql;
+	StringInfoData exclusion;
+	int ret;
+	int dependent_count;
+	StringInfoData detail_msg;
+	SPITupleTable *tuptable;
+	int i;
+
+	initStringInfo(&sql);
+	initStringInfo(&exclusion);
+	initStringInfo(&detail_msg);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in check_dependent_objects")));
+
+	PG_TRY();
+	{
+		/*
+		 * Build exclusion clause for tables in the same DROP statement
+		 * PostgreSQL allows DROP TABLE parent, child even when child has FK to parent
+		 */
+		if (drop_oids != NIL)
+		{
+			ListCell *lc;
+			bool first = true;
+
+			appendStringInfoString(&exclusion, " AND c.oid NOT IN (");
+			foreach(lc, drop_oids)
+			{
+				if (!first)
+					appendStringInfoString(&exclusion, ", ");
+				appendStringInfo(&exclusion, "%u", lfirst_oid(lc));
+				first = false;
+			}
+			appendStringInfoString(&exclusion, ")");
+		}
+
+		/*
+		 * Check for all dependent objects (views, matviews, tables, sequences)
+		 * Exclude:
+		 * - Indexes (they move with tables automatically)
+		 * - Self-dependencies
+		 * - Objects already in pgtrashcan schema
+		 * - System objects
+		 * - Auto dependencies (deptype 'a') - these are OWNED BY relationships (e.g., SERIAL sequences)
+		 *   that are automatically dropped/moved with the table
+		 * - Tables in the same DROP statement (drop_oids list)
+		 *
+		 * Note: We only check for normal dependencies (deptype 'n') which require CASCADE.
+		 * Views have dependencies via pg_rewrite, so we need a UNION query:
+		 * 1. Direct dependencies (tables, etc.)
+		 * 2. View dependencies via pg_rewrite rules
+		 */
+		appendStringInfo(&sql,
+			"SELECT DISTINCT c.relkind, n.nspname, c.relname, "
+			"  CASE c.relkind "
+			"    WHEN 'v' THEN 'view' "
+			"    WHEN 'm' THEN 'materialized view' "
+			"    WHEN 'r' THEN 'table' "
+			"    WHEN 'S' THEN 'sequence' "
+			"    ELSE 'unknown' END AS obj_type "
+			"FROM pg_depend d "
+			"JOIN pg_class c ON c.oid = d.objid "
+			"JOIN pg_namespace n ON n.oid = c.relnamespace "
+			"WHERE d.refobjid = %u "
+			"  AND d.deptype = 'n' "  /* Only normal dependencies, not auto ('a') or internal ('i') */
+			"  AND c.relkind IN ('r', 'v', 'm', 'S') "
+			"  AND c.oid != %u "
+			"  AND c.oid >= 16384 "
+			"  AND n.nspname != 'pgtrashcan' "
+			"  %s "  /* Exclusion clause */
+			"UNION "
+			"SELECT DISTINCT c.relkind, n.nspname, c.relname, "
+			"  CASE c.relkind "
+			"    WHEN 'v' THEN 'view' "
+			"    WHEN 'm' THEN 'materialized view' "
+			"    ELSE 'unknown' END AS obj_type "
+			"FROM pg_depend d "
+			"JOIN pg_rewrite rw ON rw.oid = d.objid "
+			"JOIN pg_class c ON c.oid = rw.ev_class "
+			"JOIN pg_namespace n ON n.oid = c.relnamespace "
+			"WHERE d.refobjid = %u "
+			"  AND d.deptype = 'n' "  /* Only normal dependencies */
+			"  AND c.relkind IN ('v', 'm') "
+			"  AND c.oid != %u "
+			"  AND c.oid >= 16384 "
+			"  AND n.nspname != 'pgtrashcan' "
+			"  %s "  /* Exclusion clause */
+			"LIMIT 5", relid, relid, exclusion.data, relid, relid, exclusion.data);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query dependent objects")));
+
+		dependent_count = SPI_processed;
+
+		elog(DEBUG1, "pgtrashcan: check_dependent_objects for %s.%s (OID %u) found %d dependents",
+			 nspname, relname, relid, dependent_count);
+
+		if (dependent_count > 0)
+		{
+			tuptable = SPI_tuptable;
+
+			/* Build detailed error message listing the dependent objects */
+			for (i = 0; i < dependent_count; i++)
+			{
+				HeapTuple tuple = tuptable->vals[i];
+				TupleDesc tupdesc = tuptable->tupdesc;
+				bool isnull;
+				char *dep_schema;
+				char *dep_name;
+				char *dep_type;
+				Datum datum;
+				int fno_nspname, fno_relname, fno_obj_type;
+
+				/* Get column numbers */
+				fno_nspname = SPI_fnumber(tupdesc, "nspname");
+				fno_relname = SPI_fnumber(tupdesc, "relname");
+				fno_obj_type = SPI_fnumber(tupdesc, "obj_type");
+
+				if (fno_nspname < 0 || fno_relname < 0 || fno_obj_type < 0)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("required column not found in dependent objects query")));
+
+				/* Get dependent object schema */
+				datum = SPI_getbinval(tuple, tupdesc, fno_nspname, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("nspname is NULL in dependent objects query")));
+				dep_schema = NameStr(*DatumGetName(datum));
+
+				/* Get dependent object name */
+				datum = SPI_getbinval(tuple, tupdesc, fno_relname, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("relname is NULL in dependent objects query")));
+				dep_name = NameStr(*DatumGetName(datum));
+
+				/* Get dependent object type */
+				datum = SPI_getbinval(tuple, tupdesc, fno_obj_type, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("obj_type is NULL in dependent objects query")));
+				dep_type = TextDatumGetCString(datum);
+
+				if (i > 0)
+					appendStringInfoString(&detail_msg, "\n");
+				appendStringInfo(&detail_msg, "%s %s.%s depends on table %s.%s",
+								 dep_type, dep_schema, dep_name, nspname, relname);
+
+				pfree(dep_type);  /* Free the TextDatumGetCString allocation */
+			}
+
+			/*
+			 * Raise error - PG_FINALLY will call SPI_finish() before the error
+			 * propagates to the caller.
+			 */
+			ereport(ERROR,
+					(errcode(ERRCODE_DEPENDENT_OBJECTS_STILL_EXIST),
+					 errmsg("cannot drop table %s.%s because other objects depend on it",
+							nspname, relname),
+					 errdetail("%s", detail_msg.data),
+					 errhint("Use DROP ... CASCADE to drop the dependent objects too.")));
+		}
+
+		pfree(sql.data);
+		pfree(exclusion.data);
+		pfree(detail_msg.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+}
+
+
+/*
+ * drop_foreign_keys_permanently - Drop all FK constraints permanently before moving to pgtrashcan
+ *
+ * Drops both:
+ * - Outgoing FKs (this table references others)
+ * - Incoming FKs (other tables reference this table)
+ *
+ * Parameters:
+ *   drop_oids - List of OIDs being dropped in the same DROP statement
+ *               Incoming FKs from these tables are skipped (they'll be handled when that table is processed)
+ *
+ * Uses catalog deletion for cleanliness
+ */
+static void
+drop_foreign_keys_permanently(Oid relid, List *drop_oids)
+{
+	StringInfoData sql;
+	StringInfoData drop_cmd;
+	StringInfoData exclusion;
+	int ret;
+	SPITupleTable *tuptable;
+	int i;
+	int fk_count = 0;
+	int outgoing_count = 0;
+	int incoming_count = 0;
+	char *relname;
+	char *nspname;
+	MemoryContext caller_ctx;
+
+	/* Dynamic lists for FK info before we start dropping them */
+	List *outgoing_fks = NIL;
+	List *incoming_fks = NIL;
+
+	initStringInfo(&sql);
+	initStringInfo(&drop_cmd);
+	initStringInfo(&exclusion);
+
+	/* Get table name and schema name for DROP CONSTRAINT commands */
+	relname = get_rel_name(relid);
+	nspname = get_namespace_name(get_rel_namespace(relid));
+
+	if (!relname || !nspname)
+	{
+		elog(DEBUG1, "pgtrashcan: Table OID %u no longer exists, skipping FK drop", relid);
+		return;
+	}
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in drop_foreign_keys_permanently")));
+
+	PG_TRY();
+	{
+		/*
+		 * STEP 1: Collect all outgoing FK information
+		 * (this table has FK to another table, including self-referencing)
+		 */
+		appendStringInfo(&sql,
+			"SELECT c.oid, c.conname "
+			"FROM pg_constraint c "
+			"WHERE c.conrelid = %u AND c.contype = 'f'", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query outgoing foreign keys for deletion")));
+
+		tuptable = SPI_tuptable;
+		outgoing_count = SPI_processed;
+
+		for (i = 0; i < outgoing_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			char *conname;
+			int fno_conname;
+			FKInfo *fkinfo;
+
+			fno_conname = SPI_fnumber(tupdesc, "conname");
+			if (fno_conname < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("column 'conname' not found in outgoing FK query")));
+
+			conname = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_conname, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("conname is NULL in outgoing FK collection")));
+
+			/* Allocate in caller's context so data survives SPI_finish */
+			{
+				MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+				fkinfo = palloc(sizeof(FKInfo));
+				fkinfo->conname = pstrdup(conname);
+				fkinfo->schema = pstrdup(nspname);
+				fkinfo->table = pstrdup(relname);
+				outgoing_fks = lappend(outgoing_fks, fkinfo);
+				MemoryContextSwitchTo(spi_ctx);
+			}
+		}
+
+		/*
+		 * STEP 2: Collect all incoming FK information
+		 * (other tables reference this table)
+		 * Exclude:
+		 * - Self-referencing FKs - they're already in outgoing_fks
+		 * - Tables already in pgtrashcan schema (they were handled by CASCADE)
+		 * - Tables in the same DROP statement (drop_oids) - they'll be processed when we move that table
+		 */
+
+		/* Build exclusion clause for tables in the same DROP statement */
+		if (drop_oids != NIL)
+		{
+			ListCell *lc;
+			bool first = true;
+
+			appendStringInfoString(&exclusion, " AND c.conrelid NOT IN (");
+			foreach(lc, drop_oids)
+			{
+				if (!first)
+					appendStringInfoString(&exclusion, ", ");
+				appendStringInfo(&exclusion, "%u", lfirst_oid(lc));
+				first = false;
+			}
+			appendStringInfoString(&exclusion, ")");
+
+			elog(DEBUG1, "pgtrashcan: Excluding incoming FKs from tables in DROP list: %s", exclusion.data);
+		}
+
+		resetStringInfo(&sql);
+		appendStringInfo(&sql,
+			"SELECT c.oid, c.conname, n.nspname AS source_schema, cl.relname AS source_table "
+			"FROM pg_constraint c "
+			"JOIN pg_class cl ON c.conrelid = cl.oid "
+			"JOIN pg_namespace n ON cl.relnamespace = n.oid "
+			"WHERE c.confrelid = %u AND c.contype = 'f' AND c.conrelid != c.confrelid "
+			"AND n.nspname != 'pgtrashcan' "
+			"%s", relid, exclusion.data);  /* Add exclusion clause */
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query incoming foreign keys for deletion")));
+
+		tuptable = SPI_tuptable;
+		incoming_count = SPI_processed;
+
+		for (i = 0; i < incoming_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			char *conname;
+			char *source_schema;
+			char *source_table;
+			int fno_conname, fno_source_schema, fno_source_table;
+			FKInfo *fkinfo;
+
+			fno_conname = SPI_fnumber(tupdesc, "conname");
+			fno_source_schema = SPI_fnumber(tupdesc, "source_schema");
+			fno_source_table = SPI_fnumber(tupdesc, "source_table");
+
+			if (fno_conname < 0 || fno_source_schema < 0 || fno_source_table < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("required column not found in incoming FK collection query")));
+
+			conname = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_conname, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("conname is NULL in incoming FK collection")));
+
+			source_schema = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_source_schema, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("source_schema is NULL in incoming FK collection")));
+
+			source_table = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_source_table, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("source_table is NULL in incoming FK collection")));
+
+			/* Allocate in caller's context so data survives SPI_finish */
+			{
+				MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+				fkinfo = palloc(sizeof(FKInfo));
+				fkinfo->conname = pstrdup(conname);
+				fkinfo->schema = pstrdup(source_schema);
+				fkinfo->table = pstrdup(source_table);
+				incoming_fks = lappend(incoming_fks, fkinfo);
+				MemoryContextSwitchTo(spi_ctx);
+			}
+		}
+
+		/*
+		 * STEP 3: Now drop all outgoing FKs
+		 * At this point we've collected all info, safe to execute DDL
+		 */
+		{
+			ListCell *lc;
+			foreach(lc, outgoing_fks)
+			{
+				FKInfo *fkinfo = (FKInfo *) lfirst(lc);
+
+				elog(DEBUG1, "pgtrashcan: Dropping outgoing FK constraint: %s from %s.%s",
+					 fkinfo->conname, fkinfo->schema, fkinfo->table);
+
+				resetStringInfo(&drop_cmd);
+				appendStringInfo(&drop_cmd, "ALTER TABLE %s.%s DROP CONSTRAINT %s",
+								 quote_identifier(fkinfo->schema),
+								 quote_identifier(fkinfo->table),
+								 quote_identifier(fkinfo->conname));
+
+				ret = SPI_execute(drop_cmd.data, false, 0);
+				if (ret != SPI_OK_UTILITY)
+					ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("pgtrashcan: Failed to drop outgoing FK constraint %s", fkinfo->conname),
+							 errhint("Cannot move table to trash if FK constraints cannot be dropped")));
+
+				fk_count++;
+			}
+		}
+
+		/*
+		 * STEP 4: Drop all incoming FKs
+		 * Skip if the source table no longer exists (was moved by CASCADE)
+		 */
+		{
+			ListCell *lc;
+			foreach(lc, incoming_fks)
+			{
+				FKInfo *fkinfo = (FKInfo *) lfirst(lc);
+				Oid source_relid;
+
+				/*
+				 * Check if the source table still exists in its original schema
+				 * If it was moved to pgtrashcan by CASCADE, skip this FK (already handled)
+				 */
+				source_relid = get_relname_relid(fkinfo->table,
+												 get_namespace_oid(fkinfo->schema, true));
+				if (!OidIsValid(source_relid))
+				{
+					elog(DEBUG1, "pgtrashcan: Skipping incoming FK %s - source table %s.%s no longer exists (moved by CASCADE)",
+						 fkinfo->conname, fkinfo->schema, fkinfo->table);
+					continue;
+				}
+
+				elog(DEBUG1, "pgtrashcan: Dropping incoming FK constraint: %s from table %s.%s",
+					 fkinfo->conname, fkinfo->schema, fkinfo->table);
+
+				resetStringInfo(&drop_cmd);
+				appendStringInfo(&drop_cmd, "ALTER TABLE %s.%s DROP CONSTRAINT %s",
+								 quote_identifier(fkinfo->schema),
+								 quote_identifier(fkinfo->table),
+								 quote_identifier(fkinfo->conname));
+
+				ret = SPI_execute(drop_cmd.data, false, 0);
+				if (ret != SPI_OK_UTILITY)
+					ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("pgtrashcan: Failed to drop incoming FK constraint %s from %s.%s",
+									fkinfo->conname, fkinfo->schema, fkinfo->table),
+							 errhint("Cannot move table to trash if FK constraints cannot be dropped")));
+
+				fk_count++;
+			}
+		}
+
+		/* Done with SPI - list data is in caller's memory context, safe to finish */
+		pfree(sql.data);
+		pfree(drop_cmd.data);
+		pfree(exclusion.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	if (fk_count > 0)
+	{
+		StringInfoData fk_names;
+		ListCell *lc;
+		bool first;
+
+		initStringInfo(&fk_names);
+
+		/* Build list of FK names */
+		first = true;
+		foreach(lc, outgoing_fks)
+		{
+			FKInfo *fkinfo = (FKInfo *) lfirst(lc);
+			if (!first)
+				appendStringInfoString(&fk_names, ", ");
+			appendStringInfo(&fk_names, "%s", fkinfo->conname);
+			first = false;
+		}
+		foreach(lc, incoming_fks)
+		{
+			FKInfo *fkinfo = (FKInfo *) lfirst(lc);
+			if (!first)
+				appendStringInfoString(&fk_names, ", ");
+			appendStringInfo(&fk_names, "%s (from %s.%s)",
+				fkinfo->conname,
+				fkinfo->schema,
+				fkinfo->table);
+			first = false;
+		}
+
+		elog(NOTICE, "%d foreign key constraint(s) permanently dropped (cannot be moved to trash): %s",
+			fk_count, fk_names.data);
+		elog(NOTICE, "Foreign key definitions stored in pgtrashcan._trash_catalog.metadata");
+
+		pfree(fk_names.data);
+	}
+
+	/* Free allocated memory for FK info lists */
+	{
+		ListCell *lc;
+		foreach(lc, outgoing_fks)
+		{
+			FKInfo *fkinfo = (FKInfo *) lfirst(lc);
+			pfree(fkinfo->conname);
+			pfree(fkinfo->schema);
+			pfree(fkinfo->table);
+		}
+		list_free_deep(outgoing_fks);
+		foreach(lc, incoming_fks)
+		{
+			FKInfo *fkinfo = (FKInfo *) lfirst(lc);
+			pfree(fkinfo->conname);
+			pfree(fkinfo->schema);
+			pfree(fkinfo->table);
+		}
+		list_free_deep(incoming_fks);
+	}
+
+	/* Free table/schema names */
+	pfree(relname);
+	pfree(nspname);
+}
+
+/*
+ * update_catalog_with_complete_metadata - Update the catalog row with all metadata types
+ *
+ * Merges FK, trigger, view, rule, and policy metadata into a single JSON object and stores it
+ * Result: {"foreign_keys_dropped": [...], "triggers_dropped": [...], "views_dropped": [...], "rules_dropped": [...], "policies_dropped": [...]}
+ */
+static void
+update_catalog_with_complete_metadata(const char *trash_name, const char *fk_metadata_json, const char *trigger_metadata_json, const char *view_metadata_json, const char *rule_metadata_json, const char *policy_metadata_json)
+{
+	StringInfoData sql;
+	StringInfoData merged_json;
+	int ret;
+	bool has_fk = (fk_metadata_json && strlen(fk_metadata_json) > 0);
+	bool has_trigger = (trigger_metadata_json && strlen(trigger_metadata_json) > 0);
+	bool has_view = (view_metadata_json && strlen(view_metadata_json) > 0);
+	bool has_rule = (rule_metadata_json && strlen(rule_metadata_json) > 0);
+	bool has_policy = (policy_metadata_json && strlen(policy_metadata_json) > 0);
+	bool need_comma = false;
+
+	if (!has_fk && !has_trigger && !has_view && !has_rule && !has_policy)
+		return;
+
+	initStringInfo(&sql);
+	initStringInfo(&merged_json);
+
+	/* Build merged JSON object */
+	appendStringInfoString(&merged_json, "{");
+
+	/* Add FK metadata */
+	if (has_fk)
+	{
+		size_t fk_len = strlen(fk_metadata_json);
+		appendBinaryStringInfo(&merged_json, fk_metadata_json + 1, fk_len - 2);  /* Skip { and } */
+		need_comma = true;
+	}
+
+	/* Add trigger metadata */
+	if (has_trigger)
+	{
+		size_t trigger_len = strlen(trigger_metadata_json);
+		if (need_comma)
+			appendStringInfoString(&merged_json, ", ");
+		appendBinaryStringInfo(&merged_json, trigger_metadata_json + 1, trigger_len - 2);  /* Skip { and } */
+		need_comma = true;
+	}
+
+	/* Add view metadata */
+	if (has_view)
+	{
+		size_t view_len = strlen(view_metadata_json);
+		if (need_comma)
+			appendStringInfoString(&merged_json, ", ");
+		appendBinaryStringInfo(&merged_json, view_metadata_json + 1, view_len - 2);  /* Skip { and } */
+		need_comma = true;
+	}
+
+	/* Add rule metadata */
+	if (has_rule)
+	{
+		size_t rule_len = strlen(rule_metadata_json);
+		if (need_comma)
+			appendStringInfoString(&merged_json, ", ");
+		appendBinaryStringInfo(&merged_json, rule_metadata_json + 1, rule_len - 2);  /* Skip { and } */
+		need_comma = true;
+	}
+
+	/* Add policy metadata */
+	if (has_policy)
+	{
+		size_t policy_len = strlen(policy_metadata_json);
+		if (need_comma)
+			appendStringInfoString(&merged_json, ", ");
+		appendBinaryStringInfo(&merged_json, policy_metadata_json + 1, policy_len - 2);  /* Skip { and } */
+	}
+
+	appendStringInfoString(&merged_json, "}");
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in update_catalog_with_complete_metadata")));
+
+	PG_TRY();
+	{
+		/* Set flags inside PG_TRY so PG_FINALLY always resets them */
+		pgtrashcan_in_progress = true;
+		SetConfigOption("pgtrashcan.internal_operation", "true", PGC_SUSET, PGC_S_SESSION);
+
+		appendStringInfo(&sql,
+			"UPDATE \"%s\".\"%s\" SET metadata = %s::jsonb "
+			"WHERE trash_name = %s",
+			TRASH_SCHEMA, TRASH_CATALOG,
+			quote_literal_cstr(merged_json.data),
+			quote_literal_cstr(trash_name));
+
+		ret = SPI_execute(sql.data, false, 0);
+		if (ret != SPI_OK_UPDATE)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to update catalog with metadata")));
+
+		elog(DEBUG1, "pgtrashcan: Updated catalog for %s with complete metadata (FK, triggers, views, rules, policies)", trash_name);
+
+		pfree(sql.data);
+		pfree(merged_json.data);
+		CommandCounterIncrement();
+	}
+	PG_FINALLY();
+	{
+		/* Always clean up SPI and reset flags, even on error */
+		SPI_finish();
+		pgtrashcan_in_progress = false;
+		SetConfigOption("pgtrashcan.internal_operation", "false", PGC_SUSET, PGC_S_SESSION);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * detect_and_build_trigger_metadata_json - Detect all triggers and build JSON metadata
+ *
+ * Queries pg_trigger for all user-defined triggers on the table and stores their definitions
+ * for documentation purposes (triggers are permanently dropped, not restored)
+ *
+ * Returns a JSON string with trigger definitions for storage in catalog metadata column
+ */
+static char *
+detect_and_build_trigger_metadata_json(Oid relid)
+{
+	StringInfoData json;
+	StringInfoData sql;
+	int ret;
+	bool first_trigger = true;
+	SPITupleTable *tuptable;
+	int i;
+
+	initStringInfo(&json);
+	initStringInfo(&sql);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in detect_and_build_trigger_metadata_json")));
+
+	PG_TRY();
+	{
+		appendStringInfoString(&json, "{\"triggers_dropped\": [");
+
+		/*
+		 * Query for all user-defined triggers on this table
+		 * Exclude internal triggers (tgisinternal = true)
+		 */
+		appendStringInfo(&sql,
+			"SELECT t.tgname, "
+			"  pg_get_triggerdef(t.oid) AS trigger_def, "
+			"  CASE "
+			"    WHEN t.tgtype & 2 = 2 THEN 'BEFORE' "
+			"    WHEN t.tgtype & 64 = 64 THEN 'INSTEAD OF' "
+			"    ELSE 'AFTER' "
+			"  END AS timing, "
+			"  CASE "
+			"    WHEN t.tgtype & 4 = 4 THEN 'INSERT' "
+			"    WHEN t.tgtype & 8 = 8 THEN 'DELETE' "
+			"    WHEN t.tgtype & 16 = 16 THEN 'UPDATE' "
+			"    ELSE 'TRUNCATE' "
+			"  END AS event, "
+			"  CASE "
+			"    WHEN t.tgtype & 1 = 1 THEN 'ROW' "
+			"    ELSE 'STATEMENT' "
+			"  END AS level, "
+			"  p.proname AS function_name, "
+			"  n.nspname AS function_schema "
+			"FROM pg_trigger t "
+			"JOIN pg_proc p ON t.tgfoid = p.oid "
+			"JOIN pg_namespace n ON p.pronamespace = n.oid "
+			"WHERE t.tgrelid = %u "
+			"  AND NOT t.tgisinternal "
+			"ORDER BY t.tgname", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query triggers")));
+
+		tuptable = SPI_tuptable;
+		for (i = 0; i < SPI_processed; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			char *tgname;
+			char *trigger_def;
+			char *timing;
+			char *event;
+			char *level;
+			char *function_name;
+			char *function_schema;
+			bool free_trigger_def = false;
+			bool free_timing = false;
+			bool free_event = false;
+			bool free_level = false;
+			int fno_tgname, fno_trigger_def, fno_timing, fno_event, fno_level, fno_function_name, fno_function_schema;
+
+			if (!first_trigger)
+				appendStringInfoString(&json, ", ");
+			first_trigger = false;
+
+			/* Get column numbers */
+			fno_tgname = SPI_fnumber(tupdesc, "tgname");
+			fno_trigger_def = SPI_fnumber(tupdesc, "trigger_def");
+			fno_timing = SPI_fnumber(tupdesc, "timing");
+			fno_event = SPI_fnumber(tupdesc, "event");
+			fno_level = SPI_fnumber(tupdesc, "level");
+			fno_function_name = SPI_fnumber(tupdesc, "function_name");
+			fno_function_schema = SPI_fnumber(tupdesc, "function_schema");
+
+			if (fno_tgname < 0 || fno_trigger_def < 0 || fno_timing < 0 || fno_event < 0 ||
+				fno_level < 0 || fno_function_name < 0 || fno_function_schema < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("required column not found in trigger query")));
+
+			/* Extract trigger information */
+			datum = SPI_getbinval(tuple, tupdesc, fno_tgname, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("tgname is NULL in trigger query")));
+			tgname = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_trigger_def, &isnull);
+			if (isnull)
+			{
+				trigger_def = "";
+			}
+			else
+			{
+				trigger_def = TextDatumGetCString(datum);
+				free_trigger_def = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_timing, &isnull);
+			if (isnull)
+			{
+				timing = "AFTER";
+			}
+			else
+			{
+				timing = TextDatumGetCString(datum);
+				free_timing = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_event, &isnull);
+			if (isnull)
+			{
+				event = "unknown";
+			}
+			else
+			{
+				event = TextDatumGetCString(datum);
+				free_event = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_level, &isnull);
+			if (isnull)
+			{
+				level = "STATEMENT";
+			}
+			else
+			{
+				level = TextDatumGetCString(datum);
+				free_level = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_function_name, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("function_name is NULL in trigger query")));
+			function_name = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_function_schema, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("function_schema is NULL in trigger query")));
+			function_schema = NameStr(*DatumGetName(datum));
+
+			/* Build JSON object for this trigger */
+			{
+				char *esc_tgname = escape_json_string(tgname);
+				char *esc_timing = escape_json_string(timing);
+				char *esc_event = escape_json_string(event);
+				char *esc_level = escape_json_string(level);
+				char *esc_func_schema = escape_json_string(function_schema);
+				char *esc_func_name = escape_json_string(function_name);
+				char *esc_trigger_def = escape_json_string(trigger_def);
+
+				appendStringInfo(&json,
+					"{\"trigger_name\": \"%s\", "
+					"\"timing\": \"%s\", "
+					"\"event\": \"%s\", "
+					"\"level\": \"%s\", "
+					"\"function\": \"%s.%s()\", "
+					"\"definition\": \"%s\"}",
+					esc_tgname,
+					esc_timing,
+					esc_event,
+					esc_level,
+					esc_func_schema,
+					esc_func_name,
+					esc_trigger_def);
+
+				pfree(esc_tgname);
+				pfree(esc_timing);
+				pfree(esc_event);
+				pfree(esc_level);
+				pfree(esc_func_schema);
+				pfree(esc_func_name);
+				pfree(esc_trigger_def);
+			}
+
+			/* Free TextDatumGetCString allocations */
+			if (free_trigger_def)
+				pfree(trigger_def);
+			if (free_timing)
+				pfree(timing);
+			if (free_event)
+				pfree(event);
+			if (free_level)
+				pfree(level);
+		}
+
+		appendStringInfoString(&json, "]}");
+
+		pfree(sql.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	return json.data;
+}
+
+/* drop_triggers_permanently removed — triggers now preserved with table */
+#if 0
+/*
+ * drop_triggers_permanently - Drop all user-defined triggers on the table
+ *
+ * Similar to drop_foreign_keys_permanently(), this permanently removes all triggers
+ * before moving the table to pgtrashcan. Trigger definitions are stored in metadata
+ * for documentation, but triggers are not restored.
+ */
+static void
+drop_triggers_permanently(Oid relid)
+{
+	StringInfoData sql;
+	StringInfoData drop_cmd;
+	int ret;
+	SPITupleTable *tuptable;
+	int i;
+	int trigger_count = 0;
+	char *relname;
+	char *nspname;
+	MemoryContext caller_ctx;
+
+	/* Dynamic list for trigger info before we start dropping them */
+	List *triggers = NIL;
+
+	initStringInfo(&sql);
+	initStringInfo(&drop_cmd);
+
+	/* Get table name and schema name for DROP TRIGGER commands */
+	relname = get_rel_name(relid);
+	nspname = get_namespace_name(get_rel_namespace(relid));
+
+	if (!relname || !nspname)
+	{
+		elog(DEBUG1, "pgtrashcan: Table OID %u no longer exists, skipping trigger drop", relid);
+		return;
+	}
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in drop_triggers_permanently")));
+
+	PG_TRY();
+	{
+		/*
+		 * STEP 1: Collect all trigger information
+		 * (user-defined triggers only, exclude internal triggers)
+		 */
+		appendStringInfo(&sql,
+			"SELECT t.tgname "
+			"FROM pg_trigger t "
+			"WHERE t.tgrelid = %u "
+			"  AND NOT t.tgisinternal", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query triggers for deletion")));
+
+		tuptable = SPI_tuptable;
+		trigger_count = SPI_processed;
+
+		for (i = 0; i < trigger_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			char *tgname;
+			int fno_tgname;
+			TriggerInfo *tinfo;
+
+			fno_tgname = SPI_fnumber(tupdesc, "tgname");
+			if (fno_tgname < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("column 'tgname' not found in trigger deletion query")));
+
+			tgname = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_tgname, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("tgname is NULL in trigger deletion query")));
+
+			/* Allocate in caller's context so data survives SPI_finish */
+			{
+				MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+				tinfo = palloc(sizeof(TriggerInfo));
+				tinfo->tgname = pstrdup(tgname);
+				triggers = lappend(triggers, tinfo);
+				MemoryContextSwitchTo(spi_ctx);
+			}
+		}
+
+		/*
+		 * STEP 2: Drop all triggers using stored info
+		 */
+		{
+			ListCell *lc;
+			foreach(lc, triggers)
+			{
+				TriggerInfo *tinfo = (TriggerInfo *) lfirst(lc);
+
+				elog(DEBUG1, "pgtrashcan: Dropping trigger: %s from %s.%s",
+					 tinfo->tgname, nspname, relname);
+
+				resetStringInfo(&drop_cmd);
+				appendStringInfo(&drop_cmd, "DROP TRIGGER %s ON %s.%s",
+								 quote_identifier(tinfo->tgname),
+								 quote_identifier(nspname),
+								 quote_identifier(relname));
+
+				ret = SPI_execute(drop_cmd.data, false, 0);
+				if (ret != SPI_OK_UTILITY)
+					ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("pgtrashcan: Failed to drop trigger %s", tinfo->tgname),
+							 errhint("Cannot move table to trash if triggers cannot be dropped")));
+			}
+		}
+
+		/* Done with SPI - list data is in caller's memory context, safe to finish */
+		pfree(sql.data);
+		pfree(drop_cmd.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	if (trigger_count > 0)
+	{
+		StringInfoData trigger_names;
+		ListCell *lc;
+		bool first = true;
+
+		initStringInfo(&trigger_names);
+
+		/* Build list of trigger names */
+		foreach(lc, triggers)
+		{
+			TriggerInfo *tinfo = (TriggerInfo *) lfirst(lc);
+			if (!first)
+				appendStringInfoString(&trigger_names, ", ");
+			appendStringInfo(&trigger_names, "%s", tinfo->tgname);
+			first = false;
+		}
+
+		elog(NOTICE, "%d trigger(s) permanently dropped (cannot be moved to trash): %s",
+			 trigger_count, trigger_names.data);
+		elog(NOTICE, "Trigger definitions stored in pgtrashcan._trash_catalog.metadata");
+
+		pfree(trigger_names.data);
+	}
+
+	/* Free allocated memory for trigger info list */
+	{
+		ListCell *lc;
+		foreach(lc, triggers)
+		{
+			TriggerInfo *tinfo = (TriggerInfo *) lfirst(lc);
+			pfree(tinfo->tgname);
+		}
+		list_free_deep(triggers);
+	}
+
+	/* Free table/schema names */
+	pfree(relname);
+	pfree(nspname);
+}
+#endif /* drop_triggers_permanently */
+
+/*
+ * collect_views_recursive - Recursively collect all views that depend on a relation
+ *
+ * Walks the pg_depend/pg_rewrite dependency graph to find ALL views in the chain.
+ * For a table T with view_B depending on it, and view_C depending on view_B:
+ *   - First recurses to find view_C (which depends on view_B)
+ *   - Then records view_B
+ *   - Result list is in bottom-up order (leaves first): [view_C, view_B]
+ *
+ * This ensures:
+ *   1. All nested view definitions are captured in metadata JSON
+ *   2. Views can be dropped in leaf-first order without CASCADE
+ *
+ * Parameters:
+ *   relid           - OID of the relation to find dependent views for
+ *   depends_on_name - Name of the parent view (NULL if relid is the original table)
+ *   visited         - List of already-visited OIDs to prevent cycles
+ *   depth           - Recursion depth (safety limit at 100)
+ *
+ * Returns: List of ViewInfo* in bottom-up order (must be freed by caller)
+ *
+ * IMPORTANT: Caller must have SPI connected before calling this function.
+ * This function executes SPI queries but does NOT call SPI_connect/SPI_finish.
+ */
+static List *
+collect_views_recursive(Oid relid, const char *depends_on_name, List **visited, int depth, MemoryContext caller_ctx)
+{
+	List *result = NIL;
+	StringInfoData sql;
+	int ret;
+	SPITupleTable *tuptable;
+	int view_count;
+	int i;
+
+	/* Safety limit to prevent runaway recursion */
+	if (depth > 100)
+	{
+		elog(WARNING, "pgtrashcan: view dependency chain exceeds 100 levels, stopping recursion");
+		return NIL;
+	}
+
+	/* Check if we've already visited this OID (cycle detection) */
+	{
+		ListCell *lc;
+		foreach(lc, *visited)
+		{
+			if (lfirst_oid(lc) == relid)
+				return NIL;  /* Already visited, skip */
+		}
+	}
+
+	{
+		MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+		*visited = lappend_oid(*visited, relid);
+		MemoryContextSwitchTo(spi_ctx);
+	}
+
+	initStringInfo(&sql);
+
+	/*
+	 * Query for views and materialized views that depend on this relation.
+	 * Views depend via pg_rewrite rules, so join through pg_rewrite.
+	 */
+	appendStringInfo(&sql,
+		"SELECT DISTINCT c.oid, n.nspname, c.relname, c.relkind "
+		"FROM pg_depend d "
+		"JOIN pg_rewrite rw ON rw.oid = d.objid "
+		"JOIN pg_class c ON c.oid = rw.ev_class "
+		"JOIN pg_namespace n ON n.oid = c.relnamespace "
+		"WHERE d.refobjid = %u "
+		"  AND d.deptype IN ('n', 'a') "
+		"  AND c.relkind IN ('v', 'm') "
+		"  AND c.oid != %u "
+		"  AND c.oid >= 16384 "
+		"ORDER BY c.relname", relid, relid);
+
+	ret = SPI_execute(sql.data, true, 0);
+	if (ret != SPI_OK_SELECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("failed to query views in collect_views_recursive")));
+
+	tuptable = SPI_tuptable;
+	view_count = SPI_processed;
+
+	/*
+	 * First pass: collect basic info from SPI results into a temporary list.
+	 * We must copy data out before recursing, since recursive SPI queries
+	 * will overwrite SPI_tuptable/SPI_processed.
+	 */
+	{
+		typedef struct TmpViewInfo {
+			Oid   oid;
+			char *schema;
+			char *name;
+			char  relkind;
+		} TmpViewInfo;
+
+		List *tmp_views = NIL;
+
+		for (i = 0; i < view_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			int fno_oid, fno_nspname, fno_relname, fno_relkind;
+			TmpViewInfo *tmp;
+
+			fno_oid = SPI_fnumber(tupdesc, "oid");
+			fno_nspname = SPI_fnumber(tupdesc, "nspname");
+			fno_relname = SPI_fnumber(tupdesc, "relname");
+			fno_relkind = SPI_fnumber(tupdesc, "relkind");
+
+			if (fno_oid < 0 || fno_nspname < 0 || fno_relname < 0 || fno_relkind < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("required column not found in recursive view query")));
+
+			/* Extract values while in SPI context */
+			datum = SPI_getbinval(tuple, tupdesc, fno_oid, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("oid is NULL in recursive view query")));
+
+			{
+				Oid tmp_oid = DatumGetObjectId(datum);
+				char *tmp_schema_str;
+				char *tmp_name_str;
+				char tmp_relkind;
+
+				datum = SPI_getbinval(tuple, tupdesc, fno_nspname, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("nspname is NULL in recursive view query")));
+				tmp_schema_str = NameStr(*DatumGetName(datum));
+
+				datum = SPI_getbinval(tuple, tupdesc, fno_relname, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("relname is NULL in recursive view query")));
+				tmp_name_str = NameStr(*DatumGetName(datum));
+
+				datum = SPI_getbinval(tuple, tupdesc, fno_relkind, &isnull);
+				if (isnull)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("relkind is NULL in recursive view query")));
+				tmp_relkind = DatumGetChar(datum);
+
+				/* Skip if already visited (handles diamond dependencies) */
+				{
+					bool already_visited = false;
+					ListCell *lc;
+					foreach(lc, *visited)
+					{
+						if (lfirst_oid(lc) == tmp_oid)
+						{
+							already_visited = true;
+							break;
+						}
+					}
+					if (already_visited)
+						continue;
+				}
+
+				/* Allocate in caller's context so data survives SPI_finish */
+				{
+					MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+					tmp = palloc(sizeof(TmpViewInfo));
+					tmp->oid = tmp_oid;
+					tmp->schema = pstrdup(tmp_schema_str);
+					tmp->name = pstrdup(tmp_name_str);
+					tmp->relkind = tmp_relkind;
+					tmp_views = lappend(tmp_views, tmp);
+					MemoryContextSwitchTo(spi_ctx);
+				}
+			}
+		}
+
+		/*
+		 * Second pass: for each discovered view, recurse to find its dependents
+		 * before adding it to the result list. This produces bottom-up ordering.
+		 */
+		{
+			ListCell *lc;
+			foreach(lc, tmp_views)
+			{
+				TmpViewInfo *tmp = (TmpViewInfo *) lfirst(lc);
+				List *children;
+				ViewInfo *vinfo;
+
+				/* Recurse: find views that depend on THIS view */
+				children = collect_views_recursive(tmp->oid, tmp->name, visited, depth + 1, caller_ctx);
+
+				/* Allocate in caller's context so data survives SPI_finish */
+				{
+					MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+
+					/* Add children first (bottom-up order) */
+					result = list_concat(result, children);
+
+					/* Then add this view itself */
+					vinfo = palloc(sizeof(ViewInfo));
+					vinfo->oid = tmp->oid;
+					vinfo->schema = tmp->schema;  /* transfer ownership */
+					vinfo->name = tmp->name;       /* transfer ownership */
+					vinfo->relkind = tmp->relkind;
+					vinfo->depends_on = depends_on_name ? pstrdup(depends_on_name) : NULL;
+
+					result = lappend(result, vinfo);
+
+					MemoryContextSwitchTo(spi_ctx);
+				}
+
+				pfree(tmp);  /* free TmpViewInfo shell only, strings transferred */
+			}
+		}
+
+		list_free(tmp_views);  /* shallow free, TmpViewInfo structs already freed */
+	}
+
+	pfree(sql.data);
+
+	return result;
+}
+
+/*
+ * detect_and_build_view_metadata_json - Detect all views/matviews and build JSON metadata
+ *
+ * Recursively finds ALL views in the dependency chain (not just first-level).
+ * For example: table_A → view_B → view_C will capture both view_B and view_C.
+ *
+ * Each view entry includes a "depends_on" field showing what it directly depended on:
+ *   - null for views directly on the table
+ *   - parent view name for nested views
+ *
+ * Views are listed in bottom-up order (leaves first): [view_C, view_B]
+ *
+ * Returns a JSON string with view definitions for storage in catalog metadata column
+ */
+static char *
+detect_and_build_view_metadata_json(Oid relid)
+{
+	StringInfoData json;
+	List *views;
+	List *visited = NIL;
+	ListCell *lc;
+	bool first_view = true;
+	MemoryContext caller_ctx;
+
+	initStringInfo(&json);
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in detect_and_build_view_metadata_json")));
+
+	PG_TRY();
+	{
+		/* Recursively collect all views in the dependency chain */
+		views = collect_views_recursive(relid, NULL, &visited, 0, caller_ctx);
+
+		appendStringInfoString(&json, "{\"views_dropped\": [");
+
+		/*
+		 * For each view, get its definition via pg_get_viewdef() and build JSON.
+		 * The list is in bottom-up order (leaves first).
+		 */
+		foreach(lc, views)
+		{
+			ViewInfo *vinfo = (ViewInfo *) lfirst(lc);
+			char *view_type;
+			char *view_def;
+			char *escaped_name, *escaped_schema, *escaped_type, *escaped_def;
+			StringInfoData view_def_query;
+
+			if (!first_view)
+				appendStringInfoString(&json, ", ");
+			first_view = false;
+
+			view_type = (vinfo->relkind == 'v') ? "view" : "materialized view";
+
+			/* Get view definition using pg_get_viewdef() */
+			initStringInfo(&view_def_query);
+			appendStringInfo(&view_def_query,
+				"SELECT pg_get_viewdef(%u, true)", vinfo->oid);
+
+			if (SPI_execute(view_def_query.data, true, 1) == SPI_OK_SELECT && SPI_processed > 0)
+			{
+				bool isnull;
+				int fno_def;
+				Datum def_datum;
+				TupleDesc def_tupdesc = SPI_tuptable->tupdesc;
+				fno_def = SPI_fnumber(def_tupdesc, "pg_get_viewdef");
+				if (fno_def < 0)
+					fno_def = 1;  /* Fallback to first column if name not found */
+				def_datum = SPI_getbinval(SPI_tuptable->vals[0], def_tupdesc, fno_def, &isnull);
+				view_def = isnull ? NULL : TextDatumGetCString(def_datum);
+			}
+			else
+			{
+				view_def = NULL;
+			}
+
+			/* Strip trailing ';' and whitespace from pg_get_viewdef output so the
+			 * constructed DDL doesn't end in ";;" */
+			if (view_def)
+			{
+				int def_len = strlen(view_def);
+				while (def_len > 0 &&
+					(view_def[def_len - 1] == ';' ||
+					 view_def[def_len - 1] == ' ' ||
+					 view_def[def_len - 1] == '\t' ||
+					 view_def[def_len - 1] == '\n' ||
+					 view_def[def_len - 1] == '\r'))
+					view_def[--def_len] = '\0';
+			}
+
+			/* Build the runnable DDL */
+			{
+				StringInfoData ddl;
+				const char *body = view_def ? view_def : "/* definition unavailable */";
+
+				initStringInfo(&ddl);
+				if (vinfo->relkind == 'v')
+					appendStringInfo(&ddl,
+						"CREATE OR REPLACE VIEW %s.%s AS %s;",
+						quote_identifier(vinfo->schema),
+						quote_identifier(vinfo->name),
+						body);
+				else
+					/* materialized view — no OR REPLACE variant exists; IF NOT EXISTS
+					 * is the closest runnable replay option. WITH DATA triggers a
+					 * refresh so the matview is re-populated from the restored base. */
+					appendStringInfo(&ddl,
+						"CREATE MATERIALIZED VIEW IF NOT EXISTS %s.%s AS %s WITH DATA;",
+						quote_identifier(vinfo->schema),
+						quote_identifier(vinfo->name),
+						body);
+
+				/* Escape strings for JSON */
+				escaped_name = escape_json_string(vinfo->name);
+				escaped_schema = escape_json_string(vinfo->schema);
+				escaped_type = escape_json_string(view_type);
+				escaped_def = escape_json_string(body);
+				{
+					char *escaped_ddl = escape_json_string(ddl.data);
+
+					/* Build JSON object for this view */
+					appendStringInfo(&json,
+						"{\"view_name\": \"%s\", "
+						"\"view_schema\": \"%s\", "
+						"\"view_type\": \"%s\", "
+						"\"definition\": \"%s\", "
+						"\"ddl\": \"%s\"",
+						escaped_name,
+						escaped_schema,
+						escaped_type,
+						escaped_def,
+						escaped_ddl);
+
+					pfree(escaped_ddl);
+				}
+				pfree(ddl.data);
+			}
+
+			/* Add depends_on field for nested views */
+			if (vinfo->depends_on)
+			{
+				char *escaped_depends = escape_json_string(vinfo->depends_on);
+				appendStringInfo(&json, ", \"depends_on\": \"%s\"", escaped_depends);
+				pfree(escaped_depends);
+			}
+
+			appendStringInfoChar(&json, '}');
+
+			/* Free escaped strings */
+			pfree(escaped_name);
+			pfree(escaped_schema);
+			pfree(escaped_type);
+			pfree(escaped_def);
+			if (view_def)
+				pfree(view_def);
+			pfree(view_def_query.data);
+		}
+
+		appendStringInfoString(&json, "]}");
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	/* Free the collected view info list (in caller's context, safe after SPI_finish) */
+	foreach(lc, views)
+	{
+		ViewInfo *vinfo = (ViewInfo *) lfirst(lc);
+		pfree(vinfo->schema);
+		pfree(vinfo->name);
+		if (vinfo->depends_on)
+			pfree(vinfo->depends_on);
+	}
+	list_free_deep(views);
+	list_free(visited);
+
+	return json.data;
+}
+
+/*
+ * drop_views_permanently - Drop all views/matviews that depend on the table
+ *
+ * Recursively finds ALL views in the dependency chain (including nested views)
+ * and drops them in bottom-up order (leaf views first, so no CASCADE needed).
+ *
+ * View definitions are stored in metadata for documentation, but views are not restored.
+ */
+static void
+drop_views_permanently(Oid relid)
+{
+	StringInfoData drop_cmd;
+	int ret;
+	int view_count;
+	MemoryContext caller_ctx;
+
+	/* Recursively collect all views in the dependency chain */
+	List *views = NIL;
+	List *visited = NIL;
+	ListCell *lc;
+
+	initStringInfo(&drop_cmd);
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in drop_views_permanently")));
+
+	PG_TRY();
+	{
+		/*
+		 * STEP 1: Recursively collect all views (including nested chains).
+		 * Result is in bottom-up order: leaves first, direct dependents last.
+		 * This means we can drop in list order without needing CASCADE.
+		 */
+		views = collect_views_recursive(relid, NULL, &visited, 0, caller_ctx);
+		view_count = list_length(views);
+
+		/*
+		 * STEP 2: Drop all views in bottom-up order (leaves first)
+		 */
+		foreach(lc, views)
+		{
+			ViewInfo *vinfo = (ViewInfo *) lfirst(lc);
+			char *obj_type = (vinfo->relkind == 'v') ? "VIEW" : "MATERIALIZED VIEW";
+
+			elog(DEBUG1, "pgtrashcan: Dropping %s: %s.%s%s",
+				 obj_type, vinfo->schema, vinfo->name,
+				 vinfo->depends_on ? vinfo->depends_on : "(direct)");
+
+			resetStringInfo(&drop_cmd);
+			appendStringInfo(&drop_cmd, "DROP %s IF EXISTS %s.%s",
+							 obj_type,
+							 quote_identifier(vinfo->schema),
+							 quote_identifier(vinfo->name));
+
+			ret = SPI_execute(drop_cmd.data, false, 0);
+			if (ret != SPI_OK_UTILITY)
+				ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("pgtrashcan: Failed to drop %s %s.%s",
+								obj_type, vinfo->schema, vinfo->name),
+						 errhint("Cannot move table to trash if views cannot be dropped")));
+		}
+
+		/* Done with SPI - list data is in caller's memory context, safe to finish */
+		pfree(drop_cmd.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	if (view_count > 0)
+	{
+		StringInfoData view_names;
+		bool first = true;
+
+		initStringInfo(&view_names);
+
+		/* Build list of view names */
+		foreach(lc, views)
+		{
+			ViewInfo *vinfo = (ViewInfo *) lfirst(lc);
+			if (!first)
+				appendStringInfoString(&view_names, ", ");
+			appendStringInfo(&view_names, "%s.%s (%s)",
+				vinfo->schema,
+				vinfo->name,
+				vinfo->relkind == 'v' ? "view" : "materialized view");
+			first = false;
+		}
+
+		elog(NOTICE, "%d view(s)/materialized view(s) permanently dropped (cannot be moved to trash): %s",
+			 view_count, view_names.data);
+		elog(NOTICE, "View definitions stored in pgtrashcan._trash_catalog.metadata");
+
+		pfree(view_names.data);
+	}
+
+	/* Free allocated memory for view info list */
+	foreach(lc, views)
+	{
+		ViewInfo *vinfo = (ViewInfo *) lfirst(lc);
+		pfree(vinfo->schema);
+		pfree(vinfo->name);
+		if (vinfo->depends_on)
+			pfree(vinfo->depends_on);
+	}
+	list_free_deep(views);
+	list_free(visited);
+}
+
+/*
+ * detect_and_build_rule_metadata_json - Detect all rules and build JSON metadata
+ *
+ * Queries pg_rewrite for all user-defined rules on the table and stores their definitions
+ * for documentation purposes (rules are permanently dropped, not restored)
+ *
+ * Returns a JSON string with rule definitions for storage in catalog metadata column
+ */
+static char *
+detect_and_build_rule_metadata_json(Oid relid)
+{
+	StringInfoData json;
+	StringInfoData sql;
+	int ret;
+	bool first_rule = true;
+	SPITupleTable *tuptable;
+	int i;
+
+	initStringInfo(&json);
+	initStringInfo(&sql);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in detect_and_build_rule_metadata_json")));
+
+	PG_TRY();
+	{
+		appendStringInfoString(&json, "{\"rules_dropped\": [");
+
+		/*
+		 * Query for all user-defined rules on this table
+		 * Exclude internal rules (ev_type = '1' for SELECT rules that are actually views)
+		 */
+		appendStringInfo(&sql,
+			"SELECT r.rulename, "
+			"  pg_get_ruledef(r.oid) AS rule_def, "
+			"  CASE r.ev_type "
+			"    WHEN '1' THEN 'SELECT' "
+			"    WHEN '2' THEN 'UPDATE' "
+			"    WHEN '3' THEN 'INSERT' "
+			"    WHEN '4' THEN 'DELETE' "
+			"    ELSE 'UNKNOWN' "
+			"  END AS event "
+			"FROM pg_rewrite r "
+			"WHERE r.ev_class = %u "
+			"  AND r.rulename != '_RETURN' "
+			"ORDER BY r.rulename", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query rules")));
+
+		tuptable = SPI_tuptable;
+		for (i = 0; i < SPI_processed; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			char *rulename;
+			char *rule_def;
+			char *event;
+			bool free_rule_def = false;
+			bool free_event = false;
+			int fno_rulename, fno_rule_def, fno_event;
+
+			if (!first_rule)
+				appendStringInfoString(&json, ", ");
+			first_rule = false;
+
+			/* Get column numbers */
+			fno_rulename = SPI_fnumber(tupdesc, "rulename");
+			fno_rule_def = SPI_fnumber(tupdesc, "rule_def");
+			fno_event = SPI_fnumber(tupdesc, "event");
+
+			if (fno_rulename < 0 || fno_rule_def < 0 || fno_event < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("required column not found in rule query")));
+
+			/* Extract rule information */
+			datum = SPI_getbinval(tuple, tupdesc, fno_rulename, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("rulename is NULL in rule query")));
+			rulename = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_rule_def, &isnull);
+			if (isnull)
+			{
+				rule_def = "";
+			}
+			else
+			{
+				rule_def = TextDatumGetCString(datum);
+				free_rule_def = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_event, &isnull);
+			if (isnull)
+			{
+				event = "UNKNOWN";
+			}
+			else
+			{
+				event = TextDatumGetCString(datum);
+				free_event = true;
+			}
+
+			/* Build JSON object for this rule */
+			{
+				char *esc_rulename = escape_json_string(rulename);
+				char *esc_event = escape_json_string(event);
+				char *esc_rule_def = escape_json_string(rule_def);
+
+				appendStringInfo(&json,
+					"{\"rule_name\": \"%s\", "
+					"\"event\": \"%s\", "
+					"\"definition\": \"%s\"}",
+					esc_rulename,
+					esc_event,
+					esc_rule_def);
+
+				pfree(esc_rulename);
+				pfree(esc_event);
+				pfree(esc_rule_def);
+			}
+
+			/* Free TextDatumGetCString allocations */
+			if (free_rule_def)
+				pfree(rule_def);
+			if (free_event)
+				pfree(event);
+		}
+
+		appendStringInfoString(&json, "]}");
+
+		pfree(sql.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	return json.data;
+}
+
+/*
+ * drop_rules_permanently - Drop all user-defined rules on the table
+ *
+ * Similar to drop_triggers_permanently(), this permanently removes all rules
+ * before moving the table to pgtrashcan. Rule definitions are stored in metadata
+ * for documentation, but rules are not restored.
+ */
+static void
+drop_rules_permanently(Oid relid)
+{
+	StringInfoData sql;
+	StringInfoData drop_cmd;
+	int ret;
+	SPITupleTable *tuptable;
+	int i;
+	int rule_count = 0;
+	char *relname;
+	char *nspname;
+	MemoryContext caller_ctx;
+
+	/* Dynamic list for rule info before we start dropping them */
+	List *rules = NIL;
+
+	initStringInfo(&sql);
+	initStringInfo(&drop_cmd);
+
+	/* Get table name and schema name for DROP RULE commands */
+	relname = get_rel_name(relid);
+	nspname = get_namespace_name(get_rel_namespace(relid));
+
+	if (!relname || !nspname)
+	{
+		elog(DEBUG1, "pgtrashcan: Table OID %u no longer exists, skipping rule drop", relid);
+		return;
+	}
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in drop_rules_permanently")));
+
+	PG_TRY();
+	{
+		/*
+		 * STEP 1: Collect all rule information
+		 * (user-defined rules only, exclude _RETURN which is for views)
+		 */
+		appendStringInfo(&sql,
+			"SELECT r.rulename "
+			"FROM pg_rewrite r "
+			"WHERE r.ev_class = %u "
+			"  AND r.rulename != '_RETURN'", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query rules for deletion")));
+
+		tuptable = SPI_tuptable;
+		rule_count = SPI_processed;
+
+		for (i = 0; i < rule_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			char *rulename;
+			int fno_rulename;
+			RuleInfo *rinfo;
+
+			fno_rulename = SPI_fnumber(tupdesc, "rulename");
+			if (fno_rulename < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("column 'rulename' not found in rule deletion query")));
+
+			rulename = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_rulename, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("rulename is NULL in rule deletion query")));
+
+			/* Allocate in caller's context so data survives SPI_finish */
+			{
+				MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+				rinfo = palloc(sizeof(RuleInfo));
+				rinfo->rulename = pstrdup(rulename);
+				rules = lappend(rules, rinfo);
+				MemoryContextSwitchTo(spi_ctx);
+			}
+		}
+
+		/*
+		 * STEP 2: Drop all rules using stored info
+		 */
+		{
+			ListCell *lc;
+			foreach(lc, rules)
+			{
+				RuleInfo *rinfo = (RuleInfo *) lfirst(lc);
+
+				elog(DEBUG1, "pgtrashcan: Dropping rule: %s from %s.%s",
+					 rinfo->rulename, nspname, relname);
+
+				resetStringInfo(&drop_cmd);
+				appendStringInfo(&drop_cmd, "DROP RULE %s ON %s.%s",
+								 quote_identifier(rinfo->rulename),
+								 quote_identifier(nspname),
+								 quote_identifier(relname));
+
+				ret = SPI_execute(drop_cmd.data, false, 0);
+				if (ret != SPI_OK_UTILITY)
+					ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("pgtrashcan: Failed to drop rule %s", rinfo->rulename),
+							 errhint("Cannot move table to trash if rules cannot be dropped")));
+			}
+		}
+
+		/* Done with SPI - list data is in caller's memory context, safe to finish */
+		pfree(sql.data);
+		pfree(drop_cmd.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	if (rule_count > 0)
+	{
+		StringInfoData rule_names;
+		ListCell *lc;
+		bool first = true;
+
+		initStringInfo(&rule_names);
+
+		/* Build list of rule names */
+		foreach(lc, rules)
+		{
+			RuleInfo *rinfo = (RuleInfo *) lfirst(lc);
+			if (!first)
+				appendStringInfoString(&rule_names, ", ");
+			appendStringInfo(&rule_names, "%s", rinfo->rulename);
+			first = false;
+		}
+
+		elog(NOTICE, "%d rule(s) permanently dropped (cannot be moved to trash): %s",
+			 rule_count, rule_names.data);
+		elog(NOTICE, "Rule definitions stored in pgtrashcan._trash_catalog.metadata");
+
+		pfree(rule_names.data);
+	}
+
+	/* Free allocated memory for rule info list */
+	{
+		ListCell *lc;
+		foreach(lc, rules)
+		{
+			RuleInfo *rinfo = (RuleInfo *) lfirst(lc);
+			pfree(rinfo->rulename);
+		}
+		list_free_deep(rules);
+	}
+
+	/* Free table/schema names */
+	pfree(relname);
+	pfree(nspname);
+}
+
+/*
+ * detect_and_build_policy_metadata_json - Detect all RLS policies and build JSON metadata
+ *
+ * Queries pg_policy for all policies on the table and stores their definitions
+ * for documentation purposes (policies are permanently dropped, not restored)
+ *
+ * Returns a JSON string with policy definitions for storage in catalog metadata column
+ */
+static char *
+detect_and_build_policy_metadata_json(Oid relid)
+{
+	StringInfoData json;
+	StringInfoData sql;
+	int ret;
+	bool first_policy = true;
+	SPITupleTable *tuptable;
+	int i;
+
+	initStringInfo(&json);
+	initStringInfo(&sql);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in detect_and_build_policy_metadata_json")));
+
+	PG_TRY();
+	{
+		appendStringInfoString(&json, "{\"policies_dropped\": [");
+
+		/*
+		 * Query for all policies on this table
+		 * Note: pg_policy available in PostgreSQL 9.5+
+		 */
+		appendStringInfo(&sql,
+			"SELECT polname, "
+			"  CASE polcmd "
+			"    WHEN 'r' THEN 'SELECT' "
+			"    WHEN 'a' THEN 'INSERT' "
+			"    WHEN 'w' THEN 'UPDATE' "
+			"    WHEN 'd' THEN 'DELETE' "
+			"    WHEN '*' THEN 'ALL' "
+			"    ELSE 'UNKNOWN' "
+			"  END AS command, "
+			"  CASE "
+			"    WHEN polpermissive THEN 'PERMISSIVE' "
+			"    ELSE 'RESTRICTIVE' "
+			"  END AS permissive, "
+			"  pg_get_expr(polqual, polrelid) AS using_expr, "
+			"  pg_get_expr(polwithcheck, polrelid) AS check_expr, "
+			"  (SELECT array_to_string(ARRAY(SELECT rolname FROM pg_roles WHERE oid = ANY(polroles)), ', ')) AS roles "
+			"FROM pg_policy "
+			"WHERE polrelid = %u "
+			"ORDER BY polname", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+
+		/* pg_policy might not exist in older PostgreSQL versions - just close JSON */
+		if (ret == SPI_OK_SELECT)
+		{
+		tuptable = SPI_tuptable;
+		for (i = 0; i < SPI_processed; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			char *polname;
+			char *command;
+			char *permissive;
+			char *using_expr;
+			char *check_expr;
+			char *roles;
+			bool free_command = false;
+			bool free_permissive = false;
+			bool free_using_expr = false;
+			bool free_check_expr = false;
+			bool free_roles = false;
+			int fno_polname, fno_command, fno_permissive, fno_using_expr, fno_check_expr, fno_roles;
+
+			if (!first_policy)
+				appendStringInfoString(&json, ", ");
+			first_policy = false;
+
+			/* Get column numbers */
+			fno_polname = SPI_fnumber(tupdesc, "polname");
+			fno_command = SPI_fnumber(tupdesc, "command");
+			fno_permissive = SPI_fnumber(tupdesc, "permissive");
+			fno_using_expr = SPI_fnumber(tupdesc, "using_expr");
+			fno_check_expr = SPI_fnumber(tupdesc, "check_expr");
+			fno_roles = SPI_fnumber(tupdesc, "roles");
+
+			if (fno_polname < 0 || fno_command < 0 || fno_permissive < 0 ||
+				fno_using_expr < 0 || fno_check_expr < 0 || fno_roles < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("required column not found in policy query")));
+
+			/* Extract policy information */
+			datum = SPI_getbinval(tuple, tupdesc, fno_polname, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("polname is NULL in policy query")));
+			polname = NameStr(*DatumGetName(datum));
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_command, &isnull);
+			if (isnull)
+			{
+				command = "ALL";
+			}
+			else
+			{
+				command = TextDatumGetCString(datum);
+				free_command = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_permissive, &isnull);
+			if (isnull)
+			{
+				permissive = "PERMISSIVE";
+			}
+			else
+			{
+				permissive = TextDatumGetCString(datum);
+				free_permissive = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_using_expr, &isnull);
+			if (isnull)
+			{
+				using_expr = "";
+			}
+			else
+			{
+				using_expr = TextDatumGetCString(datum);
+				free_using_expr = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_check_expr, &isnull);
+			if (isnull)
+			{
+				check_expr = "";
+			}
+			else
+			{
+				check_expr = TextDatumGetCString(datum);
+				free_check_expr = true;
+			}
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_roles, &isnull);
+			if (isnull)
+			{
+				roles = "public";
+			}
+			else
+			{
+				roles = TextDatumGetCString(datum);
+				free_roles = true;
+			}
+
+			/* Build JSON object for this policy */
+			{
+				char *esc_polname = escape_json_string(polname);
+				char *esc_command = escape_json_string(command);
+				char *esc_permissive = escape_json_string(permissive);
+				char *esc_roles = escape_json_string(roles);
+				char *esc_using_expr = escape_json_string(using_expr);
+				char *esc_check_expr = escape_json_string(check_expr);
+
+				appendStringInfo(&json,
+					"{\"policy_name\": \"%s\", "
+					"\"command\": \"%s\", "
+					"\"permissive\": \"%s\", "
+					"\"roles\": \"%s\", "
+					"\"using\": \"%s\", "
+					"\"check\": \"%s\"}",
+					esc_polname,
+					esc_command,
+					esc_permissive,
+					esc_roles,
+					esc_using_expr,
+					esc_check_expr);
+
+				pfree(esc_polname);
+				pfree(esc_command);
+				pfree(esc_permissive);
+				pfree(esc_roles);
+				pfree(esc_using_expr);
+				pfree(esc_check_expr);
+			}
+
+			/* Free TextDatumGetCString allocations */
+			if (free_command)
+				pfree(command);
+			if (free_permissive)
+				pfree(permissive);
+			if (free_using_expr)
+				pfree(using_expr);
+			if (free_check_expr)
+				pfree(check_expr);
+			if (free_roles)
+				pfree(roles);
+		}
+		}  /* end if (ret == SPI_OK_SELECT) */
+
+		appendStringInfoString(&json, "]}");
+
+		pfree(sql.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	return json.data;
+}
+
+/*
+ * drop_policies_permanently - Drop all RLS policies on the table
+ *
+ * Similar to drop_triggers_permanently(), this permanently removes all policies
+ * before moving the table to pgtrashcan. Policy definitions are stored in metadata
+ * for documentation, but policies are not restored.
+ */
+static void
+drop_policies_permanently(Oid relid)
+{
+	StringInfoData sql;
+	StringInfoData drop_cmd;
+	int ret;
+	SPITupleTable *tuptable;
+	int i;
+	int policy_count = 0;
+	char *relname;
+	char *nspname;
+	MemoryContext caller_ctx;
+
+	/* Dynamic list for policy info before we start dropping them */
+	List *policies = NIL;
+
+	initStringInfo(&sql);
+	initStringInfo(&drop_cmd);
+
+	/* Get table name and schema name for DROP POLICY commands */
+	relname = get_rel_name(relid);
+	nspname = get_namespace_name(get_rel_namespace(relid));
+
+	if (!relname || !nspname)
+	{
+		elog(DEBUG1, "pgtrashcan: Table OID %u no longer exists, skipping policy drop", relid);
+		return;
+	}
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in drop_policies_permanently")));
+
+	PG_TRY();
+	{
+		/*
+		 * STEP 1: Collect all policy information
+		 */
+		appendStringInfo(&sql,
+			"SELECT polname "
+			"FROM pg_policy "
+			"WHERE polrelid = %u", relid);
+
+		ret = SPI_execute(sql.data, true, 0);
+
+		/* pg_policy might not exist in older PostgreSQL versions - just skip */
+		if (ret == SPI_OK_SELECT)
+		{
+		tuptable = SPI_tuptable;
+		policy_count = SPI_processed;
+
+		for (i = 0; i < policy_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			char *polname;
+			int fno_polname;
+			PolicyInfo *pinfo;
+
+			fno_polname = SPI_fnumber(tupdesc, "polname");
+			if (fno_polname < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("column 'polname' not found in policy deletion query")));
+
+			polname = NameStr(*DatumGetName(SPI_getbinval(tuple, tupdesc, fno_polname, &isnull)));
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("polname is NULL in policy deletion query")));
+
+			/* Allocate in caller's context so data survives SPI_finish */
+			{
+				MemoryContext spi_ctx = MemoryContextSwitchTo(caller_ctx);
+				pinfo = palloc(sizeof(PolicyInfo));
+				pinfo->polname = pstrdup(polname);
+				policies = lappend(policies, pinfo);
+				MemoryContextSwitchTo(spi_ctx);
+			}
+		}
+
+		/*
+		 * STEP 2: Drop all policies using stored info
+		 */
+		{
+			ListCell *lc;
+			foreach(lc, policies)
+			{
+				PolicyInfo *pinfo = (PolicyInfo *) lfirst(lc);
+
+				elog(DEBUG1, "pgtrashcan: Dropping policy: %s from %s.%s",
+					 pinfo->polname, nspname, relname);
+
+				resetStringInfo(&drop_cmd);
+				appendStringInfo(&drop_cmd, "DROP POLICY %s ON %s.%s",
+								 quote_identifier(pinfo->polname),
+								 quote_identifier(nspname),
+								 quote_identifier(relname));
+
+				ret = SPI_execute(drop_cmd.data, false, 0);
+				if (ret != SPI_OK_UTILITY)
+					ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("pgtrashcan: Failed to drop policy %s", pinfo->polname),
+							 errhint("Cannot move table to trash if policies cannot be dropped")));
+			}
+		}
+		}  /* end if (ret == SPI_OK_SELECT) */
+
+		/* Done with SPI - list data is in caller's memory context, safe to finish */
+		pfree(sql.data);
+		pfree(drop_cmd.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	if (policy_count > 0)
+	{
+		StringInfoData policy_names;
+		ListCell *lc;
+		bool first = true;
+
+		initStringInfo(&policy_names);
+
+		/* Build list of policy names */
+		foreach(lc, policies)
+		{
+			PolicyInfo *pinfo = (PolicyInfo *) lfirst(lc);
+			if (!first)
+				appendStringInfoString(&policy_names, ", ");
+			appendStringInfo(&policy_names, "%s", pinfo->polname);
+			first = false;
+		}
+
+		elog(NOTICE, "%d RLS polic%s permanently dropped (cannot be moved to trash): %s",
+			 policy_count, policy_count == 1 ? "y" : "ies", policy_names.data);
+		elog(NOTICE, "RLS policy definitions stored in pgtrashcan._trash_catalog.metadata");
+
+		pfree(policy_names.data);
+	}
+
+	/* Free allocated memory for policy info list */
+	{
+		ListCell *lc;
+		foreach(lc, policies)
+		{
+			PolicyInfo *pinfo = (PolicyInfo *) lfirst(lc);
+			pfree(pinfo->polname);
+		}
+		list_free_deep(policies);
+	}
+
+	/* Free table/schema names */
+	pfree(relname);
+	pfree(nspname);
+}
+
+/*
+ * is_sequence_owned - Check if a sequence is owned by a table column (SERIAL)
+ *
+ * Owned sequences cannot be moved independently - they move with their owner table.
+ * Returns true if the sequence is owned by a column.
+ */
+static bool
+is_sequence_owned(Oid seq_oid)
+{
+	StringInfoData sql;
+	int ret;
+	bool is_owned = false;
+
+	initStringInfo(&sql);
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in is_sequence_owned")));
+
+	PG_TRY();
+	{
+		/*
+		 * Check if this sequence has an 'a' (auto) or 'i' (internal) dependency
+		 * on a table column (refobjsubid > 0 means it's a column dependency)
+		 */
+		appendStringInfo(&sql,
+			"SELECT 1 FROM pg_depend "
+			"WHERE objid = %u "
+			"  AND deptype IN ('a', 'i') "
+			"  AND refobjsubid > 0 "
+			"LIMIT 1", seq_oid);
+
+		ret = SPI_execute(sql.data, true, 1);
+		if (ret == SPI_OK_SELECT && SPI_processed > 0)
+			is_owned = true;
+
+		pfree(sql.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	return is_owned;
+}
+
+/*
+ * move_single_object_to_trash - Move a single object (table or sequence) to pgtrashcan
+ *
+ * This is a helper function for CASCADE support.
+ * It handles moving one object and its indexes to the pgtrashcan schema.
+ * Note: Views and matviews are NOT moved to trash - they are dropped permanently.
+ * Note: Owned sequences (SERIAL columns) are skipped - they move with their owner table.
+ */
+static void
+move_single_object_to_trash(Oid relid, const char *orig_schema, const char *orig_name, char relkind, Oid parent_oid)
+{
+	AlterObjectSchemaStmt *alter_stmt;
+	PlannedStmt *wrapper;
+	RangeVar *rv;
+	char *trash_name;
+	TrashObjectType obj_type;
+	List *indexoidlist = NIL;
+	ListCell *lc;
+	Relation rel;
+
+	/* Determine object type */
+	switch (relkind)
+	{
+		case 'r':  /* ordinary table */
+			obj_type = relkind_to_trash_type(relkind);
+			break;
+		case 'S':  /* sequence */
+			/* Skip owned sequences - they move automatically with their owner table */
+			if (is_sequence_owned(relid))
+			{
+				elog(DEBUG1, "pgtrashcan: Skipping owned sequence %s.%s (moves with owner table)",
+					 orig_schema, orig_name);
+				return;
+			}
+			obj_type = relkind_to_trash_type(relkind);
+			break;
+		default:
+			ereport(WARNING, (errmsg("pgtrashcan: Unsupported relkind '%c' for CASCADE (only tables and sequences are moved to trash)", relkind)));
+			return;
+	}
+
+	elog(DEBUG1, "pgtrashcan CASCADE: Moving %s, orig_schema='%s', orig_name='%s', relid=%u",
+		 trash_obj_type_str(obj_type), orig_schema ? orig_schema : "NULL", orig_name ? orig_name : "NULL", relid);
+
+	/* Generate trash name */
+	trash_name = compose_trash_name(orig_name, relid);
+
+	/* Insert catalog entry for this object */
+	/* For CASCADE'd tables, parent_oid is the CASCADE parent (cascade_parent_oid) */
+	/* For indexes/sequences, we'll use dependent_on_oid later */
+	insert_catalog_entry(relid, orig_schema, orig_name, obj_type, InvalidOid, parent_oid, trash_name);
+
+	/* For tables, handle indexes, FKs, triggers, views, rules, policies */
+	if (relkind == 'r')
+	{
+		char *fk_metadata;
+		char *trigger_metadata;
+		char *view_metadata;
+		char *rule_metadata;
+		char *policy_metadata;
+
+		/* Get list of indexes before moving */
+		rel = RelationIdGetRelation(relid);
+		if (RelationIsValid(rel))
+		{
+			indexoidlist = RelationGetIndexList(rel);
+			RelationClose(rel);
+		}
+
+		/* Insert catalog entries for indexes */
+		foreach(lc, indexoidlist)
+		{
+			Oid indexoid = lfirst_oid(lc);
+			char *indexname = get_rel_name(indexoid);
+			char *index_trash_name;
+
+			if (indexname)
+			{
+				index_trash_name = compose_trash_name(indexname, indexoid);
+				/* Indexes are true dependents - use dependent_on_oid */
+				insert_catalog_entry(indexoid, orig_schema, indexname,
+									 TRASH_OBJ_INDEX, relid, InvalidOid, index_trash_name);
+				pfree(index_trash_name);
+				pfree(indexname);
+			}
+		}
+
+		/* Insert catalog entries for owned sequences */
+		{
+			StringInfoData seq_sql;
+			int seq_ret;
+			SPITupleTable *seq_tuptable;
+			int seq_count;
+			int seq_i;
+
+			if (SPI_connect() != SPI_OK_CONNECT)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("SPI_connect failed in CASCADE sequence tracking")));
+
+			initStringInfo(&seq_sql);
+
+			PG_TRY();
+			{
+				/* Query for sequences owned by this table */
+				appendStringInfo(&seq_sql,
+					"SELECT d.objid, c.relname "
+					"FROM pg_depend d "
+					"JOIN pg_class c ON c.oid = d.objid "
+					"WHERE d.refobjid = %u "
+					"  AND d.deptype IN ('a', 'i') "
+					"  AND c.relkind = 'S'",
+					relid);
+
+				seq_ret = SPI_execute(seq_sql.data, true, 0);
+				if (seq_ret != SPI_OK_SELECT)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("failed to query owned sequences in CASCADE")));
+
+				seq_tuptable = SPI_tuptable;
+				seq_count = SPI_processed;
+
+				elog(DEBUG1, "pgtrashcan CASCADE: Found %d owned sequence(s) to track", seq_count);
+
+				/* Insert catalog entries for each sequence */
+				for (seq_i = 0; seq_i < seq_count; seq_i++)
+				{
+					HeapTuple seq_tuple = seq_tuptable->vals[seq_i];
+					TupleDesc seq_tupdesc = seq_tuptable->tupdesc;
+					bool isnull;
+					Oid seq_oid;
+					char *seq_name;
+					char *seq_trash_name;
+					int fno_objid, fno_relname;
+
+					fno_objid = SPI_fnumber(seq_tupdesc, "objid");
+					fno_relname = SPI_fnumber(seq_tupdesc, "relname");
+
+					if (fno_objid < 0 || fno_relname < 0)
+						ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("required column not found in sequence query")));
+
+					seq_oid = DatumGetObjectId(SPI_getbinval(seq_tuple, seq_tupdesc, fno_objid, &isnull));
+					if (isnull)
+						ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("objid is NULL in sequence query")));
+
+					{
+						Datum seq_name_datum = SPI_getbinval(seq_tuple, seq_tupdesc, fno_relname, &isnull);
+						if (isnull)
+							ereport(ERROR,
+								(errcode(ERRCODE_INTERNAL_ERROR),
+								 errmsg("relname is NULL in sequence query")));
+						seq_name = pstrdup(NameStr(*DatumGetName(seq_name_datum)));
+					}
+
+					/* Generate trash name for sequence */
+					seq_trash_name = compose_trash_name(seq_name, seq_oid);
+
+					/* Insert catalog entry for sequence (it's a true dependent) */
+					insert_catalog_entry(seq_oid, orig_schema, seq_name,
+										 TRASH_OBJ_SEQUENCE, relid, InvalidOid, seq_trash_name);
+
+					pfree(seq_trash_name);
+					pfree(seq_name);
+				}
+
+				pfree(seq_sql.data);
+			}
+			PG_FINALLY();
+			{
+				SPI_finish();
+			}
+			PG_END_TRY();
+		}
+
+
+		/* Handle foreign keys */
+		fk_metadata = detect_and_build_fk_metadata_json(relid, orig_schema, orig_name);
+		drop_foreign_keys_permanently(relid, NIL);  /* CASCADE: processing single table at a time */
+
+		/* Handle triggers — detect and record in metadata, but keep them attached */
+		trigger_metadata = detect_and_build_trigger_metadata_json(relid);
+
+		/* Handle views */
+		view_metadata = detect_and_build_view_metadata_json(relid);
+		drop_views_permanently(relid);
+
+		/* Handle rules — permanently drop (rule actions reference external tables
+		 * by name and break restored table if those tables no longer exist) */
+		rule_metadata = detect_and_build_rule_metadata_json(relid);
+		drop_rules_permanently(relid);
+
+		/* Handle policies — permanently drop (silent deny-all on restore is dangerous) */
+		policy_metadata = detect_and_build_policy_metadata_json(relid);
+		drop_policies_permanently(relid);
+
+		/* Update catalog with complete metadata */
+		update_catalog_with_complete_metadata(trash_name, fk_metadata, trigger_metadata, view_metadata, rule_metadata, policy_metadata);
+
+		/* Free metadata strings */
+		if (fk_metadata) pfree(fk_metadata);
+		if (trigger_metadata) pfree(trigger_metadata);
+		if (view_metadata) pfree(view_metadata);
+		if (rule_metadata) pfree(rule_metadata);
+		if (policy_metadata) pfree(policy_metadata);
+
+		list_free(indexoidlist);
+	}
+
+	/* Create ALTER SCHEMA statement */
+	rv = makeNode(RangeVar);
+	rv->catalogname = NULL;
+	rv->schemaname = pstrdup(orig_schema);
+	rv->relname = pstrdup(orig_name);
+	rv->relpersistence = RELPERSISTENCE_PERMANENT;
+	rv->location = -1;
+
+	alter_stmt = makeNode(AlterObjectSchemaStmt);
+	alter_stmt->objectType = (relkind == 'S') ? OBJECT_SEQUENCE : OBJECT_TABLE;
+	alter_stmt->relation = rv;
+	alter_stmt->newschema = pstrdup(trashcan_nspname);
+	alter_stmt->missing_ok = false;
+
+	/* Wrap in PlannedStmt */
+	wrapper = makeNode(PlannedStmt);
+	wrapper->commandType = CMD_UTILITY;
+	wrapper->utilityStmt = (Node *) alter_stmt;
+	wrapper->canSetTag = false;
+
+	/* Execute ALTER SCHEMA */
+	elog(DEBUG1, "pgtrashcan CASCADE: About to execute ALTER SCHEMA for rv->schemaname='%s', rv->relname='%s', newschema='%s'",
+		 rv->schemaname ? rv->schemaname : "NULL",
+		 rv->relname ? rv->relname : "NULL",
+		 alter_stmt->newschema ? alter_stmt->newschema : "NULL");
+	(*prev_ProcessUtility)(wrapper, NULL, false, PROCESS_UTILITY_TOPLEVEL,
+						   NULL, NULL, NULL, NULL);
+
+	CommandCounterIncrement();
+
+	/* Rename the object in pgtrashcan schema */
+	rename_in_trash(relid, orig_name, trash_name, false);
+
+	/* For tables, rename indexes as well */
+	if (relkind == 'r')
+	{
+		rel = RelationIdGetRelation(relid);
+		if (RelationIsValid(rel))
+		{
+			indexoidlist = RelationGetIndexList(rel);
+			foreach(lc, indexoidlist)
+			{
+				Oid indexoid = lfirst_oid(lc);
+				char *indexname = get_rel_name(indexoid);
+				char *index_trash_name;
+
+				if (indexname)
+				{
+					index_trash_name = compose_trash_name(indexname, indexoid);
+					rename_in_trash(indexoid, indexname, index_trash_name, true);
+					pfree(index_trash_name);
+					pfree(indexname);
+				}
+			}
+			RelationClose(rel);
+			list_free(indexoidlist);
+		}
+
+		/* Rename owned sequences in pgtrashcan schema */
+		{
+			StringInfoData seq_sql;
+			int seq_ret;
+			SPITupleTable *seq_tuptable;
+			int seq_count;
+			int seq_i;
+
+			if (SPI_connect() != SPI_OK_CONNECT)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("SPI_connect failed in CASCADE sequence rename")));
+
+			initStringInfo(&seq_sql);
+
+			PG_TRY();
+			{
+				/* Query for sequences owned by this table */
+				appendStringInfo(&seq_sql,
+					"SELECT d.objid, c.relname "
+					"FROM pg_depend d "
+					"JOIN pg_class c ON c.oid = d.objid "
+					"WHERE d.refobjid = %u "
+					"  AND d.deptype IN ('a', 'i') "
+					"  AND c.relkind = 'S'",
+					relid);
+
+				seq_ret = SPI_execute(seq_sql.data, true, 0);
+				if (seq_ret != SPI_OK_SELECT)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("failed to query sequences for rename in CASCADE")));
+
+				seq_tuptable = SPI_tuptable;
+				seq_count = SPI_processed;
+
+				elog(DEBUG1, "pgtrashcan CASCADE: Renaming %d owned sequence(s)", seq_count);
+
+				/* Rename each sequence */
+				for (seq_i = 0; seq_i < seq_count; seq_i++)
+				{
+					HeapTuple seq_tuple = seq_tuptable->vals[seq_i];
+					TupleDesc seq_tupdesc = seq_tuptable->tupdesc;
+					bool isnull;
+					Oid seq_oid;
+					char *seq_name;
+					char *seq_trash_name;
+					int fno_objid, fno_relname;
+
+					fno_objid = SPI_fnumber(seq_tupdesc, "objid");
+					fno_relname = SPI_fnumber(seq_tupdesc, "relname");
+
+					if (fno_objid < 0 || fno_relname < 0)
+						ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("required column not found in sequence rename query")));
+
+					seq_oid = DatumGetObjectId(SPI_getbinval(seq_tuple, seq_tupdesc, fno_objid, &isnull));
+					if (isnull)
+						ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("objid is NULL in sequence rename query")));
+
+					{
+						Datum seq_name_datum = SPI_getbinval(seq_tuple, seq_tupdesc, fno_relname, &isnull);
+						if (isnull)
+							ereport(ERROR,
+								(errcode(ERRCODE_INTERNAL_ERROR),
+								 errmsg("relname is NULL in sequence rename query")));
+						seq_name = pstrdup(NameStr(*DatumGetName(seq_name_datum)));
+					}
+
+					/* Generate trash name for sequence */
+					seq_trash_name = compose_trash_name(seq_name, seq_oid);
+
+					/* Rename sequence in pgtrashcan schema */
+					rename_in_trash(seq_oid, seq_name, seq_trash_name, false);
+
+					pfree(seq_trash_name);
+					pfree(seq_name);
+				}
+
+				pfree(seq_sql.data);
+			}
+			PG_FINALLY();
+			{
+				SPI_finish();
+			}
+			PG_END_TRY();
+		}
+	}
+
+	elog(NOTICE, "%s \"%s.%s\" moved to trash as \"%s\"",
+		 trash_obj_type_str(obj_type), orig_schema, orig_name, trash_name);
+
+	pfree(trash_name);
+}
+
+/*
+ * move_dependent_objects_cascade - Find and move pg_depend-dependent objects to pgtrashcan
+ *
+ * This function implements CASCADE behavior by:
+ * 1. Querying pg_depend to find dependent objects (tables via normal deps, sequences via auto deps)
+ * 2. Recursively moving dependents (if they have dependents)
+ * 3. Moving the dependent to pgtrashcan
+ *
+ * Note: This does NOT handle FK-referencing child tables. PostgreSQL's native CASCADE
+ * only drops FK constraints on child tables, not the child tables themselves.
+ * FK constraint handling is done separately by drop_foreign_keys_permanently().
+ *
+ * Called when DROP TABLE ... CASCADE is executed.
+ */
+static void
+move_dependent_objects_cascade(Oid parent_oid, const char *parent_schema, const char *parent_name)
+{
+	StringInfoData sql;
+	int ret;
+	SPITupleTable *tuptable;
+	int i;
+	MemoryContext caller_ctx;
+
+	/*
+	 * Dynamic list for dependent info - store OIDs and relkind.
+	 * IMPORTANT: These must be allocated in the caller's memory context,
+	 * not in SPI's memory context, because SPI_finish() destroys its
+	 * memory context and we need to iterate the list afterwards.
+	 */
+	List *dependents = NIL;
+	int dependent_count = 0;
+
+	initStringInfo(&sql);
+
+	elog(DEBUG1, "pgtrashcan CASCADE: move_dependent_objects_cascade called for parent_oid=%u, parent_schema='%s', parent_name='%s'",
+		 parent_oid, parent_schema ? parent_schema : "NULL", parent_name ? parent_name : "NULL");
+
+	/* Save caller's memory context before SPI switches it */
+	caller_ctx = CurrentMemoryContext;
+
+	if (SPI_connect() != SPI_OK_CONNECT)
+		ereport(ERROR,
+			(errcode(ERRCODE_INTERNAL_ERROR),
+			 errmsg("SPI_connect failed in move_dependent_objects_cascade")));
+
+	PG_TRY();
+	{
+		/*
+		 * Query pg_depend for dependent objects (owned sequences, etc.)
+		 * FK-referencing child tables are NOT included here — see comment below.
+		 */
+		{
+			char *quoted_nspname = quote_literal_cstr(trashcan_nspname);
+			/*
+			 * Only query pg_depend for true object dependencies (owned sequences, etc.)
+			 *
+			 * We do NOT query pg_constraint here for FK-referencing child tables.
+			 * PostgreSQL's native CASCADE only drops FK constraints on child tables,
+			 * it does not drop the child tables themselves. FK constraint handling
+			 * is done separately by drop_foreign_keys_permanently().
+			 */
+			appendStringInfo(&sql,
+				"SELECT DISTINCT c.oid, c.relkind, "
+				"  CASE c.relkind "
+				"    WHEN 'r' THEN 1 "  /* tables first */
+				"    WHEN 'S' THEN 2 "  /* sequences second */
+				"    ELSE 3 END AS sort_order "
+				"FROM pg_depend d "
+				"JOIN pg_class c ON c.oid = d.objid "
+				"JOIN pg_namespace n ON n.oid = c.relnamespace "
+				"WHERE d.refobjid = %u "
+				"  AND d.deptype IN ('n', 'a') "
+				"  AND c.relkind IN ('r', 'S') "
+				"  AND c.oid >= 16384 "
+				"  AND n.nspname != %s "
+				"ORDER BY sort_order",
+				parent_oid, quoted_nspname);
+			pfree(quoted_nspname);
+		}
+
+		ret = SPI_execute(sql.data, true, 0);
+		if (ret != SPI_OK_SELECT)
+			ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("failed to query dependent objects for CASCADE")));
+
+		tuptable = SPI_tuptable;
+		dependent_count = SPI_processed;
+
+		elog(DEBUG1, "pgtrashcan: CASCADE found %d dependent object(s) for %s.%s",
+			 dependent_count, parent_schema, parent_name);
+
+		/*
+		 * Store OIDs and relkind in the caller's memory context so they
+		 * survive SPI_finish().  palloc() inside SPI allocates in SPI's
+		 * procCxt which is destroyed by SPI_finish().
+		 */
+		for (i = 0; i < dependent_count; i++)
+		{
+			HeapTuple tuple = tuptable->vals[i];
+			TupleDesc tupdesc = tuptable->tupdesc;
+			bool isnull;
+			Datum datum;
+			int fno_oid, fno_relkind;
+			DepInfo *dinfo;
+			MemoryContext spi_ctx;
+
+			fno_oid = SPI_fnumber(tupdesc, "oid");
+			fno_relkind = SPI_fnumber(tupdesc, "relkind");
+
+			if (fno_oid < 0 || fno_relkind < 0)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("required column not found in CASCADE dependent query")));
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_oid, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("oid is NULL in CASCADE dependent query")));
+
+			/* Switch to caller's context for allocation that must survive SPI_finish */
+			spi_ctx = MemoryContextSwitchTo(caller_ctx);
+
+			dinfo = palloc(sizeof(DepInfo));
+			dinfo->oid = DatumGetObjectId(datum);
+
+			/* Switch back to SPI context for the next SPI_getbinval call */
+			MemoryContextSwitchTo(spi_ctx);
+
+			datum = SPI_getbinval(tuple, tupdesc, fno_relkind, &isnull);
+			if (isnull)
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("relkind is NULL in CASCADE dependent query")));
+			dinfo->relkind = DatumGetChar(datum);
+
+			elog(DEBUG1, "pgtrashcan CASCADE: Found dependent %d: oid=%u, relkind='%c'",
+				 i, dinfo->oid, dinfo->relkind);
+
+			/* Switch to caller's context for lappend (allocates List cells) */
+			spi_ctx = MemoryContextSwitchTo(caller_ctx);
+			dependents = lappend(dependents, dinfo);
+			MemoryContextSwitchTo(spi_ctx);
+		}
+
+		/* Now safe to finish SPI - dependents list is in caller's memory context */
+		pfree(sql.data);
+	}
+	PG_FINALLY();
+	{
+		SPI_finish();
+	}
+	PG_END_TRY();
+
+	/* Now move all dependents - get names fresh from system caches */
+	{
+		ListCell *lc;
+		int dep_num = 0;
+		foreach(lc, dependents)
+		{
+			DepInfo *dinfo = (DepInfo *) lfirst(lc);
+			Oid dep_oid = dinfo->oid;
+			char dep_relkind = dinfo->relkind;
+			char *dep_name;
+			char *dep_schema;
+			Oid dep_namespace;
+
+			dep_num++;
+
+			/* Get fresh name and schema from system caches - these work outside SPI */
+			dep_name = get_rel_name(dep_oid);
+			dep_namespace = get_rel_namespace(dep_oid);
+			dep_schema = get_namespace_name(dep_namespace);
+
+			if (dep_name == NULL || dep_schema == NULL)
+			{
+				ereport(WARNING, (errmsg("CASCADE: Could not get name/schema for OID %u, skipping", dep_oid)));
+				continue;
+			}
+
+			elog(DEBUG1, "pgtrashcan CASCADE: Processing dependent %d/%d: schema='%s', name='%s', oid=%u, relkind='%c'",
+				 dep_num, dependent_count, dep_schema, dep_name, dep_oid, dep_relkind);
+
+			/* Recursively move this dependent's dependents first */
+			move_dependent_objects_cascade(dep_oid, dep_schema, dep_name);
+
+			/* Now move the dependent itself */
+			elog(DEBUG1, "pgtrashcan CASCADE: Moving dependent %s.%s to pgtrashcan", dep_schema, dep_name);
+			move_single_object_to_trash(dep_oid, dep_schema, dep_name, dep_relkind, parent_oid);
+			elog(DEBUG1, "pgtrashcan CASCADE: Completed moving %s.%s", dep_schema, dep_name);
+
+			/* Free the names allocated by get_rel_name and get_namespace_name */
+			pfree(dep_name);
+			pfree(dep_schema);
+		}
+	}
+
+	elog(DEBUG1, "pgtrashcan: CASCADE completed moving %d dependent(s) of %s.%s",
+		 dependent_count, parent_schema, parent_name);
+
+	/* Free the dependents list */
+	list_free_deep(dependents);
+}
+
+static void
+pgtrashcan_ProcessUtility(PlannedStmt *pstmt, const char *queryString,
+						   bool readOnlyTree,
+						   ProcessUtilityContext context,
+						   ParamListInfo params,
+						   QueryEnvironment *queryEnv,
+						   DestReceiver *dest,
+						   QueryCompletion *qc)
+{
+	Node *parsetree = pstmt->utilityStmt;
+	PlannedStmt *newpstmt = NULL;
+
+	elog(DEBUG1, "pgtrashcan: ProcessUtility called, nodeTag=%d", nodeTag(parsetree));
+
+	/*
+	 * Bypass all protections if extension is performing internal operations
+	 * or if we're inside CREATE EXTENSION / ALTER EXTENSION UPDATE
+	 * This prevents the extension from blocking its own catalog operations
+	 */
+	if (pgtrashcan_in_progress || creating_extension)
+	{
+		elog(DEBUG2, "pgtrashcan: Bypassing protections - internal operation in progress");
+		goto execute_utility;
+	}
+
+	/*
+	 * Block TRUNCATE on _trash_catalog
+	 * (INSERT/UPDATE/DELETE protection is handled by the pgtrashcan_protect_catalog trigger)
+	 * TRUNCATE is a utility command so it does go through ProcessUtility
+	 */
+	if (IsA(parsetree, TruncateStmt))
+	{
+		TruncateStmt *truncstmt = (TruncateStmt*)parsetree;
+		ListCell *lc;
+		foreach(lc, truncstmt->relations)
+		{
+			RangeVar *rv = (RangeVar*)lfirst(lc);
+			if (rv->schemaname && rv->relname &&
+				strcmp(rv->schemaname, trashcan_nspname) == 0 &&
+				strcmp(rv->relname, "_trash_catalog") == 0)
+			{
+				ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("direct modification of trash catalog table is not allowed"),
+					 errhint("Use pgtrashcan functions (restore, purge, rename) to manage trash")));
+			}
+		}
+	}
+
+	/*
+	 * Block ALTER TABLE operations on _trash_catalog
+	 * Schema changes to catalog table could break the extension
+	 */
+	if (IsA(parsetree, AlterTableStmt))
+	{
+		AlterTableStmt *stmt = (AlterTableStmt*)parsetree;
+		if (stmt->relation && stmt->relation->schemaname && stmt->relation->relname &&
+			strcmp(stmt->relation->schemaname, trashcan_nspname) == 0 &&
+			strcmp(stmt->relation->relname, "_trash_catalog") == 0)
+		{
+			ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("cannot alter trash catalog table structure"),
+				 errhint("pgtrashcan catalog schema is managed by the extension")));
+		}
+	}
+
+	/*
+	 * Block CREATE operations in trash schema
+	 * pgtrashcan schema is reserved for system-managed objects only
+	 */
+	if (IsA(parsetree, CreateStmt) ||      /* CREATE TABLE */
+		IsA(parsetree, IndexStmt) ||       /* CREATE INDEX */
+		IsA(parsetree, ViewStmt) ||        /* CREATE VIEW */
+		IsA(parsetree, CreateSeqStmt) ||   /* CREATE SEQUENCE */
+		IsA(parsetree, CreateTrigStmt) ||  /* CREATE TRIGGER */
+		IsA(parsetree, CreateFunctionStmt)) /* CREATE FUNCTION */
+	{
+		RangeVar *relation = NULL;
+		char *schema_name = NULL;
+
+		if (IsA(parsetree, CreateStmt))
+		{
+			relation = ((CreateStmt*)parsetree)->relation;
+			schema_name = relation ? relation->schemaname : NULL;
+		}
+		else if (IsA(parsetree, IndexStmt))
+		{
+			relation = ((IndexStmt*)parsetree)->relation;
+			schema_name = relation ? relation->schemaname : NULL;
+		}
+		else if (IsA(parsetree, ViewStmt))
+		{
+			relation = ((ViewStmt*)parsetree)->view;
+			schema_name = relation ? relation->schemaname : NULL;
+		}
+		else if (IsA(parsetree, CreateSeqStmt))
+		{
+			relation = ((CreateSeqStmt*)parsetree)->sequence;
+			schema_name = relation ? relation->schemaname : NULL;
+		}
+		else if (IsA(parsetree, CreateTrigStmt))
+		{
+			CreateTrigStmt *stmt = (CreateTrigStmt*)parsetree;
+			relation = stmt->relation;
+			schema_name = relation ? relation->schemaname : NULL;
+		}
+		/* For CreateFunctionStmt, we'll check if function is being created in pgtrashcan schema */
+		else if (IsA(parsetree, CreateFunctionStmt))
+		{
+			CreateFunctionStmt *stmt = (CreateFunctionStmt*)parsetree;
+			/* Check if any of the function names specify the pgtrashcan schema */
+			if (stmt->funcname && list_length(stmt->funcname) >= 2)
+			{
+				ListCell *lc = list_head(stmt->funcname);
+				schema_name = strVal(lfirst(lc));
+			}
+		}
+
+		if (schema_name && strcmp(schema_name, trashcan_nspname) == 0)
+		{
+			ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("cannot create objects in trash schema"),
+				 errhint("pgtrashcan schema is reserved for system use only")));
+		}
+	}
+
+	/*
+	 * Block ALTER operations on trash objects
+	 * Trashed objects should remain immutable until restored
+	 */
+	if (IsA(parsetree, AlterTableStmt))
+	{
+		AlterTableStmt *stmt = (AlterTableStmt*)parsetree;
+		if (stmt->relation && stmt->relation->schemaname &&
+			strcmp(stmt->relation->schemaname, trashcan_nspname) == 0)
+		{
+			ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("cannot alter trash objects: %s", stmt->relation->relname),
+				 errhint("Restore the table first to make changes")));
+		}
+	}
+
+	/*
+	 * Block ALTER ... SET SCHEMA on trash objects
+	 * Prevents users from moving objects out of pgtrashcan without catalog cleanup.
+	 * Use pgtrashcan_restore() or pgtrashcan_move_to_schema() instead.
+	 * SECURITY DEFINER functions (restore, move_to_schema) bypass this via
+	 * the pgtrashcan_internal_operation GUC.
+	 */
+	if (IsA(parsetree, AlterObjectSchemaStmt) && !pgtrashcan_internal_operation)
+	{
+		AlterObjectSchemaStmt *stmt = (AlterObjectSchemaStmt *) parsetree;
+		if (stmt->relation && stmt->relation->schemaname &&
+			strcmp(stmt->relation->schemaname, trashcan_nspname) == 0)
+		{
+			ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("cannot move trash objects directly"),
+				 errhint("Use pgtrashcan_restore() to restore to original schema, "
+						 "pgtrashcan_move_to_schema() to move to a different schema, "
+						 "or pgtrashcan_purge() to permanently delete.")));
+		}
+	}
+
+	/*
+	 * Block RENAME on trash objects
+	 * Prevents users from renaming objects in pgtrashcan directly, bypassing the catalog.
+	 * Use pgtrashcan_rename_in_trash() for legitimate renames.
+	 * SECURITY DEFINER functions bypass this via the pgtrashcan_internal_operation GUC.
+	 */
+	if (IsA(parsetree, RenameStmt) && !pgtrashcan_internal_operation)
+	{
+		RenameStmt *stmt = (RenameStmt *) parsetree;
+		if (stmt->relation && stmt->relation->schemaname &&
+			strcmp(stmt->relation->schemaname, trashcan_nspname) == 0)
+		{
+			ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("cannot rename trash objects directly"),
+				 errhint("Use pgtrashcan_rename_in_trash(trash_name, new_name) "
+						 "to change the restore name.")));
+		}
+	}
+
+	/*
+	 * Block TRUNCATE operations on trash objects
+	 * This would destroy data without proper catalog cleanup
+	 */
+	if (IsA(parsetree, TruncateStmt))
+	{
+		TruncateStmt *stmt = (TruncateStmt*)parsetree;
+		ListCell *lc;
+
+		foreach(lc, stmt->relations)
+		{
+			RangeVar *rv = (RangeVar*)lfirst(lc);
+			if (rv->schemaname && strcmp(rv->schemaname, trashcan_nspname) == 0)
+			{
+				ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("cannot truncate trash objects: %s", rv->relname),
+					 errhint("Use DROP to permanently delete or restore first")));
+			}
+		}
+	}
+
+	if (nodeTag(parsetree) == T_DropStmt)
+	{
+		DropStmt *stmt = (DropStmt *) parsetree;
+		int num_tables;
+		ListCell *obj_lc;
+		int table_num = 0;
+
+		elog(DEBUG1, "pgtrashcan: DROP statement detected, removeType=%d", stmt->removeType);
+
+		/*
+		 * Protect pgtrashcan schema from being dropped by users
+		 * PostgreSQL also has extension dependency protection, but we provide
+		 * a more helpful error message here
+		 */
+	if (stmt->removeType == OBJECT_SCHEMA)
+	{
+		ListCell *lc;
+
+		elog(DEBUG1, "pgtrashcan: DROP SCHEMA detected in ProcessUtility hook");
+		elog(DEBUG1, "pgtrashcan: Number of schemas: %d", list_length(stmt->objects));
+
+		/* Only protect pgtrashcan schema if extension is still installed */
+		if (!extension_exists())
+		{
+			elog(DEBUG1, "pgtrashcan: Extension not installed, allowing DROP SCHEMA to proceed");
+			/* Fall through to standard_ProcessUtility */
+		}
+		else
+		{
+			foreach(lc, stmt->objects)
+			{
+				char *schema_name;
+				Node *object = (Node *) lfirst(lc);
+
+				/*
+				 * For DROP SCHEMA, objects are String nodes directly,
+				 * not Lists like DROP TABLE uses
+				 */
+				if (IsA(object, String))
+				{
+					schema_name = strVal(object);
+					elog(DEBUG1, "pgtrashcan: Schema name=\"%s\", trashcan_nspname=\"%s\"",
+						schema_name, trashcan_nspname);
+
+					if (strcmp(schema_name, trashcan_nspname) == 0)
+					{
+						elog(DEBUG1, "pgtrashcan: MATCH! Throwing error");
+						ereport(ERROR,
+							(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+							 errmsg("cannot drop trash schema \"%s\"", trashcan_nspname),
+							 errdetail("The \"%s\" schema is managed by pgtrashcan extension", trashcan_nspname),
+							 errhint("Use pgtrashcan_purge_all() to empty trash, or DROP EXTENSION pgtrashcan CASCADE to uninstall")));
+					}
+				}
+				else if (IsA(object, List))
+				{
+					/* Handle qualified schema names (schema.object) if they exist */
+					List *objname = (List *) object;
+					if (list_length(objname) >= 1)
+					{
+						schema_name = strVal(linitial(objname));
+						elog(DEBUG1, "pgtrashcan: Qualified schema name=\"%s\"", schema_name);
+
+						if (strcmp(schema_name, trashcan_nspname) == 0)
+						{
+							ereport(ERROR,
+								(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+								 errmsg("cannot drop trash schema \"%s\"", trashcan_nspname),
+								 errdetail("The \"%s\" schema is managed by pgtrashcan extension", trashcan_nspname),
+								 errhint("Use pgtrashcan_purge_all() to empty trash, or DROP EXTENSION pgtrashcan CASCADE to uninstall")));
+						}
+					}
+				}
+				else
+				{
+					elog(DEBUG1, "pgtrashcan: Unexpected object type in DROP SCHEMA: %d", nodeTag(object));
+				}
+			}
+		}  /* end else (extension exists) */
+
+		/* Schema is not pgtrashcan - allow standard DROP SCHEMA to proceed */
+	}
+
+	if (stmt->removeType == OBJECT_TABLE)
+	{
+		List *drop_oids = NIL;  /* List of OIDs being dropped in this statement */
+		List *temp_table_objects = NIL;  /* Temp tables to drop separately (can't be trashed) */
+		bool has_trash_deletions = false;  /* Track if any tables need permanent deletion from pgtrashcan */
+		bool has_non_trash_tables = false; /* Track if any tables are outside pgtrashcan */
+		ListCell *collect_lc;
+
+		num_tables = list_length(stmt->objects);
+		elog(DEBUG1, "pgtrashcan: DROP TABLE detected with %d table(s), intercepting", num_tables);
+
+		/*
+		 * FIRST PASS: Collect all table OIDs being dropped
+		 * This allows us to exclude tables in the same DROP statement from dependency checks
+		 * Note: DROP TABLE parent, child succeeds even when child has FK to parent (This matches PostgreSQL default behavior)
+		 */
+		foreach(collect_lc, stmt->objects)
+		{
+			RangeVar *r = makeRangeVarFromAnyName(lfirst(collect_lc));
+			Oid relid;
+
+			/* Track if in pgtrashcan schema (permanent deletion) */
+			if (r->schemaname && strcmp(r->schemaname, trashcan_nspname) == 0)
+			{
+				has_trash_deletions = true;
+				continue;
+			}
+
+			has_non_trash_tables = true;
+
+			/* Get OID without locking (NoLock - we'll lock properly in second pass) */
+			relid = RangeVarGetRelid(r, NoLock, stmt->missing_ok);
+			if (OidIsValid(relid))
+			{
+				drop_oids = lappend_oid(drop_oids, relid);
+				elog(DEBUG1, "pgtrashcan: Collected OID %u for dependency exclusion", relid);
+			}
+		}
+
+		/*
+		 * Block mixed drops: cannot combine pgtrashcan and non-pgtrashcan tables in one statement.
+		 * e.g., DROP TABLE public.foo, pgtrashcan.bar_$12345 is not allowed.
+		 * This avoids corruption where the original statement is passed to
+		 * prev_ProcessUtility with already-moved tables still in its object list.
+		 */
+		if (has_trash_deletions && has_non_trash_tables)
+		{
+			list_free(drop_oids);
+			ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot mix regular tables and pgtrashcan objects in a single DROP TABLE statement"),
+				 errhint("Drop regular tables and pgtrashcan objects in separate statements.")));
+		}
+
+		/*
+		 * If pgtrashcan.enabled is false, skip the trash-can transformation for
+		 * non-pgtrashcan tables and let standard PostgreSQL handle the DROP. pgtrashcan
+		 * protections fired above remain active. Pure DROPs of pgtrashcan objects
+		 * (has_trash_deletions only) continue through the existing permanent
+		 * purge path below.
+		 */
+		if (!pgtrashcan_enabled && has_non_trash_tables)
+		{
+			list_free(drop_oids);
+			elog(DEBUG1, "pgtrashcan: disabled via GUC, falling through to standard DROP");
+			goto execute_utility;
+		}
+
+		/*
+		 * SECOND PASS: Loop through all tables and move them to pgtrashcan
+		 * This handles both single-table: DROP TABLE t1;
+		 * and multi-table: DROP TABLE t1, t2, t3;
+		 */
+		foreach(obj_lc, stmt->objects)
+		{
+			RangeVar *r;
+			AlterObjectSchemaStmt *newstmt = makeNode(AlterObjectSchemaStmt);
+
+			table_num++;
+			elog(DEBUG1, "pgtrashcan: Processing table %d of %d", table_num, num_tables);
+
+			r = makeRangeVarFromAnyName(lfirst(obj_lc));
+			r->alias = NULL;
+
+			/*
+			 * Protect _trash_catalog table from being dropped by users
+			 * This would destroy all recovery metadata
+			 */
+			if (r->schemaname && r->relname &&
+				strcmp(r->schemaname, trashcan_nspname) == 0 &&
+				strcmp(r->relname, "_trash_catalog") == 0)
+			{
+				ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("cannot drop trash catalog table"),
+					 errhint("pgtrashcan catalog is managed automatically by the extension")));
+			}
+
+			newstmt->objectType = stmt->removeType;
+			newstmt->newschema = pstrdup(trashcan_nspname);
+			newstmt->missing_ok = stmt->missing_ok;
+
+			newstmt->relation = r;
+
+		if (!r->schemaname || strcmp(r->schemaname, trashcan_nspname) != 0)
+		{
+			Oid relid;
+			Oid nspoid;
+			const char *orig_schema;
+			char *trash_name;
+			char *fk_metadata;      /* FK metadata JSON */
+			char *trigger_metadata; /* Trigger metadata JSON */
+			char *view_metadata;    /* View metadata JSON */
+			char *rule_metadata;    /* Rule metadata JSON */
+			char *policy_metadata;  /* RLS Policy metadata JSON */
+			List *indexoidlist;
+			ListCell *lc;
+			Relation rel;
+
+			elog(DEBUG1, "pgtrashcan: Converting DROP to ALTER SCHEMA, moving to pgtrashcan");
+
+			/* Get the table OID and lock it */
+			relid = RangeVarGetRelid(r, AccessExclusiveLock, stmt->missing_ok);
+			if (!OidIsValid(relid))
+			{
+				/*
+				 * Table doesn't exist and missing_ok is true.
+				 * Emit the standard PostgreSQL notice and skip to next table.
+				 * We must NOT goto execute_utility here because in a multi-table
+				 * DROP (e.g., DROP TABLE IF EXISTS missing, real), that would
+				 * pass the entire original DropStmt to the standard handler,
+				 * permanently dropping remaining tables instead of moving to trash.
+				 */
+				ereport(NOTICE,
+					(errmsg("table \"%s\" does not exist, skipping",
+							r->relname)));
+				continue;
+			}
+
+			/* Block partitioned tables - not yet supported */
+			if (get_rel_relkind(relid) == RELKIND_PARTITIONED_TABLE)
+			{
+				ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("pgtrashcan does not support partitioned tables yet"),
+					 errhint("Use DROP TABLE without pgtrashcan loaded to drop partitioned tables.")));
+			}
+
+			/* Block partition children - not yet supported */
+			if (get_rel_relispartition(relid))
+			{
+				ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("pgtrashcan does not support partition children yet"),
+					 errhint("Use DROP TABLE without pgtrashcan loaded to drop partition children.")));
+			}
+
+			/* Block foreign tables - not supported */
+			if (get_rel_relkind(relid) == RELKIND_FOREIGN_TABLE)
+			{
+				ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("pgtrashcan does not support foreign tables"),
+					 errhint("Use DROP FOREIGN TABLE to drop foreign tables.")));
+			}
+
+			/* Get the actual schema from the table's OID */
+			nspoid = get_rel_namespace(relid);
+
+			/* Skip temp tables — they can't be moved across schemas */
+			if (isTempNamespace(nspoid))
+			{
+				ereport(NOTICE,
+					(errmsg("temporary table \"%s\" will be permanently dropped (not moved to trash)",
+							r->relname)));
+				temp_table_objects = lappend(temp_table_objects, lfirst(obj_lc));
+				continue;
+			}
+
+			orig_schema = get_namespace_name(nspoid);
+
+			if (!orig_schema)
+			{
+				elog(DEBUG1, "pgtrashcan: Schema for table OID %u no longer exists, skipping", relid);
+				continue;
+			}
+
+			/*
+			 * Note: We rely on PostgreSQL's native locking for schema protection:
+			 * - RangeVarGetRelid() acquires AccessExclusiveLock on the table
+			 * - DROP SCHEMA will block when trying to drop this locked table
+			 * - This allows concurrent DROP TABLE operations on different tables
+			 */
+
+			/* Generate trash name */
+			trash_name = compose_trash_name(r->relname, relid);
+
+			elog(DEBUG1, "pgtrashcan: Table %s.%s (OID %u) will be moved to pgtrashcan as %s",
+				 orig_schema, r->relname, relid, trash_name);
+
+			/*
+			 * Check for dependencies BEFORE making any changes
+			 * - If DROP_CASCADE: We'll move dependents after setting up catalog
+			 * - Otherwise (default or RESTRICT): Block the drop if any dependencies exist
+			 */
+			elog(DEBUG1, "pgtrashcan: stmt->behavior = %d, DROP_CASCADE = %d", stmt->behavior, DROP_CASCADE);
+			if (stmt->behavior != DROP_CASCADE)
+			{
+				elog(DEBUG1, "pgtrashcan: Running dependency checks");
+				/* RESTRICT (or default): Block if any dependent objects exist (views, tables with FKs, etc.) */
+				/* Pass drop_oids to exclude tables in the same DROP statement from dependency checks */
+				check_dependent_objects(relid, r->relname, orig_schema, drop_oids);
+				/* Also check for incoming foreign key constraints from other tables */
+				check_incoming_fk_dependencies(relid, r->relname, orig_schema, drop_oids);
+			}
+			else
+			{
+				elog(DEBUG1, "pgtrashcan: Skipping dependency checks (CASCADE mode)");
+			}
+
+			parsetree = (Node *) newstmt;
+			/* Create a new PlannedStmt wrapping the rewritten statement */
+			newpstmt = makeNode(PlannedStmt);
+			newpstmt->commandType = CMD_UTILITY;
+			newpstmt->canSetTag = pstmt->canSetTag;
+			newpstmt->utilityStmt = (Node *) newstmt;
+			newpstmt->stmt_location = pstmt->stmt_location;
+			newpstmt->stmt_len = pstmt->stmt_len;
+
+			/* Create pgtrashcan schema and catalog if needed */
+			create_trashcan_schema();
+			ensure_trash_catalog_exists();
+
+			/* Insert catalog entry for TABLE - this is the main table being dropped */
+			insert_catalog_entry(relid, orig_schema, r->relname, TRASH_OBJ_TABLE, InvalidOid, InvalidOid, trash_name);
+
+			/*
+			 * CASCADE support - move dependent objects
+			 */
+			if (stmt->behavior == DROP_CASCADE)
+			{
+				/* CASCADE: Move dependent objects to pgtrashcan first */
+				elog(DEBUG1, "pgtrashcan: DROP CASCADE detected, moving dependents first");
+				move_dependent_objects_cascade(relid, orig_schema, r->relname);
+				elog(DEBUG1, "pgtrashcan: CASCADE recursion completed");
+
+				/*
+				 * Make CASCADE changes visible to subsequent queries
+				 * This ensures FK drops and table moves done during CASCADE
+				 * are visible when we process the parent table's FKs
+				 */
+				CommandCounterIncrement();
+			}
+
+			/*
+			 * Foreign Key, Trigger, View, Rule, and Policy Handling
+			 * Detect all FK constraints, triggers, views, rules, and RLS policies, and store them in the metadata
+			 * All are permanently dropped before moving to pgtrashcan
+			 */
+			fk_metadata = detect_and_build_fk_metadata_json(relid, orig_schema, r->relname);
+			drop_foreign_keys_permanently(relid, drop_oids);  /* Pass drop_oids to skip incoming FKs from tables in same DROP */
+
+			trigger_metadata = detect_and_build_trigger_metadata_json(relid);
+
+			view_metadata = detect_and_build_view_metadata_json(relid);
+			drop_views_permanently(relid);
+
+			rule_metadata = detect_and_build_rule_metadata_json(relid);
+			drop_rules_permanently(relid);
+
+			policy_metadata = detect_and_build_policy_metadata_json(relid);
+			drop_policies_permanently(relid);
+
+			/* Get list of indexes on this table */
+			rel = RelationIdGetRelation(relid);
+			if (!RelationIsValid(rel))
+				ereport(ERROR,
+					(errcode(ERRCODE_INTERNAL_ERROR),
+					 errmsg("could not open relation with OID %u", relid)));
+
+			indexoidlist = RelationGetIndexList(rel);
+
+			/* Insert catalog entries for each INDEX */
+			foreach(lc, indexoidlist)
+			{
+				Oid indexoid = lfirst_oid(lc);
+				char *indexname = get_rel_name(indexoid);
+				char *index_trash_name;
+
+				if (indexname)
+				{
+					index_trash_name = compose_trash_name(indexname, indexoid);
+					/* Indexes are true dependents - use dependent_on_oid */
+					insert_catalog_entry(indexoid, orig_schema, indexname,
+									   TRASH_OBJ_INDEX, relid, InvalidOid, index_trash_name);
+					pfree(index_trash_name);
+					pfree(indexname);
+				}
+			}
+
+			RelationClose(rel);
+			list_free(indexoidlist);
+
+			/*
+			 * Get sequences owned by this table (SERIAL columns) and insert catalog entries
+			 * These will automatically move to pgtrashcan with the table (via OWNED BY dependency)
+			 */
+			{
+				StringInfoData seq_sql;
+				int seq_ret;
+				SPITupleTable *seq_tuptable;
+				int seq_count;
+				int seq_i;
+
+				if (SPI_connect() != SPI_OK_CONNECT)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("SPI_connect failed while querying owned sequences")));
+
+				initStringInfo(&seq_sql);
+
+				PG_TRY();
+				{
+					/* Query for sequences owned by this table (dependency type 'a' or 'i') */
+					appendStringInfo(&seq_sql,
+						"SELECT d.objid, c.relname "
+						"FROM pg_depend d "
+						"JOIN pg_class c ON c.oid = d.objid "
+						"WHERE d.refobjid = %u "
+						"  AND d.deptype IN ('a', 'i') "
+						"  AND c.relkind = 'S'",
+						relid);
+
+					elog(DEBUG1, "pgtrashcan: Querying owned sequences BEFORE move: %s", seq_sql.data);
+
+					seq_ret = SPI_execute(seq_sql.data, true, 0);
+					if (seq_ret != SPI_OK_SELECT)
+						ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("failed to query owned sequences")));
+
+					seq_tuptable = SPI_tuptable;
+					seq_count = SPI_processed;
+
+					elog(DEBUG1, "pgtrashcan: Found %d owned sequence(s) to track", seq_count);
+
+					/* Insert catalog entries for each sequence */
+					for (seq_i = 0; seq_i < seq_count; seq_i++)
+					{
+						HeapTuple seq_tuple = seq_tuptable->vals[seq_i];
+						TupleDesc seq_tupdesc = seq_tuptable->tupdesc;
+						bool isnull;
+						Oid seq_oid;
+						char *seq_name;
+						char *seq_trash_name;
+						int fno_objid, fno_relname;
+
+						fno_objid = SPI_fnumber(seq_tupdesc, "objid");
+						fno_relname = SPI_fnumber(seq_tupdesc, "relname");
+
+						if (fno_objid < 0 || fno_relname < 0)
+							ereport(ERROR,
+								(errcode(ERRCODE_INTERNAL_ERROR),
+								 errmsg("required column not found in owned sequence query")));
+
+						seq_oid = DatumGetObjectId(SPI_getbinval(seq_tuple, seq_tupdesc, fno_objid, &isnull));
+						if (isnull)
+							ereport(ERROR,
+								(errcode(ERRCODE_INTERNAL_ERROR),
+								 errmsg("objid is NULL in owned sequence query")));
+
+						{
+							Datum seq_name_datum = SPI_getbinval(seq_tuple, seq_tupdesc, fno_relname, &isnull);
+							if (isnull)
+								ereport(ERROR,
+									(errcode(ERRCODE_INTERNAL_ERROR),
+									 errmsg("relname is NULL in owned sequence query")));
+							seq_name = pstrdup(NameStr(*DatumGetName(seq_name_datum)));
+						}
+
+						elog(DEBUG1, "pgtrashcan: Tracking owned sequence: %s (OID %u)", seq_name, seq_oid);
+
+						/* Generate trash name for sequence */
+						seq_trash_name = compose_trash_name(seq_name, seq_oid);
+
+						/* Insert catalog entry for sequence (it's a true dependent) */
+						insert_catalog_entry(seq_oid, orig_schema, seq_name,
+											 TRASH_OBJ_SEQUENCE, relid, InvalidOid, seq_trash_name);
+
+						pfree(seq_trash_name);
+						pfree(seq_name);
+					}
+
+					pfree(seq_sql.data);
+				}
+				PG_FINALLY();
+				{
+					SPI_finish();
+				}
+				PG_END_TRY();
+			}
+
+			/* Execute the ALTER SCHEMA (this moves table + indexes + sequences + constraints automatically) */
+			elog(DEBUG1, "pgtrashcan: Executing ALTER TABLE SET SCHEMA");
+			(*prev_ProcessUtility)(newpstmt ? newpstmt : pstmt, queryString, readOnlyTree, context, params, queryEnv, dest, qc);
+
+
+			/* CRITICAL: Make ALTER TABLE SET SCHEMA visible */
+			CommandCounterIncrement();
+			elog(DEBUG1, "pgtrashcan: Schema change made visible");
+			/* Now rename the table in pgtrashcan schema */
+			rename_in_trash(relid, r->relname, trash_name, false);
+
+			/* Rename indexes in pgtrashcan schema */
+			rel = RelationIdGetRelation(relid);
+			if (!RelationIsValid(rel))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_TABLE),
+						 errmsg("could not open relation with OID %u after moving it to the pgtrashcan schema",
+								relid)));
+			indexoidlist = RelationGetIndexList(rel);
+			foreach(lc, indexoidlist)
+			{
+				Oid indexoid = lfirst_oid(lc);
+				char *indexname = get_rel_name(indexoid);
+				char *index_trash_name;
+
+				if (indexname)
+				{
+					index_trash_name = compose_trash_name(indexname, indexoid);
+					rename_in_trash(indexoid, indexname, index_trash_name, true);
+					pfree(index_trash_name);
+					pfree(indexname);
+				}
+			}
+			RelationClose(rel);
+
+			list_free(indexoidlist);
+
+			/* Rename owned sequences in pgtrashcan schema */
+			{
+				StringInfoData seq_sql;
+				int seq_ret;
+				SPITupleTable *seq_tuptable;
+				int seq_count;
+				int seq_i;
+
+				if (SPI_connect() != SPI_OK_CONNECT)
+					ereport(ERROR,
+						(errcode(ERRCODE_INTERNAL_ERROR),
+						 errmsg("SPI_connect failed while querying sequences for rename")));
+
+				initStringInfo(&seq_sql);
+
+				PG_TRY();
+				{
+					/* Query for sequences owned by this table (they're now in pgtrashcan schema) */
+					appendStringInfo(&seq_sql,
+						"SELECT d.objid, c.relname "
+						"FROM pg_depend d "
+						"JOIN pg_class c ON c.oid = d.objid "
+						"WHERE d.refobjid = %u "
+						"  AND d.deptype IN ('a', 'i') "
+						"  AND c.relkind = 'S'",
+						relid);
+
+					seq_ret = SPI_execute(seq_sql.data, true, 0);
+					if (seq_ret != SPI_OK_SELECT)
+						ereport(ERROR,
+							(errcode(ERRCODE_INTERNAL_ERROR),
+							 errmsg("failed to query sequences for rename")));
+
+					seq_tuptable = SPI_tuptable;
+					seq_count = SPI_processed;
+
+					elog(DEBUG1, "pgtrashcan: Renaming %d owned sequence(s)", seq_count);
+
+					/* Rename each sequence */
+					for (seq_i = 0; seq_i < seq_count; seq_i++)
+					{
+						HeapTuple seq_tuple = seq_tuptable->vals[seq_i];
+						TupleDesc seq_tupdesc = seq_tuptable->tupdesc;
+						bool isnull;
+						Oid seq_oid;
+						char *seq_name;
+						char *seq_trash_name;
+						int fno_objid, fno_relname;
+
+						fno_objid = SPI_fnumber(seq_tupdesc, "objid");
+						fno_relname = SPI_fnumber(seq_tupdesc, "relname");
+
+						if (fno_objid < 0 || fno_relname < 0)
+							ereport(ERROR,
+								(errcode(ERRCODE_INTERNAL_ERROR),
+								 errmsg("required column not found in sequence rename query")));
+
+						seq_oid = DatumGetObjectId(SPI_getbinval(seq_tuple, seq_tupdesc, fno_objid, &isnull));
+						if (isnull)
+							ereport(ERROR,
+								(errcode(ERRCODE_INTERNAL_ERROR),
+								 errmsg("objid is NULL in sequence rename query")));
+
+						{
+							Datum seq_name_datum = SPI_getbinval(seq_tuple, seq_tupdesc, fno_relname, &isnull);
+							if (isnull)
+								ereport(ERROR,
+									(errcode(ERRCODE_INTERNAL_ERROR),
+									 errmsg("relname is NULL in sequence rename query")));
+							seq_name = pstrdup(NameStr(*DatumGetName(seq_name_datum)));
+						}
+
+						/* Generate trash name for sequence */
+						seq_trash_name = compose_trash_name(seq_name, seq_oid);
+
+						elog(DEBUG1, "pgtrashcan: Renaming sequence %s to %s", seq_name, seq_trash_name);
+
+						/* Rename sequence in pgtrashcan schema */
+						rename_in_trash(seq_oid, seq_name, seq_trash_name, false);
+
+						pfree(seq_trash_name);
+						pfree(seq_name);
+					}
+
+					pfree(seq_sql.data);
+				}
+				PG_FINALLY();
+				{
+					SPI_finish();
+				}
+				PG_END_TRY();
+			}
+
+
+			/* Update catalog with complete metadata (FK + triggers + views + rules + policies) */
+			update_catalog_with_complete_metadata(trash_name, fk_metadata, trigger_metadata, view_metadata, rule_metadata, policy_metadata);
+
+			/* Free metadata strings */
+			if (fk_metadata) pfree(fk_metadata);
+			if (trigger_metadata) pfree(trigger_metadata);
+			if (view_metadata) pfree(view_metadata);
+			if (rule_metadata) pfree(rule_metadata);
+			if (policy_metadata) pfree(policy_metadata);
+
+			elog(NOTICE, "Table \"%s.%s\" moved to trash as \"%s\"",
+				 orig_schema, r->relname, trash_name);
+
+			pfree(trash_name);
+			/* Continue to next table in multi-table DROP */
+		}
+		else
+		{
+			/* Dropping from pgtrashcan - permanent deletion */
+			elog(NOTICE, "Permanently dropping object from pgtrashcan: %s", r->relname);
+			delete_catalog_entry_by_trash_name(r->relname);
+			has_trash_deletions = true;  /* Mark that we need to execute actual DROP */
+
+			/*
+			 * For tables in pgtrashcan, we need PostgreSQL to execute the DROP
+			 * We'll fall through to execute_utility after processing all tables
+			 */
+		}
+		}  /* end foreach table */
+
+		/*
+		 * Drop any temp tables that were skipped above.
+		 * Temp tables can't be moved to pgtrashcan (cross-schema move not allowed for
+		 * temp objects), so we construct a separate DropStmt and execute it directly.
+		 */
+		if (temp_table_objects != NIL)
+		{
+			DropStmt *temp_drop = makeNode(DropStmt);
+			temp_drop->removeType = OBJECT_TABLE;
+			temp_drop->missing_ok = stmt->missing_ok;
+			temp_drop->behavior = stmt->behavior;
+			temp_drop->concurrent = stmt->concurrent;
+			temp_drop->objects = temp_table_objects;
+
+			pgtrashcan_in_progress = true;
+			PG_TRY();
+			{
+				PlannedStmt *temp_pstmt = makeNode(PlannedStmt);
+				temp_pstmt->commandType = CMD_UTILITY;
+				temp_pstmt->canSetTag = pstmt->canSetTag;
+				temp_pstmt->utilityStmt = (Node *) temp_drop;
+				temp_pstmt->stmt_location = pstmt->stmt_location;
+				temp_pstmt->stmt_len = pstmt->stmt_len;
+				(*prev_ProcessUtility)(temp_pstmt, queryString, readOnlyTree, context, params, queryEnv, dest, qc);
+			}
+			PG_FINALLY();
+			{
+				pgtrashcan_in_progress = false;
+			}
+			PG_END_TRY();
+		}
+
+		/*
+		 * All tables processed.
+		 * - If all tables were moved to trash: return (don't execute DROP)
+		 * - If any tables were in pgtrashcan (permanent deletion): fall through to execute DROP
+		 */
+		if (!has_trash_deletions)
+		{
+			list_free(drop_oids);
+			return;  /* All tables moved to trash - don't execute original DROP */
+		}
+		/* else: has_trash_deletions == true, fall through to execute_utility */
+		list_free(drop_oids);
+	}
+}
+
+execute_utility:
+	(*prev_ProcessUtility)(newpstmt ? newpstmt : pstmt, queryString, readOnlyTree, context, params, queryEnv, dest, qc);
+}
diff --git a/contrib/pgtrashcan/pgtrashcan.control b/contrib/pgtrashcan/pgtrashcan.control
new file mode 100644
index 00000000000..fed0b56e1ce
--- /dev/null
+++ b/contrib/pgtrashcan/pgtrashcan.control
@@ -0,0 +1,4 @@
+# pgtrashcan extension
+comment = 'PostgreSQL trash can for tables - intercepts DROP TABLE and allows recovery'
+default_version = '1.0'
+relocatable = false
diff --git a/contrib/pgtrashcan/sql/pgtrashcan.sql b/contrib/pgtrashcan/sql/pgtrashcan.sql
new file mode 100644
index 00000000000..3f243a96059
--- /dev/null
+++ b/contrib/pgtrashcan/sql/pgtrashcan.sql
@@ -0,0 +1,1402 @@
+-- pgtrashcan regression test suite
+-- Tests: DROP TABLE interception, restore, purge, CASCADE, metadata, protections
+--
+-- NOTE: This test relies on shared_preload_libraries = 'pgtrashcan'
+-- being set in postgresql.conf. The pg_regress framework creates a
+-- temporary database for testing.
+--
+-- TEST SECTIONS:
+--   Section 1: Core - Basic DROP and Restore
+--   Section 2: Indexes and Sequences
+--   Section 3: CASCADE Behavior
+--   Section 4: Metadata Capture
+--   Section 5: Restore Advanced (versions, schemas, conflicts)
+--   Section 6: Purge Operations
+--   Section 7: DROP TABLE Variants (IF EXISTS, multi-table, schemas)
+--   Section 8: Protection Mechanisms
+--   Section 9: Listing Functions
+--   Section 10: Final State
+
+-- Setup: create the extension (installs schema + catalog + functions)
+CREATE EXTENSION IF NOT EXISTS pgtrashcan;
+
+-- Suppress NOTICE messages from restore/purge functions (they contain
+-- dynamic content like OIDs and timestamps that would make output non-deterministic)
+SET client_min_messages = warning;
+
+-- ########################################
+-- SECTION 1: Core - Basic DROP and Restore
+-- ########################################
+
+-- ========================================
+-- TEST 1: Basic DROP TABLE moves to pgtrashcan
+-- ========================================
+CREATE TABLE test_basic (id int, val text);
+INSERT INTO test_basic VALUES (1, 'hello');
+
+DROP TABLE test_basic;
+
+-- Verify table is in trash catalog
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_basic' ORDER BY orig_name;
+
+-- ========================================
+-- TEST 2: Restore by name + data survives
+-- ========================================
+SELECT pgtrashcan_restore('test_basic') IS NOT NULL AS restored;
+
+-- Verify data survived
+SELECT * FROM test_basic;
+
+-- Verify catalog entry removed
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_basic';
+
+-- ========================================
+-- TEST 3: TOAST data survives drop/restore
+-- ========================================
+DROP TABLE test_basic;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+CREATE TABLE toast_test (id int PRIMARY KEY, large_text text);
+INSERT INTO toast_test VALUES (1, repeat('x', 10000));
+
+DROP TABLE toast_test;
+
+SELECT pgtrashcan_restore('toast_test') IS NOT NULL AS restored;
+
+-- Verify large data survived via TOAST
+SELECT id, length(large_text) AS text_length FROM toast_test;
+
+DROP TABLE toast_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 4: DROP from pgtrashcan is permanent
+-- ========================================
+CREATE TABLE test_perm (id int);
+DROP TABLE test_perm;
+
+-- Verify in trash first
+SELECT count(*) AS in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_perm';
+
+-- Get trash_name and drop it permanently
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'test_perm' LIMIT 1;
+    EXECUTE format('DROP TABLE pgtrashcan.%I', v_tname);
+END $$;
+
+-- Verify gone from catalog
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_perm';
+
+-- ########################################
+-- SECTION 2: Indexes and Sequences
+-- ########################################
+
+-- ========================================
+-- TEST 5: Indexes are preserved on drop/restore
+-- ========================================
+CREATE TABLE test_idx (id int PRIMARY KEY, email text UNIQUE, name text);
+CREATE INDEX idx_test_name ON test_idx(name);
+INSERT INTO test_idx VALUES (1, 'a@b.com', 'Alice');
+
+DROP TABLE test_idx;
+
+-- Check indexes tracked in catalog
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_idx', 'test_idx_pkey', 'test_idx_email_key', 'idx_test_name')
+ORDER BY orig_obj_type, orig_name;
+
+-- Restore
+SELECT pgtrashcan_restore('test_idx') IS NOT NULL AS restored;
+
+-- Verify indexes restored
+SELECT indexname FROM pg_indexes
+WHERE tablename = 'test_idx' AND schemaname = 'public'
+ORDER BY indexname;
+
+-- Verify data
+SELECT * FROM test_idx;
+
+-- Cleanup
+DROP TABLE test_idx;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 6: Index name conflict detection + resolution via rename
+-- ========================================
+CREATE TABLE idx_tbl1 (id int, value text);
+CREATE INDEX shared_idx ON idx_tbl1(value);
+DROP TABLE idx_tbl1;
+
+-- Create another table reusing the same index name
+CREATE TABLE idx_tbl2 (id int, value text);
+CREATE INDEX shared_idx ON idx_tbl2(value);
+
+-- Restore should fail due to index name conflict
+DO $$
+BEGIN
+    PERFORM pgtrashcan_restore('idx_tbl1');
+    RAISE WARNING 'UNEXPECTED: restore should have failed but succeeded';
+EXCEPTION
+    WHEN OTHERS THEN
+        RAISE WARNING 'OK: index conflict correctly detected';
+END $$;
+
+-- Rename the conflicting index in trash to resolve
+DO $$
+DECLARE v_idx_tname text;
+BEGIN
+    SELECT trash_name INTO v_idx_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'shared_idx' LIMIT 1;
+    PERFORM pgtrashcan_rename_in_trash(v_idx_tname, 'shared_idx_old');
+END $$;
+
+-- Now restore should succeed
+SELECT pgtrashcan_restore('idx_tbl1') IS NOT NULL AS restored;
+
+-- Verify both tables have their indexes
+SELECT tablename, indexname FROM pg_indexes
+WHERE schemaname = 'public' AND tablename IN ('idx_tbl1', 'idx_tbl2')
+ORDER BY tablename, indexname;
+
+DROP TABLE idx_tbl1, idx_tbl2;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 7: SERIAL sequence moves with table
+-- ========================================
+CREATE TABLE serial_test (id SERIAL PRIMARY KEY, val text);
+INSERT INTO serial_test (val) VALUES ('row1');
+
+DROP TABLE serial_test;
+
+-- Sequence should be tracked
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('serial_test', 'serial_test_id_seq')
+ORDER BY orig_obj_type, orig_name;
+
+-- Restore and verify sequence works
+SELECT pgtrashcan_restore('serial_test') IS NOT NULL AS restored;
+INSERT INTO serial_test (val) VALUES ('row2');
+SELECT id, val FROM serial_test ORDER BY id;
+
+DROP TABLE serial_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ########################################
+-- SECTION 3: CASCADE Behavior
+-- ########################################
+
+-- ========================================
+-- TEST 8: RESTRICT fails with FK dependents
+-- TEST 9: CASCADE drops FK constraint, child table survives
+-- (paired: test 8 leaves test_parent + test_child for test 9)
+-- ========================================
+CREATE TABLE test_parent (id int PRIMARY KEY);
+CREATE TABLE test_child (id int, pid int REFERENCES test_parent(id));
+
+-- Should fail: child depends on parent
+DROP TABLE test_parent;
+
+-- Verify parent not in trash (drop failed)
+SELECT count(*) AS parent_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_parent';
+
+-- Now CASCADE: only parent goes to trash, child survives with FK dropped
+DROP TABLE test_parent CASCADE;
+
+-- Only parent should be in trash (child table stays, only FK constraint dropped)
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_parent', 'test_child') AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+-- Child table should still exist in public schema
+SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = 'test_child';
+
+-- FK constraint should be gone from child
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'test_child'::regclass AND contype = 'f';
+
+-- FK metadata should be captured on parent
+SELECT orig_name, metadata ? 'foreign_keys_dropped' AS has_fk_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_parent';
+
+DROP TABLE test_child;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 10: CASCADE with multiple FK children - children survive
+-- ========================================
+CREATE TABLE cascade_parent (id int PRIMARY KEY, name text);
+CREATE TABLE cascade_child1 (id int PRIMARY KEY, parent_id int REFERENCES cascade_parent(id));
+CREATE TABLE cascade_child2 (id int PRIMARY KEY, parent_id int REFERENCES cascade_parent(id));
+
+INSERT INTO cascade_parent VALUES (1, 'Parent');
+INSERT INTO cascade_child1 VALUES (1, 1);
+INSERT INTO cascade_child2 VALUES (1, 1);
+
+DROP TABLE cascade_parent CASCADE;
+
+-- Only parent should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('cascade_parent', 'cascade_child1', 'cascade_child2') AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+-- Both children should still exist with their data intact
+SELECT * FROM cascade_child1;
+SELECT * FROM cascade_child2;
+
+-- FK constraints should be gone from both children
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid IN ('cascade_child1'::regclass, 'cascade_child2'::regclass) AND contype = 'f';
+
+DROP TABLE cascade_child1, cascade_child2;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 11: RESTRICT fails with view dependents
+-- TEST 12: CASCADE - view permanently dropped, metadata captured
+-- (paired: test 11 leaves view_base + view_dep for test 12)
+-- ========================================
+CREATE TABLE view_base (id int PRIMARY KEY, val text);
+CREATE VIEW view_dep AS SELECT * FROM view_base WHERE id > 0;
+
+-- RESTRICT should fail (view depends on table)
+DROP TABLE view_base;
+
+-- CASCADE drops view, moves table
+DROP TABLE view_base CASCADE;
+
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'view_base';
+
+-- New: ddl field is a runnable CREATE statement
+SELECT (metadata->'views_dropped'->0->>'ddl' LIKE 'CREATE OR REPLACE VIEW %AS%') AS view_ddl_looks_runnable
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'view_base';
+
+-- View should be gone
+SELECT count(*) AS views_remaining FROM pg_views
+WHERE viewname = 'view_dep' AND schemaname = 'public';
+
+SELECT pgtrashcan_restore('view_base') IS NOT NULL AS restored;
+
+-- View NOT auto-restored
+SELECT count(*) AS views_after_restore FROM pg_views
+WHERE viewname = 'view_dep' AND schemaname = 'public';
+
+DROP TABLE view_base;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 13: CASCADE - materialized views permanently dropped
+-- ========================================
+CREATE TABLE matview_base (id int PRIMARY KEY, name text, price numeric);
+CREATE VIEW matview_regular AS SELECT * FROM matview_base WHERE price > 0;
+CREATE MATERIALIZED VIEW matview_expensive AS SELECT * FROM matview_base WHERE price > 100;
+
+INSERT INTO matview_base VALUES (1, 'Item 1', 50), (2, 'Item 2', 150);
+
+DROP TABLE matview_base CASCADE;
+
+-- Metadata should capture both regular and materialized views
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'matview_base';
+
+-- New: every view's ddl field is a runnable CREATE statement
+SELECT bool_and(
+    (v->>'ddl' LIKE 'CREATE MATERIALIZED VIEW %') OR
+    (v->>'ddl' LIKE 'CREATE OR REPLACE VIEW %')
+) AS all_view_ddls_runnable
+FROM pgtrashcan._trash_catalog tc,
+     jsonb_array_elements(tc.metadata->'views_dropped') AS v
+WHERE tc.orig_name = 'matview_base';
+
+-- Both views gone
+SELECT count(*) AS regular_views FROM pg_views
+WHERE viewname = 'matview_regular' AND schemaname = 'public';
+
+SELECT count(*) AS mat_views FROM pg_matviews
+WHERE matviewname = 'matview_expensive' AND schemaname = 'public';
+
+SELECT pgtrashcan_restore('matview_base') IS NOT NULL AS restored;
+
+-- Verify data survived
+SELECT * FROM matview_base ORDER BY id;
+
+DROP TABLE matview_base;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 14: CASCADE - nested 3-level view chain
+-- ========================================
+CREATE TABLE chain_base (id int PRIMARY KEY, val text);
+CREATE VIEW chain_v1 AS SELECT id, val FROM chain_base;
+CREATE VIEW chain_v2 AS SELECT id FROM chain_v1;
+CREATE VIEW chain_v3 AS SELECT id FROM chain_v2;
+
+DROP TABLE chain_base CASCADE;
+
+-- Table should be in trash
+SELECT count(*) AS chain_base_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'chain_base';
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 15: CASCADE - multi-table view (drop one table, other survives)
+-- ========================================
+CREATE TABLE multi_base_a (id int PRIMARY KEY, a_val text);
+CREATE TABLE multi_base_b (id int PRIMARY KEY, b_val text);
+
+INSERT INTO multi_base_a VALUES (1, 'a_test');
+INSERT INTO multi_base_b VALUES (1, 'b_test');
+
+CREATE VIEW multi_table_view AS
+    SELECT a.id, a.a_val, b.b_val
+    FROM multi_base_a a JOIN multi_base_b b ON a.id = b.id;
+
+-- Drop ONLY table_a with CASCADE
+DROP TABLE multi_base_a CASCADE;
+
+-- table_b should still exist with data intact
+SELECT count(*) AS b_exists FROM pg_tables
+WHERE tablename = 'multi_base_b' AND schemaname = 'public';
+
+SELECT * FROM multi_base_b;
+
+-- View should be gone
+SELECT count(*) AS view_remaining FROM pg_views
+WHERE viewname = 'multi_table_view' AND schemaname = 'public';
+
+-- Metadata captured on the dropped table
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'multi_base_a';
+
+-- Restore table_a
+SELECT pgtrashcan_restore('multi_base_a') IS NOT NULL AS restored;
+SELECT * FROM multi_base_a;
+
+DROP TABLE multi_base_a, multi_base_b;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 16: CASCADE - mixed objects (SERIAL + FK child + view)
+-- ========================================
+CREATE TABLE mixed_parent (id SERIAL PRIMARY KEY, name text);
+CREATE TABLE mixed_child (id int PRIMARY KEY, parent_id int REFERENCES mixed_parent(id));
+CREATE VIEW mixed_view AS SELECT * FROM mixed_parent WHERE id > 0;
+
+INSERT INTO mixed_parent (name) VALUES ('Test');
+INSERT INTO mixed_child VALUES (1, 1);
+
+DROP TABLE mixed_parent CASCADE;
+
+-- Only parent table and its sequence should be in trash; child survives, view permanently dropped
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name LIKE 'mixed_%'
+ORDER BY orig_obj_type, orig_name;
+
+-- Child table should still exist with data intact
+SELECT * FROM mixed_child;
+
+-- FK constraint should be gone from child
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'mixed_child'::regclass AND contype = 'f';
+
+-- View metadata should be captured on parent
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'mixed_parent';
+
+-- Restore parent and verify SERIAL sequence ownership is restored
+SELECT pgtrashcan_restore('mixed_parent') IS NOT NULL AS restored;
+
+SELECT pg_get_serial_sequence('mixed_parent', 'id') IS NOT NULL AS seq_ownership_restored;
+
+-- Sequence should work after restore
+INSERT INTO mixed_parent (name) VALUES ('After Restore');
+SELECT id, name FROM mixed_parent ORDER BY id;
+
+DROP TABLE mixed_child;
+DROP TABLE mixed_parent;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 17: Multi-table DROP with FK between them (RESTRICT mode)
+-- Both in same DROP statement - should succeed without CASCADE
+-- ========================================
+CREATE TABLE test_multi_fk_parent (id int PRIMARY KEY);
+CREATE TABLE test_multi_fk_child (id int, pid int REFERENCES test_multi_fk_parent(id));
+INSERT INTO test_multi_fk_parent VALUES (1), (2);
+INSERT INTO test_multi_fk_child VALUES (1, 1), (2, 2);
+
+-- This should NOT error even without CASCADE, because both tables are in the same DROP
+DROP TABLE test_multi_fk_parent, test_multi_fk_child;
+
+-- Both should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_fk_parent', 'test_multi_fk_child')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 18: Multi-table DROP CASCADE with FK between them + external table
+-- ========================================
+CREATE TABLE test_cascade_multi_p (id int PRIMARY KEY, name text);
+CREATE TABLE test_cascade_multi_c (id int PRIMARY KEY, pid int REFERENCES test_cascade_multi_p(id));
+CREATE TABLE test_cascade_multi_ext (id int, pid int REFERENCES test_cascade_multi_p(id));
+INSERT INTO test_cascade_multi_p VALUES (1, 'parent');
+INSERT INTO test_cascade_multi_c VALUES (1, 1);
+INSERT INTO test_cascade_multi_ext VALUES (1, 1);
+
+-- CASCADE: parent and child in same DROP, ext_table has FK to parent but not in DROP
+DROP TABLE test_cascade_multi_p, test_cascade_multi_c CASCADE;
+
+-- Both parent and child should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_cascade_multi_p', 'test_cascade_multi_c', 'test_cascade_multi_ext')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+-- External table should survive with FK dropped
+SELECT * FROM test_cascade_multi_ext;
+
+-- FK on external table should be gone
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'test_cascade_multi_ext'::regclass AND contype = 'f';
+
+DROP TABLE test_cascade_multi_ext;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ########################################
+-- SECTION 4: Metadata Capture
+-- ########################################
+
+-- ========================================
+-- TEST 19: FK metadata captured (outgoing FK on dropped table)
+-- ========================================
+CREATE TABLE fk_ref (id int PRIMARY KEY);
+CREATE TABLE fk_test (id int PRIMARY KEY, ref_id int,
+    CONSTRAINT fk_to_ref FOREIGN KEY (ref_id) REFERENCES fk_ref(id));
+INSERT INTO fk_ref VALUES (1);
+INSERT INTO fk_test VALUES (1, 1);
+
+-- Drop child (has outgoing FK)
+DROP TABLE fk_test;
+
+-- FK metadata stored
+SELECT orig_name, metadata ? 'foreign_keys_dropped' AS has_fk_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'fk_test';
+
+-- New FK metadata fields: columns list, ddl, human-readable on_delete
+SELECT
+    (metadata->'foreign_keys_dropped'->0->>'ddl' LIKE 'ALTER TABLE %ADD CONSTRAINT %FOREIGN KEY%') AS ddl_looks_runnable,
+    jsonb_array_length(metadata->'foreign_keys_dropped'->0->'columns') AS col_count,
+    metadata->'foreign_keys_dropped'->0->>'on_delete' AS on_delete_action
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'fk_test';
+
+-- Restore and verify FK is gone
+SELECT pgtrashcan_restore('fk_test') IS NOT NULL AS restored;
+SELECT conname FROM pg_constraint
+WHERE conrelid = 'fk_test'::regclass AND contype = 'f';
+
+DROP TABLE fk_test, fk_ref;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 20: Trigger metadata captured
+-- ========================================
+CREATE TABLE trig_test (id int PRIMARY KEY, val text, updated_at timestamptz);
+
+CREATE OR REPLACE FUNCTION test_update_ts() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_update BEFORE UPDATE ON trig_test
+    FOR EACH ROW EXECUTE FUNCTION test_update_ts();
+
+DROP TABLE trig_test;
+
+SELECT orig_name, metadata ? 'triggers_dropped' AS has_trigger_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'trig_test';
+
+SELECT pgtrashcan_restore('trig_test') IS NOT NULL AS restored;
+
+-- Trigger should be gone
+SELECT tgname FROM pg_trigger
+WHERE tgrelid = 'trig_test'::regclass AND NOT tgisinternal;
+
+DROP TABLE trig_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 21: Rule metadata captured
+-- ========================================
+CREATE TABLE rule_test (id int PRIMARY KEY, val text);
+CREATE RULE notify_rule AS ON INSERT TO rule_test DO ALSO NOTIFY rule_channel;
+
+DROP TABLE rule_test;
+
+SELECT orig_name, metadata ? 'rules_dropped' AS has_rule_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'rule_test';
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 22: RLS policy metadata captured
+-- ========================================
+CREATE TABLE policy_test (id int PRIMARY KEY, user_id int);
+ALTER TABLE policy_test ENABLE ROW LEVEL SECURITY;
+CREATE POLICY user_pol ON policy_test FOR SELECT USING (user_id = 1);
+
+DROP TABLE policy_test;
+
+SELECT orig_name, metadata ? 'policies_dropped' AS has_policy_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'policy_test';
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 23: Complete scenario - all 5 metadata types on one table
+-- ========================================
+CREATE TABLE ref_for_complete (id int PRIMARY KEY);
+INSERT INTO ref_for_complete VALUES (1);
+
+CREATE TABLE complete_test (id SERIAL PRIMARY KEY, ref_id int, val text, updated_at timestamptz);
+
+ALTER TABLE complete_test ADD CONSTRAINT fk_complete
+    FOREIGN KEY (ref_id) REFERENCES ref_for_complete(id);
+
+CREATE OR REPLACE FUNCTION complete_update_ts() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_complete BEFORE UPDATE ON complete_test
+    FOR EACH ROW EXECUTE FUNCTION complete_update_ts();
+
+CREATE VIEW complete_view AS SELECT * FROM complete_test WHERE val IS NOT NULL;
+
+CREATE RULE complete_rule AS ON DELETE TO complete_test
+    WHERE val = 'protected' DO INSTEAD NOTHING;
+
+ALTER TABLE complete_test ENABLE ROW LEVEL SECURITY;
+CREATE POLICY complete_pol ON complete_test FOR SELECT USING (ref_id = 1);
+
+INSERT INTO complete_test (ref_id, val) VALUES (1, 'test');
+
+DROP TABLE complete_test CASCADE;
+
+-- All 5 metadata keys should exist
+SELECT orig_name,
+    metadata ? 'foreign_keys_dropped' AS has_fk,
+    metadata ? 'triggers_dropped' AS has_triggers,
+    metadata ? 'views_dropped' AS has_views,
+    metadata ? 'rules_dropped' AS has_rules,
+    metadata ? 'policies_dropped' AS has_policies
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'complete_test';
+
+-- ========================================
+-- TEST 24: Post-restore verification - all 5 types absent from system catalogs
+-- ========================================
+SELECT pgtrashcan_restore('complete_test') IS NOT NULL AS restored;
+
+-- FK absent
+SELECT count(*) AS fk_count FROM pg_constraint
+WHERE conrelid = 'complete_test'::regclass AND contype = 'f';
+
+-- Trigger absent
+SELECT count(*) AS trigger_count FROM pg_trigger
+WHERE tgrelid = 'complete_test'::regclass AND NOT tgisinternal;
+
+-- View absent
+SELECT count(*) AS view_count FROM pg_views
+WHERE viewname = 'complete_view' AND schemaname = 'public';
+
+-- Rule absent (excluding _RETURN)
+SELECT count(*) AS rule_count FROM pg_rewrite r
+JOIN pg_class c ON r.ev_class = c.oid
+WHERE c.relname = 'complete_test'
+AND c.relnamespace = 'public'::regnamespace
+AND r.rulename != '_RETURN';
+
+-- Policy absent
+SELECT count(*) AS policy_count FROM pg_policy
+WHERE polrelid = 'complete_test'::regclass;
+
+DROP TABLE complete_test, ref_for_complete;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ########################################
+-- SECTION 5: Restore Advanced
+-- ########################################
+
+-- ========================================
+-- TEST 25: Multiple drops of same table name (versioning)
+-- ========================================
+CREATE TABLE test_basic (id int, val text);
+INSERT INTO test_basic VALUES (1, 'hello');
+DROP TABLE test_basic;
+
+CREATE TABLE test_basic (id int, val text, extra text);
+INSERT INTO test_basic VALUES (2, 'world', 'v2');
+DROP TABLE test_basic;
+
+-- Both versions in trash
+SELECT count(*) AS versions FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_basic' AND orig_obj_type = 'table';
+
+-- Restore most recent (has extra column)
+SELECT pgtrashcan_restore('test_basic') IS NOT NULL AS restored;
+SELECT * FROM test_basic;
+
+DROP TABLE test_basic;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 26: Conflict detection on restore
+-- ========================================
+CREATE TABLE conflict_t (id int, val text);
+INSERT INTO conflict_t VALUES (1, 'original');
+DROP TABLE conflict_t;
+
+-- Create new table with same name
+CREATE TABLE conflict_t (id int, val text);
+
+-- Restore should fail - name conflict (use DO block to get deterministic output)
+DO $$
+BEGIN
+    PERFORM pgtrashcan_restore('conflict_t');
+    RAISE WARNING 'UNEXPECTED: restore should have failed but succeeded';
+EXCEPTION
+    WHEN OTHERS THEN
+        RAISE WARNING 'OK: conflict correctly detected';
+END $$;
+
+-- Clean up
+DROP TABLE conflict_t;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 27: Rename in trash for conflict resolution
+-- ========================================
+CREATE TABLE rename_test (id int, val text);
+INSERT INTO rename_test VALUES (1, 'original');
+DROP TABLE rename_test;
+
+CREATE TABLE rename_test (id int, val text);
+INSERT INTO rename_test VALUES (2, 'new');
+
+-- Rename in trash
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'rename_test' LIMIT 1;
+    PERFORM pgtrashcan_rename_in_trash(v_tname, 'rename_test_old');
+END $$;
+
+-- Now restore should work
+SELECT pgtrashcan_restore('rename_test_old') IS NOT NULL AS restored;
+
+SELECT * FROM rename_test ORDER BY id;
+SELECT * FROM rename_test_old ORDER BY id;
+
+DROP TABLE rename_test, rename_test_old;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 28: Restore with explicit schema hint
+-- ========================================
+CREATE TABLE schema_restore_test (id int, val text);
+INSERT INTO schema_restore_test VALUES (1, 'data');
+DROP TABLE schema_restore_test;
+
+-- Restore using 2-arg form with explicit schema
+SELECT pgtrashcan_restore('schema_restore_test', 'public') IS NOT NULL AS restored;
+
+-- Verify data
+SELECT * FROM schema_restore_test;
+
+DROP TABLE schema_restore_test;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 29: Restore with OID hint (restoring older version)
+-- ========================================
+CREATE TABLE versioned_test (id int);
+INSERT INTO versioned_test VALUES (1);
+DROP TABLE versioned_test;  -- version 1
+
+CREATE TABLE versioned_test (id int, extra text);
+INSERT INTO versioned_test VALUES (2, 'v2');
+DROP TABLE versioned_test;  -- version 2
+
+-- Both versions in trash
+SELECT count(*) AS versions FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'versioned_test' AND orig_obj_type = 'table';
+
+-- Rename older version so both can be restored without conflict
+DO $$
+DECLARE v_old_oid oid;
+BEGIN
+    SELECT orig_oid INTO v_old_oid
+    FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'versioned_test' AND orig_obj_type = 'table'
+    ORDER BY drop_time ASC
+    LIMIT 1;
+
+    PERFORM pgtrashcan_rename_in_trash(
+        (SELECT trash_name FROM pgtrashcan._trash_catalog WHERE orig_oid = v_old_oid),
+        'versioned_test_old'
+    );
+END $$;
+
+-- Restore most recent (v2 with extra column)
+SELECT pgtrashcan_restore('versioned_test') IS NOT NULL AS restored_v2;
+SELECT * FROM versioned_test;
+
+-- Restore old version (v1, single column)
+SELECT pgtrashcan_restore('versioned_test_old') IS NOT NULL AS restored_v1;
+SELECT * FROM versioned_test_old;
+
+DROP TABLE versioned_test, versioned_test_old;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 30: Restore from non-public schema
+-- ========================================
+CREATE SCHEMA myapp;
+CREATE TABLE myapp.myapp_table (id int PRIMARY KEY, val text);
+INSERT INTO myapp.myapp_table VALUES (1, 'from myapp');
+
+DROP TABLE myapp.myapp_table;
+
+-- Should be in trash with orig_schema = myapp
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'myapp_table';
+
+-- Restore back to original schema using schema hint
+SELECT pgtrashcan_restore('myapp_table', 'myapp') IS NOT NULL AS restored;
+
+-- Verify back in myapp schema with data
+SELECT * FROM myapp.myapp_table;
+
+DROP TABLE myapp.myapp_table;
+DROP SCHEMA myapp;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 31: Multiple schemas - same table name disambiguation
+-- ========================================
+CREATE SCHEMA schema_a;
+CREATE SCHEMA schema_b;
+CREATE TABLE schema_a.shared_name (id int);
+CREATE TABLE schema_b.shared_name (id int);
+INSERT INTO schema_a.shared_name VALUES (1);
+INSERT INTO schema_b.shared_name VALUES (2);
+
+DROP TABLE schema_a.shared_name;
+DROP TABLE schema_b.shared_name;
+
+-- Both in trash
+SELECT orig_name, orig_schema FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'shared_name' AND orig_obj_type = 'table'
+ORDER BY orig_schema;
+
+-- Restore from specific schema
+SELECT pgtrashcan_restore('shared_name', 'schema_a') IS NOT NULL AS restored_a;
+SELECT * FROM schema_a.shared_name;
+
+SELECT pgtrashcan_restore('shared_name', 'schema_b') IS NOT NULL AS restored_b;
+SELECT * FROM schema_b.shared_name;
+
+DROP TABLE schema_a.shared_name, schema_b.shared_name;
+DROP SCHEMA schema_a;
+DROP SCHEMA schema_b;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ########################################
+-- SECTION 6: Purge Operations
+-- ########################################
+
+-- ========================================
+-- TEST 32: Purge by name
+-- ========================================
+CREATE TABLE purge_test (id int);
+DROP TABLE purge_test;
+
+SELECT count(*) AS in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_test';
+
+SELECT pgtrashcan_purge('purge_test') IS NOT NULL AS purged;
+
+SELECT count(*) AS after_purge FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_test';
+
+-- ========================================
+-- TEST 33: Purge by OID
+-- ========================================
+CREATE TABLE purge_oid_test (id int, val text);
+INSERT INTO purge_oid_test VALUES (1, 'test');
+DROP TABLE purge_oid_test;
+
+-- Get OID and purge by it
+DO $$
+DECLARE v_oid oid;
+BEGIN
+    SELECT orig_oid INTO v_oid FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'purge_oid_test' AND orig_obj_type = 'table';
+    PERFORM pgtrashcan_purge_by_oid(v_oid);
+END $$;
+
+-- Should be gone
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_oid_test';
+
+-- ========================================
+-- TEST 34: Purge with schema hint
+-- ========================================
+CREATE SCHEMA purge_ns;
+CREATE TABLE purge_ns.purge_schema_test (id int);
+DROP TABLE purge_ns.purge_schema_test;
+
+-- Purge using 2-arg form with explicit schema
+SELECT pgtrashcan_purge('purge_schema_test', 'purge_ns') IS NOT NULL AS purged;
+
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'purge_schema_test';
+
+DROP SCHEMA purge_ns;
+
+-- ========================================
+-- TEST 35: Purge all
+-- ========================================
+CREATE TABLE purge_all_t1 (id int);
+CREATE TABLE purge_all_t2 (id int);
+DROP TABLE purge_all_t1;
+DROP TABLE purge_all_t2;
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- Verify empty
+SELECT count(*) AS remaining FROM pgtrashcan._trash_catalog;
+
+-- ########################################
+-- SECTION 7: DROP TABLE Variants
+-- ########################################
+
+-- ========================================
+-- TEST 36: DROP TABLE in non-public schema
+-- ========================================
+CREATE SCHEMA testns;
+CREATE TABLE testns.ns_table (id int, val text);
+CREATE INDEX ns_idx ON testns.ns_table(val);
+INSERT INTO testns.ns_table VALUES (1, 'ns_data');
+
+DROP TABLE testns.ns_table;
+
+-- Should be in trash with orig_schema = testns
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('ns_table', 'ns_idx')
+ORDER BY orig_obj_type, orig_name;
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+DROP SCHEMA testns;
+
+-- ========================================
+-- TEST 37: DROP TABLE IF EXISTS - single nonexistent table
+-- ========================================
+-- Should emit a NOTICE and do nothing (no crash, no error)
+DROP TABLE IF EXISTS this_table_does_not_exist;
+
+-- Verify trash catalog unchanged
+SELECT count(*) AS count_after_if_exists FROM pgtrashcan._trash_catalog;
+
+-- ========================================
+-- TEST 38: DROP TABLE IF EXISTS - single existing table
+-- ========================================
+CREATE TABLE test_if_exists_single (id int, val text);
+INSERT INTO test_if_exists_single VALUES (1, 'exists');
+
+DROP TABLE IF EXISTS test_if_exists_single;
+
+-- Verify table was moved to trash (not permanently dropped)
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_if_exists_single';
+
+-- Clean up
+SELECT pgtrashcan_purge('test_if_exists_single') IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 39: DROP TABLE IF EXISTS - multi-table, first doesn't exist
+-- ========================================
+CREATE TABLE test_multi_real (id int, val text);
+INSERT INTO test_multi_real VALUES (1, 'should be in trash');
+
+DROP TABLE IF EXISTS nonexistent_table_xyz, test_multi_real;
+
+-- Verify real table was moved to trash (NOT permanently dropped)
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_multi_real';
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_real') IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 40: DROP TABLE IF EXISTS - multi-table, second doesn't exist
+-- ========================================
+CREATE TABLE test_multi_real2 (id int, val text);
+INSERT INTO test_multi_real2 VALUES (2, 'also should be in trash');
+
+DROP TABLE IF EXISTS test_multi_real2, another_nonexistent_xyz;
+
+-- Verify real table was moved to trash
+SELECT orig_name, orig_schema, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'test_multi_real2';
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_real2') IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 41: DROP TABLE IF EXISTS - multi-table, all exist
+-- ========================================
+CREATE TABLE test_multi_all1 (id int);
+CREATE TABLE test_multi_all2 (id int);
+CREATE TABLE test_multi_all3 (id int);
+
+DROP TABLE IF EXISTS test_multi_all1, test_multi_all2, test_multi_all3;
+
+-- Verify all three were moved to trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_all1', 'test_multi_all2', 'test_multi_all3')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_all1') IS NOT NULL AS purged1;
+SELECT pgtrashcan_purge('test_multi_all2') IS NOT NULL AS purged2;
+SELECT pgtrashcan_purge('test_multi_all3') IS NOT NULL AS purged3;
+
+-- ========================================
+-- TEST 42: DROP TABLE IF EXISTS - multi-table, none exist
+-- ========================================
+DROP TABLE IF EXISTS ghost1, ghost2, ghost3;
+
+-- Verify trash catalog unchanged
+SELECT count(*) AS count_after_all_ghosts FROM pgtrashcan._trash_catalog;
+
+-- ========================================
+-- TEST 43: DROP TABLE IF EXISTS - multi-table, middle missing
+-- ========================================
+CREATE TABLE test_multi_a (id int);
+CREATE TABLE test_multi_c (id int);
+
+DROP TABLE IF EXISTS test_multi_a, nonexistent_middle_xyz, test_multi_c;
+
+-- Verify both existing tables were moved to trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_a', 'test_multi_c')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_a') IS NOT NULL AS purged_a;
+SELECT pgtrashcan_purge('test_multi_c') IS NOT NULL AS purged_c;
+
+-- ========================================
+-- TEST 44: DROP TABLE IF EXISTS with CASCADE
+-- ========================================
+CREATE TABLE if_exists_cascade_base (id int PRIMARY KEY, val text);
+CREATE VIEW if_exists_cascade_view AS SELECT * FROM if_exists_cascade_base WHERE id > 0;
+INSERT INTO if_exists_cascade_base VALUES (1, 'test');
+
+-- IF EXISTS + CASCADE: base moves to trash, view permanently dropped
+DROP TABLE IF EXISTS if_exists_cascade_base CASCADE;
+
+-- Base should be in trash
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'if_exists_cascade_base';
+
+-- View should be gone
+SELECT count(*) AS view_count FROM pg_views
+WHERE viewname = 'if_exists_cascade_view' AND schemaname = 'public';
+
+-- View definition should be in metadata
+SELECT orig_name, metadata ? 'views_dropped' AS has_view_metadata
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'if_exists_cascade_base';
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 45: DROP TABLE multi-table without IF EXISTS
+-- ========================================
+CREATE TABLE test_multi_no_ie_1 (id int);
+CREATE TABLE test_multi_no_ie_2 (id int);
+
+DROP TABLE test_multi_no_ie_1, test_multi_no_ie_2;
+
+SELECT orig_name, orig_obj_type FROM pgtrashcan._trash_catalog
+WHERE orig_name IN ('test_multi_no_ie_1', 'test_multi_no_ie_2')
+  AND orig_obj_type = 'table'
+ORDER BY orig_name;
+
+-- Clean up
+SELECT pgtrashcan_purge('test_multi_no_ie_1') IS NOT NULL AS purged1;
+SELECT pgtrashcan_purge('test_multi_no_ie_2') IS NOT NULL AS purged2;
+
+-- ########################################
+-- SECTION 8: Protection Mechanisms
+-- ########################################
+
+-- ========================================
+-- TEST 46: pgtrashcan schema protection (DML + DDL + schema drop)
+-- ========================================
+
+-- Cannot create tables in pgtrashcan schema
+CREATE TABLE pgtrashcan.user_junk (id int);
+
+-- Cannot insert into catalog directly (blocked by trigger)
+INSERT INTO pgtrashcan._trash_catalog (trash_name, orig_name, orig_schema, orig_oid, orig_obj_type)
+VALUES ('fake', 'fake', 'public', 0, 'table');
+
+-- To test UPDATE/DELETE blocking, we need a row in the catalog first.
+-- Drop a table to get a real row, then try to modify it directly.
+CREATE TABLE dml_protect_test (id int);
+DROP TABLE dml_protect_test;
+
+-- Cannot update catalog directly (blocked by trigger)
+UPDATE pgtrashcan._trash_catalog SET orig_name = 'hacked' WHERE orig_name = 'dml_protect_test';
+
+-- Cannot delete from catalog directly (blocked by trigger)
+DELETE FROM pgtrashcan._trash_catalog WHERE orig_name = 'dml_protect_test';
+
+-- Cannot truncate catalog (blocked by ProcessUtility hook)
+TRUNCATE pgtrashcan._trash_catalog;
+
+-- Clean up the test row via purge
+SELECT pgtrashcan_purge('dml_protect_test') IS NOT NULL AS purged;
+
+-- Cannot drop pgtrashcan schema while extension is installed
+DROP SCHEMA pgtrashcan CASCADE;
+
+-- ########################################
+-- SECTION 9: Listing Functions
+-- ########################################
+
+-- ========================================
+-- TEST 47: pgtrashcan_list_dropped() - basic listing
+-- ========================================
+CREATE TABLE list_test_a (id int, val text);
+CREATE TABLE list_test_b (id int);
+INSERT INTO list_test_a VALUES (1, 'hello');
+DROP TABLE list_test_a;
+DROP TABLE list_test_b;
+
+-- Both should appear in list_dropped
+SELECT orig_name, orig_schema, obj_type
+FROM pgtrashcan_list_dropped()
+ORDER BY orig_name;
+
+-- is_dependent should be false for both
+SELECT orig_name, is_dependent
+FROM pgtrashcan_list_dropped()
+ORDER BY orig_name;
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 48: pgtrashcan_list_dropped_grouped() - standalone tables
+-- ========================================
+CREATE TABLE grouped_t1 (id int);
+CREATE TABLE grouped_t2 (id int);
+DROP TABLE grouped_t1;
+DROP TABLE grouped_t2;
+
+-- Both should show as standalone (separate drops, no CASCADE relationship)
+SELECT drop_group, relationship, orig_name
+FROM pgtrashcan_list_dropped_grouped()
+ORDER BY orig_name;
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 49: pgtrashcan_list_dropped_grouped() - CASCADE parent with FK child
+-- ========================================
+CREATE TABLE grouped_parent (id int PRIMARY KEY);
+CREATE TABLE grouped_child (id int, pid int REFERENCES grouped_parent(id));
+INSERT INTO grouped_parent VALUES (1);
+INSERT INTO grouped_child VALUES (1, 1);
+
+DROP TABLE grouped_parent CASCADE;
+
+-- Only parent in trash, child survives - parent should show as standalone
+SELECT drop_group, relationship, orig_name
+FROM pgtrashcan_list_dropped_grouped()
+ORDER BY orig_name;
+
+DROP TABLE grouped_child;
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 50: pgtrashcan_list_dropped_grouped() - multi-table DROP with FK
+-- Both tables in same DROP statement, no cascade_parent_oid
+-- ========================================
+CREATE TABLE grouped_mp (id int PRIMARY KEY);
+CREATE TABLE grouped_mc (id int, pid int REFERENCES grouped_mp(id));
+INSERT INTO grouped_mp VALUES (1);
+INSERT INTO grouped_mc VALUES (1, 1);
+
+DROP TABLE grouped_mp, grouped_mc;
+
+-- Both in trash as standalone (same DROP statement, no CASCADE relationship)
+SELECT drop_group, relationship, orig_name
+FROM pgtrashcan_list_dropped_grouped()
+ORDER BY orig_name;
+
+SELECT pgtrashcan_purge_all() IS NOT NULL AS purged;
+
+-- ########################################
+-- SECTION 10: Protection: ALTER/RENAME on pgtrashcan Objects
+-- ########################################
+
+-- ========================================
+-- TEST 52: Block direct SET SCHEMA on trash object
+-- ========================================
+CREATE TABLE alter_protect_test (id int, val text);
+INSERT INTO alter_protect_test VALUES (1, 'data');
+DROP TABLE alter_protect_test;
+
+-- Attempt direct SET SCHEMA - should be blocked (insufficient_privilege)
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'alter_protect_test' LIMIT 1;
+    EXECUTE format('ALTER TABLE pgtrashcan.%I SET SCHEMA public', v_tname);
+    RAISE WARNING 'FAIL: SET SCHEMA on trash object was NOT blocked';
+EXCEPTION WHEN insufficient_privilege THEN
+    RAISE WARNING 'PASS: SET SCHEMA correctly blocked: %', SQLERRM;
+END $$;
+
+-- Table must NOT have leaked to public (the definitive protection check)
+SELECT count(*) AS leaked_to_public FROM pg_tables
+WHERE schemaname = 'public' AND tablename = 'alter_protect_test';
+
+-- Catalog entry must still exist
+SELECT count(*) AS still_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'alter_protect_test';
+
+SELECT pgtrashcan_purge('alter_protect_test') IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 53: Block direct RENAME on trash object
+-- ========================================
+CREATE TABLE rename_protect_test (id int);
+DROP TABLE rename_protect_test;
+
+-- Attempt direct RENAME - should be blocked (insufficient_privilege)
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'rename_protect_test' LIMIT 1;
+    EXECUTE format('ALTER TABLE pgtrashcan.%I RENAME TO something_else', v_tname);
+    RAISE WARNING 'FAIL: RENAME on trash object was NOT blocked';
+EXCEPTION WHEN insufficient_privilege THEN
+    RAISE WARNING 'PASS: RENAME correctly blocked: %', SQLERRM;
+END $$;
+
+-- The rename target must NOT exist in pgtrashcan (protection definitive check)
+SELECT count(*) AS rename_leaked FROM pg_tables
+WHERE schemaname = 'pgtrashcan' AND tablename = 'something_else';
+
+-- Catalog entry must still exist
+SELECT count(*) AS still_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'rename_protect_test';
+
+SELECT pgtrashcan_purge('rename_protect_test') IS NOT NULL AS purged;
+
+-- ========================================
+-- TEST 54: pgtrashcan_move_to_schema() - simple table
+-- ========================================
+CREATE SCHEMA move_target_schema;
+CREATE TABLE move_to_schema_test (id int, val text);
+INSERT INTO move_to_schema_test VALUES (1, 'hello'), (2, 'world');
+DROP TABLE move_to_schema_test;
+
+-- Move to a different schema (not original)
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'move_to_schema_test' LIMIT 1;
+    PERFORM pgtrashcan_move_to_schema(v_tname, 'move_target_schema');
+END $$;
+
+-- Table should now be in move_target_schema with original name
+SELECT count(*) AS row_count FROM move_target_schema.move_to_schema_test;
+
+-- Catalog entry should be gone
+SELECT count(*) AS catalog_count FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'move_to_schema_test';
+
+-- Clean up
+DROP TABLE move_target_schema.move_to_schema_test;
+SELECT pgtrashcan_purge('move_to_schema_test') IS NOT NULL AS purged;
+DROP SCHEMA move_target_schema;
+
+-- ========================================
+-- TEST 55: pgtrashcan_move_to_schema() - table with index and sequence
+-- ========================================
+CREATE SCHEMA move_archive_schema;
+CREATE TABLE move_with_deps_test (id SERIAL PRIMARY KEY, name text);
+CREATE INDEX idx_move_with_deps ON move_with_deps_test(name);
+INSERT INTO move_with_deps_test (name) VALUES ('alice'), ('bob');
+DROP TABLE move_with_deps_test;
+
+-- Move table (with its index and sequence) to archive schema
+DO $$
+DECLARE v_tname text;
+BEGIN
+    SELECT trash_name INTO v_tname FROM pgtrashcan._trash_catalog
+    WHERE orig_name = 'move_with_deps_test' AND orig_obj_type = 'table' LIMIT 1;
+    PERFORM pgtrashcan_move_to_schema(v_tname, 'move_archive_schema');
+END $$;
+
+-- Table should be in archive schema with original name and data intact
+SELECT count(*) AS row_count FROM move_archive_schema.move_with_deps_test;
+
+-- Catalog should have no entries for this table or its dependents
+SELECT count(*) AS catalog_count FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'move_with_deps_test'
+   OR orig_name IN ('idx_move_with_deps', 'move_with_deps_test_id_seq');
+
+-- Index should exist in archive schema
+SELECT count(*) AS idx_count FROM pg_indexes
+WHERE schemaname = 'move_archive_schema' AND tablename = 'move_with_deps_test';
+
+-- Clean up
+DROP TABLE move_archive_schema.move_with_deps_test;
+SELECT pgtrashcan_purge('move_with_deps_test') IS NOT NULL AS purged;
+DROP SCHEMA move_archive_schema;
+
+-- ########################################
+-- SECTION 11: Temp Table Pass-Through
+-- ########################################
+
+-- ========================================
+-- TEST 56: DROP on temp table passes through (not trashed)
+-- ========================================
+CREATE TEMP TABLE temp_drop_test (id int, val text);
+INSERT INTO temp_drop_test VALUES (1, 'ephemeral');
+
+-- Should emit NOTICE and permanently drop (not moved to trash)
+DROP TABLE temp_drop_test;
+
+-- Must NOT appear in trash catalog
+SELECT count(*) AS temp_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'temp_drop_test';
+
+-- Must be gone entirely
+SELECT count(*) AS temp_exists FROM pg_tables
+WHERE tablename = 'temp_drop_test';
+
+-- ========================================
+-- TEST 57: Mixed DROP - regular + temp table
+-- ========================================
+CREATE TABLE regular_for_mix (id int);
+CREATE TEMP TABLE temp_for_mix (id int);
+INSERT INTO regular_for_mix VALUES (1);
+INSERT INTO temp_for_mix VALUES (2);
+
+-- Mixed drop: regular goes to trash, temp is permanently dropped
+DROP TABLE regular_for_mix, temp_for_mix;
+
+-- Regular table should be in trash
+SELECT count(*) AS regular_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'regular_for_mix';
+
+-- Temp table must NOT be in trash
+SELECT count(*) AS temp_in_trash FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'temp_for_mix';
+
+-- Temp table must be gone entirely
+SELECT count(*) AS temp_exists FROM pg_tables
+WHERE tablename = 'temp_for_mix';
+
+SELECT pgtrashcan_purge('regular_for_mix') IS NOT NULL AS purged;
+
+-- ########################################
+-- SECTION 12: GUC pgtrashcan.enabled (session toggle)
+-- ########################################
+
+-- ========================================
+-- TEST 58a: GUC off -> DROP TABLE permanently drops (no pgtrashcan entry)
+-- ========================================
+CREATE TABLE guc_test (id int);
+SET pgtrashcan.enabled = false;
+DROP TABLE guc_test;
+SELECT count(*) AS trash_entries
+FROM pgtrashcan._trash_catalog
+WHERE orig_name = 'guc_test';
+SELECT count(*) AS still_in_pg_class
+FROM pg_class
+WHERE relname = 'guc_test' AND relnamespace = 'public'::regnamespace;
+RESET pgtrashcan.enabled;
+
+-- ========================================
+-- TEST 58b: GUC off -> permanent purge from pgtrashcan still works
+-- ========================================
+CREATE TABLE guc_keep (id int);
+DROP TABLE guc_keep;                                         -- normal: goes to pgtrashcan
+SELECT count(*) AS before_purge
+FROM pgtrashcan._trash_catalog WHERE orig_name = 'guc_keep';  -- 1
+
+SET pgtrashcan.enabled = false;
+SELECT pgtrashcan_purge('guc_keep') IS NOT NULL AS purged;   -- still works
+SELECT count(*) AS after_purge
+FROM pgtrashcan._trash_catalog WHERE orig_name = 'guc_keep';  -- 0
+RESET pgtrashcan.enabled;
+
+-- ========================================
+-- TEST 58c: GUC off -> pgtrashcan protections still active
+-- ========================================
+SET pgtrashcan.enabled = false;
+DO $$ BEGIN
+    BEGIN
+        DROP TABLE pgtrashcan._trash_catalog;
+        RAISE WARNING 'pgtrashcan: catalog drop unexpectedly allowed';
+    EXCEPTION WHEN insufficient_privilege THEN
+        RAISE WARNING 'pgtrashcan: catalog drop blocked even when GUC is false';
+    END;
+END $$;
+RESET pgtrashcan.enabled;
+
+-- ########################################
+-- SECTION 13: Final State
+-- ########################################
+
+-- ========================================
+-- TEST 58: Final state check - trash must be empty
+-- ========================================
+SELECT count(*) AS final_count FROM pgtrashcan._trash_catalog;
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index b9b03654aad..b7ca15a72f1 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -163,6 +163,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
+ &pgtrashcan;
  &pgtrgm;
  &pgvisibility;
  &pgwalinspect;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 0e7e7c8ca53..c60f09ced2b 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -152,6 +152,7 @@
 <!ENTITY pgoverexplain   SYSTEM "pgoverexplain.sgml">
 <!ENTITY pgplanadvice    SYSTEM "pgplanadvice.sgml">
 <!ENTITY pgprewarm       SYSTEM "pgprewarm.sgml">
+<!ENTITY pgtrashcan      SYSTEM "pgtrashcan.sgml">
 <!ENTITY pgrowlocks      SYSTEM "pgrowlocks.sgml">
 <!ENTITY pgstatstatements SYSTEM "pgstatstatements.sgml">
 <!ENTITY pgstattuple     SYSTEM "pgstattuple.sgml">
diff --git a/doc/src/sgml/pgtrashcan.sgml b/doc/src/sgml/pgtrashcan.sgml
new file mode 100644
index 00000000000..ce0dbfe1f40
--- /dev/null
+++ b/doc/src/sgml/pgtrashcan.sgml
@@ -0,0 +1,395 @@
+<!--
+doc/src/sgml/pgtrashcan.sgml
+PostgreSQL documentation
+-->
+
+<sect1 id="pgtrashcan" xreflabel="pgtrashcan">
+ <title>pgtrashcan &mdash; recoverable DROP TABLE</title>
+
+ <indexterm zone="pgtrashcan">
+  <primary>pgtrashcan</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pgtrashcan</filename> module intercepts
+  <command>DROP TABLE</command> and moves the relation to a reserved schema
+  named <literal>pgtrashcan</literal> instead of immediately destroying
+  it. The table can later be restored (<quote>flashed back</quote>) to its
+  original schema with <function>pgtrashcan_restore</function>, or
+  permanently deleted with <function>pgtrashcan_purge</function>.
+ </para>
+
+ <sect2 id="pgtrashcan-installation">
+  <title>Installation</title>
+
+  <para>
+   <filename>pgtrashcan</filename> installs a
+   <symbol>ProcessUtility_hook</symbol>, so the shared library must be
+   preloaded:
+<programlisting>
+shared_preload_libraries = 'pgtrashcan'
+</programlisting>
+   Restart the server, then run, in each database that should use it:
+<programlisting>
+CREATE EXTENSION pgtrashcan;
+</programlisting>
+  </para>
+ </sect2>
+
+ <sect2 id="pgtrashcan-config">
+  <title>Configuration parameters</title>
+
+  <variablelist>
+   <varlistentry>
+    <term><varname>pgtrashcan.enabled</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      Per-session opt-out. When <literal>off</literal>,
+      <command>DROP TABLE</command> behaves like stock PostgreSQL and the
+      relation is permanently destroyed. Existing entries in the
+      <literal>pgtrashcan</literal> schema remain protected.
+      Default is <literal>on</literal>. Scope: <literal>USERSET</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect2>
+
+ <sect2 id="pgtrashcan-catalog">
+  <title>The catalog table</title>
+
+  <para>
+   Every dropped object is recorded in
+   <structname>pgtrashcan._trash_catalog</structname>:
+  </para>
+
+  <table id="pgtrashcan-catalog-columns">
+   <title><structname>pgtrashcan._trash_catalog</structname> columns</title>
+   <tgroup cols="3">
+    <thead>
+     <row><entry>Column</entry><entry>Type</entry><entry>Description</entry></row>
+    </thead>
+    <tbody>
+     <row>
+      <entry><structfield>trash_name</structfield></entry>
+      <entry><type>name</type></entry>
+      <entry>System-generated name in the <literal>pgtrashcan</literal> schema (<literal>trash_$&lt;oid&gt;</literal>). Primary key.</entry>
+     </row>
+     <row>
+      <entry><structfield>orig_name</structfield></entry>
+      <entry><type>name</type></entry>
+      <entry>Original relation name at drop time.</entry>
+     </row>
+     <row>
+      <entry><structfield>orig_schema</structfield></entry>
+      <entry><type>name</type></entry>
+      <entry>Original schema name at drop time.</entry>
+     </row>
+     <row>
+      <entry><structfield>orig_oid</structfield></entry>
+      <entry><type>oid</type></entry>
+      <entry>The relation's OID before it was moved.</entry>
+     </row>
+     <row>
+      <entry><structfield>orig_obj_type</structfield></entry>
+      <entry><type>text</type></entry>
+      <entry>One of <literal>table</literal>, <literal>index</literal>, <literal>sequence</literal>.</entry>
+     </row>
+     <row>
+      <entry><structfield>drop_time</structfield></entry>
+      <entry><type>timestamptz</type></entry>
+      <entry>When the drop ran.</entry>
+     </row>
+     <row>
+      <entry><structfield>dropped_by</structfield></entry>
+      <entry><type>name</type></entry>
+      <entry><function>current_user</function> at drop time.</entry>
+     </row>
+     <row>
+      <entry><structfield>dependent_on_oid</structfield></entry>
+      <entry><type>oid</type></entry>
+      <entry>For indexes and owned sequences, the parent table's <structfield>orig_oid</structfield>.</entry>
+     </row>
+     <row>
+      <entry><structfield>cascade_parent_oid</structfield></entry>
+      <entry><type>oid</type></entry>
+      <entry>For tables moved by <command>CASCADE</command>, the OID of the table that triggered the cascade.</entry>
+     </row>
+     <row>
+      <entry><structfield>metadata</structfield></entry>
+      <entry><type>jsonb</type></entry>
+      <entry>
+       Captured definitions of objects that could not safely follow the
+       table into the trash schema (foreign-key constraints, triggers,
+       views, materialized views, rules, RLS policies). Stored verbatim
+       so a user can recreate them manually after a restore.
+      </entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+
+  <para>
+   The table is protected by a row-level trigger that rejects any
+   <command>INSERT</command>, <command>UPDATE</command>, or
+   <command>DELETE</command> coming from outside the extension's own
+   <literal>SECURITY DEFINER</literal> functions. <command>TRUNCATE</command>
+   and <command>ALTER TABLE</command> on it are blocked from inside the
+   <symbol>ProcessUtility</symbol> hook.
+  </para>
+ </sect2>
+
+ <sect2 id="pgtrashcan-functions">
+  <title>SQL functions</title>
+
+  <variablelist>
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_list_dropped()</function>
+    </term>
+    <listitem>
+     <para>
+      Returns a row per dropped table currently in the trash schema
+      with columns <structfield>trash_name</structfield>,
+      <structfield>orig_name</structfield>,
+      <structfield>orig_schema</structfield>,
+      <structfield>orig_oid</structfield>,
+      <structfield>obj_type</structfield>,
+      <structfield>drop_time</structfield>,
+      <structfield>dropped_by</structfield>,
+      <structfield>is_dependent</structfield>, and
+      <structfield>cascade_parent_oid</structfield>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_list_dropped_grouped()</function>
+    </term>
+    <listitem>
+     <para>
+      Same as <function>pgtrashcan_list_dropped</function>, but groups
+      cascade children under their parent table via a
+      <structfield>drop_group</structfield> column.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_restore</function>(<parameter>orig_name</parameter> <type>text</type>
+     <optional>, <parameter>orig_schema</parameter> <type>text</type></optional>
+     <optional>, <parameter>orig_oid_hint</parameter> <type>oid</type></optional>)
+     returns <type>text</type>
+    </term>
+    <listitem>
+     <para>
+      Restore the most recently dropped table whose original name matches
+      <parameter>orig_name</parameter>. If a schema is provided,
+      restoration is restricted to that schema. The optional OID hint
+      disambiguates among multiple drops of the same name. Indexes and
+      owned sequences come back with the table; captured definitions of
+      foreign keys, triggers, views, rules, and RLS policies are emitted
+      as <literal>NOTICE</literal> messages for manual reconstruction.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_restore_by_oid</function>(<parameter>orig_oid</parameter> <type>oid</type>)
+     returns <type>text</type>
+    </term>
+    <listitem>
+     <para>
+      Restore the table whose pre-drop OID equals <parameter>orig_oid</parameter>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_rename_in_trash</function>(<parameter>trash_name</parameter> <type>text</type>, <parameter>new_name</parameter> <type>text</type>)
+     returns <type>text</type>
+    </term>
+    <listitem>
+     <para>
+      Rename an object inside the trash schema. Useful to make room before
+      a restore that would otherwise collide with an existing table in the
+      original schema.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_move_to_schema</function>(<parameter>trash_name</parameter> <type>text</type>, <parameter>target_schema</parameter> <type>text</type>)
+     returns <type>text</type>
+    </term>
+    <listitem>
+     <para>
+      Move a trashed object out of the <literal>pgtrashcan</literal>
+      schema into <parameter>target_schema</parameter> (which must not
+      be <literal>pgtrashcan</literal>). The catalog entry is removed.
+      Useful when the original schema no longer exists.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_purge</function>(<parameter>orig_name</parameter> <type>text</type>
+     <optional>, <parameter>orig_schema</parameter> <type>text</type></optional>
+     <optional>, <parameter>orig_oid_hint</parameter> <type>oid</type></optional>)
+     returns <type>text</type>
+    </term>
+    <listitem>
+     <para>
+      Permanently delete a table from the trash schema, with its
+      indexes and owned sequences. Same name/schema/OID disambiguation
+      rules as <function>pgtrashcan_restore</function>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_purge_by_oid</function>(<parameter>orig_oid</parameter> <type>oid</type>)
+     returns <type>text</type>
+    </term>
+    <listitem>
+     <para>
+      Permanently delete the trashed table whose pre-drop OID equals
+      <parameter>orig_oid</parameter>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pgtrashcan_purge_all</function>() returns <type>text</type></term>
+    <listitem>
+     <para>
+      Permanently delete every object in the trash schema and clear the
+      catalog. Returns a short summary.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect2>
+
+ <sect2 id="pgtrashcan-behaviour">
+  <title>Behaviour</title>
+
+  <sect3 id="pgtrashcan-naming">
+   <title>Naming inside the trash schema</title>
+   <para>
+    Each moved relation is renamed to <literal>trash_$&lt;oid&gt;</literal>,
+    where <literal>&lt;oid&gt;</literal> is its OID at drop time. The
+    name is always at most 17 characters, never truncated, and unique
+    by construction. The original name is recovered from the catalog at
+    restore time.
+   </para>
+  </sect3>
+
+  <sect3 id="pgtrashcan-moved-objects">
+   <title>What follows the table into the trash schema</title>
+   <para>
+    These objects move with the table and are fully restored on
+    flashback:
+    <itemizedlist>
+     <listitem><para>The heap (including TOAST).</para></listitem>
+     <listitem><para>Indexes, including the primary key.</para></listitem>
+     <listitem><para>Sequences owned by the table (typically <type>SERIAL</type> or <literal>GENERATED ... AS IDENTITY</literal> columns).</para></listitem>
+    </itemizedlist>
+   </para>
+  </sect3>
+
+  <sect3 id="pgtrashcan-dropped-objects">
+   <title>What is permanently dropped</title>
+   <para>
+    The following are dropped at <command>DROP</command> time; their
+    definitions are captured into <structfield>metadata</structfield>
+    (JSONB) and reported as <literal>NOTICE</literal> on restore:
+    <itemizedlist>
+     <listitem><para>Foreign-key constraints (incoming and outgoing).</para></listitem>
+     <listitem><para>Triggers.</para></listitem>
+     <listitem><para>Views and materialized views that referenced the table.</para></listitem>
+     <listitem><para>Rules.</para></listitem>
+     <listitem><para>Row-level security policies.</para></listitem>
+    </itemizedlist>
+    These cannot safely stay attached to a relocated table because they
+    hold <structname>pg_depend</structname> references to live objects
+    (functions, other relations) that may be modified or dropped while
+    the table is in the trash schema.
+   </para>
+  </sect3>
+
+  <sect3 id="pgtrashcan-cascade">
+   <title>CASCADE and RESTRICT</title>
+   <para>
+    <command>DROP TABLE ... CASCADE</command> walks
+    <structname>pg_depend</structname> and
+    <structname>pg_constraint</structname> to also move tables whose
+    foreign keys reference the dropped table. View, matview, rule, and
+    RLS dependents are handled per the rules above (definitions captured,
+    objects dropped).
+   </para>
+   <para>
+    <command>DROP TABLE ... RESTRICT</command> (the default) errors out
+    if any dependent exists, exactly as in stock PostgreSQL.
+   </para>
+   <para>
+    A statement that mixes regular tables and objects already in the
+    <literal>pgtrashcan</literal> schema (for example
+    <literal>DROP TABLE public.foo, pgtrashcan.bar_$12345</literal>) is
+    rejected; the two cases must be issued separately.
+   </para>
+  </sect3>
+
+  <sect3 id="pgtrashcan-transactions">
+   <title>Transactions</title>
+   <para>
+    Every operation is normal DDL and participates in the surrounding
+    transaction. <literal>BEGIN; DROP TABLE t; ROLLBACK;</literal>
+    puts <literal>t</literal> back exactly where it was. The same
+    applies to restore and purge.
+   </para>
+  </sect3>
+
+  <sect3 id="pgtrashcan-protections">
+   <title>Protections on the trash schema</title>
+   <para>
+    While the extension is installed:
+    <itemizedlist>
+     <listitem><para>The <literal>pgtrashcan</literal> schema is created by <command>CREATE EXTENSION</command> and cannot be dropped.</para></listitem>
+     <listitem><para><command>CREATE TABLE</command> directly in the <literal>pgtrashcan</literal> schema is rejected.</para></listitem>
+     <listitem><para><command>ALTER TABLE ... SET SCHEMA</command> and <command>ALTER TABLE ... RENAME</command> on objects in the trash schema are blocked; use <function>pgtrashcan_move_to_schema</function> or <function>pgtrashcan_rename_in_trash</function>.</para></listitem>
+     <listitem><para>Direct DML on <structname>_trash_catalog</structname> is blocked.</para></listitem>
+    </itemizedlist>
+   </para>
+  </sect3>
+
+  <sect3 id="pgtrashcan-temp">
+   <title>Temporary tables</title>
+   <para>
+    Temporary tables are not moved to the trash schema; their drop
+    passes through with a <literal>NOTICE</literal>. A
+    <command>DROP</command> statement that mixes regular and temporary
+    tables is split: the regular tables are moved to the trash schema
+    and the temporary tables are dropped normally.
+   </para>
+  </sect3>
+ </sect2>
+
+ <sect2 id="pgtrashcan-limitations">
+  <title>Limitations</title>
+  <itemizedlist>
+   <listitem><para>Partitioned tables and partitions are not supported; <command>DROP</command> on them is rejected by the hook.</para></listitem>
+   <listitem><para>Foreign tables are not supported.</para></listitem>
+   <listitem><para>The trash schema has no automatic purge; operators are responsible for calling <function>pgtrashcan_purge</function> or <function>pgtrashcan_purge_all</function>.</para></listitem>
+   <listitem><para>Restoring a child table when its parent is still in the trash schema does not auto-restore the parent.</para></listitem>
+  </itemizedlist>
+ </sect2>
+</sect1>
-- 
2.39.5 (Apple Git-154)

