From 0418b9a6b1fe998fac8180522d14395b08a2a364 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Date: Mon, 17 Aug 2026 22:27:25 +0000
Subject: [PATCH v5] Omit virtual generated columns from test_decoding output.

Virtual generated columns are not stored on disk, so
heap_getattr() in tuple_to_stringinfo() always returns NULL for
them. test_decoding therefore emitted a NULL value for such a
column even though the user can observe a computed, possibly
non-null value via SELECT. This makes the output ambiguous: a
virtual generated column shown as NULL cannot be told apart from
a column that genuinely holds NULL, even though the two mean very
different things.

The logical replication pgoutput plugin already skips virtual
generated columns in logicalrep_should_publish_column().

Fix this by skipping virtual generated columns in
tuple_to_stringinfo(), the single place that renders every tuple,
so INSERT, UPDATE and DELETE output are all covered. Stored
generated columns continue to be emitted as before because their
values do live in the heap tuple.

No back-patch, as this changes test_decoding's output.

Author: Satya Narlapuram <satyanarlapuram@gmail.com>
Co-authored-by: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Reviewed-by: Euler Taveira <euler@eulerto.com>
Reviewed-by: Fujii Masao <masao.fujii@gmail.com>
Reviewed-by: Masahiko Sawada <sawada.mshk@gmail.com>
Discussion: https://postgr.es/m/CAHg%2BQDfTh3UbB-Ed--o2Bd%3DSBDJoEiG-qp3C0%2BETDibF63y%3Ddw%40mail.gmail.com
---
 contrib/test_decoding/expected/ddl.out | 30 ++++++++++++++++++++++++++
 contrib/test_decoding/sql/ddl.sql      | 17 +++++++++++++++
 contrib/test_decoding/test_decoding.c  | 11 ++++++++++
 3 files changed, 58 insertions(+)

diff --git a/contrib/test_decoding/expected/ddl.out b/contrib/test_decoding/expected/ddl.out
index 6819812e806..3a953a34552 100644
--- a/contrib/test_decoding/expected/ddl.out
+++ b/contrib/test_decoding/expected/ddl.out
@@ -895,6 +895,36 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
 data
 (0 rows)
 \pset format aligned
+-- Virtual generated columns are always stored as null in the tuple, so they
+-- are not printed at all; a printed null would not be distinguishable from a
+-- column that really contains a null. Stored generated columns are printed
+-- as usual.
+CREATE TABLE gtest1 (
+    a int PRIMARY KEY,
+    b int,
+    c int GENERATED ALWAYS AS (a + b) VIRTUAL,
+    d int GENERATED ALWAYS AS (a * 2) STORED,
+    e int
+);
+INSERT INTO gtest1 (a, b) VALUES (1, 10), (2, 20);
+UPDATE gtest1 SET b = 99 WHERE a = 1;
+DELETE FROM gtest1 WHERE a = 2;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+                                         data                                         
+--------------------------------------------------------------------------------------
+ BEGIN
+ table public.gtest1: INSERT: a[integer]:1 b[integer]:10 d[integer]:2 e[integer]:null
+ table public.gtest1: INSERT: a[integer]:2 b[integer]:20 d[integer]:4 e[integer]:null
+ COMMIT
+ BEGIN
+ table public.gtest1: UPDATE: a[integer]:1 b[integer]:99 d[integer]:2 e[integer]:null
+ COMMIT
+ BEGIN
+ table public.gtest1: DELETE: a[integer]:2
+ COMMIT
+(10 rows)
+
+DROP TABLE gtest1;
 SELECT pg_drop_replication_slot('regression_slot');
  pg_drop_replication_slot 
 --------------------------
diff --git a/contrib/test_decoding/sql/ddl.sql b/contrib/test_decoding/sql/ddl.sql
index 6d0b7d77778..5975e55d5ef 100644
--- a/contrib/test_decoding/sql/ddl.sql
+++ b/contrib/test_decoding/sql/ddl.sql
@@ -467,6 +467,23 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
 SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
 \pset format aligned
 
+-- Virtual generated columns are always stored as null in the tuple, so they
+-- are not printed at all; a printed null would not be distinguishable from a
+-- column that really contains a null. Stored generated columns are printed
+-- as usual.
+CREATE TABLE gtest1 (
+    a int PRIMARY KEY,
+    b int,
+    c int GENERATED ALWAYS AS (a + b) VIRTUAL,
+    d int GENERATED ALWAYS AS (a * 2) STORED,
+    e int
+);
+INSERT INTO gtest1 (a, b) VALUES (1, 10), (2, 20);
+UPDATE gtest1 SET b = 99 WHERE a = 1;
+DELETE FROM gtest1 WHERE a = 2;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+DROP TABLE gtest1;
+
 SELECT pg_drop_replication_slot('regression_slot');
 
 /* check that the slot is gone */
diff --git a/contrib/test_decoding/test_decoding.c b/contrib/test_decoding/test_decoding.c
index d5cf0fa02b0..61a311ab825 100644
--- a/contrib/test_decoding/test_decoding.c
+++ b/contrib/test_decoding/test_decoding.c
@@ -554,6 +554,17 @@ tuple_to_stringinfo(StringInfo s, TupleDesc tupdesc, HeapTuple tuple, bool skip_
 		if (attr->attnum < 0)
 			continue;
 
+		/*
+		 * Virtual generated columns are always stored as null in the tuple,
+		 * so don't print them at all; a printed null would not be
+		 * distinguishable from a column that really contains a null. pgoutput
+		 * likewise never publishes virtual generated columns (see
+		 * logicalrep_should_publish_column()). Stored generated columns are
+		 * printed as usual since their values are actually on disk.
+		 */
+		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			continue;
+
 		typid = attr->atttypid;
 
 		/* get Datum from tuple */
-- 
2.47.3

