From 18833cfc35b5530f2a79673c73b851795c31514b Mon Sep 17 00:00:00 2001
From: Henson Choi <assam258@gmail.com>
Date: Fri, 26 Dec 2025 10:07:52 +0900
Subject: [PATCH 2/3] SQL/PGQ: Add PROPERTY_NAMES() graph element function

Implement PROPERTY_NAMES() function that returns all property names of a
graph element as text[]. This follows the SQL/PGQ standard for graph
element functions.

Implementation mirrors LABELS() by wrapping each element table in a
subquery that adds a virtual __property_names__ column containing the
element's property name array. This design enables the query planner to:

- Prune Append branches when filtering by specific properties
  (e.g., WHERE 'name' = ANY(PROPERTY_NAMES(v)) scans only tables with
  'name' property)
- Constant-fold property name arrays at plan time
- Eliminate scans entirely for non-matching property filters

Property names are collected from all labels attached to an element,
with duplicates removed. Elements without PROPERTIES clauses return
an empty array.

Key changes:
- Add GraphPropertyNamesRef node type for PROPERTY_NAMES() in parse tree
- Transform PROPERTY_NAMES(var) to Var referencing __property_names__ column
- Add build_property_names_const() to collect properties from catalog
- Extend create_element_subquery_rte() with property_names_attnum
- Add optimizer pruning tests for property-based filtering
---
 src/backend/nodes/nodeFuncs.c             |   4 +
 src/backend/parser/parse_collate.c        |   1 +
 src/backend/parser/parse_graphtable.c     |  43 +++++
 src/backend/rewrite/rewriteGraphTable.c   | 160 +++++++++++++++-
 src/backend/utils/adt/ruleutils.c         |   8 +
 src/include/nodes/primnodes.h             |  10 +
 src/test/regress/expected/graph_table.out | 218 ++++++++++++++++++++++
 src/test/regress/sql/graph_table.sql      | 104 +++++++++++
 8 files changed, 538 insertions(+), 10 deletions(-)

diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index 8c478a31f49..462e83aa232 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -288,6 +288,7 @@ exprType(const Node *expr)
 			type = ((const GraphPropertyRef *) expr)->typeId;
 			break;
 		case T_GraphLabelsRef:
+		case T_GraphPropertyNamesRef:
 			type = TEXTARRAYOID;
 			break;
 		default:
@@ -545,6 +546,7 @@ exprTypmod(const Node *expr)
 		case T_GraphPropertyRef:
 			return ((const GraphPropertyRef *) expr)->typmod;
 		case T_GraphLabelsRef:
+		case T_GraphPropertyNamesRef:
 			return -1;
 		default:
 			break;
@@ -1072,6 +1074,7 @@ exprCollation(const Node *expr)
 			coll = ((const GraphPropertyRef *) expr)->collation;
 			break;
 		case T_GraphLabelsRef:
+		case T_GraphPropertyNamesRef:
 			coll = DEFAULT_COLLATION_OID;
 			break;
 		default:
@@ -2142,6 +2145,7 @@ expression_tree_walker_impl(Node *node,
 		case T_CTESearchClause:
 		case T_GraphPropertyRef:
 		case T_GraphLabelsRef:
+		case T_GraphPropertyNamesRef:
 		case T_MergeSupportFunc:
 			/* primitive node types with no expression subnodes */
 			break;
diff --git a/src/backend/parser/parse_collate.c b/src/backend/parser/parse_collate.c
index 9aaf01bf58b..ab7a26830cf 100644
--- a/src/backend/parser/parse_collate.c
+++ b/src/backend/parser/parse_collate.c
@@ -548,6 +548,7 @@ assign_collations_walker(Node *node, assign_collations_context *context)
 		case T_CurrentOfExpr:
 		case T_GraphPropertyRef:
 		case T_GraphLabelsRef:
+		case T_GraphPropertyNamesRef:
 
 			/*
 			 * General case for childless expression nodes.  These should
diff --git a/src/backend/parser/parse_graphtable.c b/src/backend/parser/parse_graphtable.c
index 5bd71eae536..2f8ec0cae30 100644
--- a/src/backend/parser/parse_graphtable.c
+++ b/src/backend/parser/parse_graphtable.c
@@ -143,6 +143,49 @@ transformGraphTableFuncCall(ParseState *pstate, FuncCall *fn)
 		return (Node *) glr;
 	}
 
+	if (pg_strcasecmp(funcname, "property_names") == 0)
+	{
+		Node	   *arg;
+		ColumnRef  *cref;
+		char	   *elvarname;
+		GraphPropertyNamesRef *gpnr;
+
+		if (list_length(fn->args) != 1)
+			ereport(ERROR,
+					errcode(ERRCODE_SYNTAX_ERROR),
+					errmsg("PROPERTY_NAMES() requires exactly one argument"),
+					parser_errposition(pstate, fn->location));
+
+		arg = linitial(fn->args);
+
+		if (!IsA(arg, ColumnRef))
+			ereport(ERROR,
+					errcode(ERRCODE_SYNTAX_ERROR),
+					errmsg("PROPERTY_NAMES() argument must be an element variable"),
+					parser_errposition(pstate, fn->location));
+
+		cref = (ColumnRef *) arg;
+		if (list_length(cref->fields) != 1)
+			ereport(ERROR,
+					errcode(ERRCODE_SYNTAX_ERROR),
+					errmsg("PROPERTY_NAMES() argument must be an element variable"),
+					parser_errposition(pstate, cref->location));
+
+		elvarname = strVal(linitial(cref->fields));
+
+		if (!list_member(gpstate->variables, linitial(cref->fields)))
+			ereport(ERROR,
+					errcode(ERRCODE_UNDEFINED_COLUMN),
+					errmsg("element variable \"%s\" does not exist", elvarname),
+					parser_errposition(pstate, cref->location));
+
+		gpnr = makeNode(GraphPropertyNamesRef);
+		gpnr->elvarname = pstrdup(elvarname);
+		gpnr->location = fn->location;
+
+		return (Node *) gpnr;
+	}
+
 	return NULL;
 }
 
diff --git a/src/backend/rewrite/rewriteGraphTable.c b/src/backend/rewrite/rewriteGraphTable.c
index 605ce35da97..44bdc8a1f20 100644
--- a/src/backend/rewrite/rewriteGraphTable.c
+++ b/src/backend/rewrite/rewriteGraphTable.c
@@ -91,6 +91,8 @@ struct path_element
 	List	   *dest_quals;
 	/* Attribute number of __labels__ column in subquery RTE */
 	AttrNumber	labels_attnum;
+	/* Attribute number of __property_names__ column in subquery RTE */
+	AttrNumber	property_names_attnum;
 };
 
 static Node *replace_property_refs(Oid propgraphid, Node *node, const List *mappings);
@@ -98,7 +100,8 @@ static List *build_edge_vertex_link_quals(HeapTuple edgetup, int edgerti, int re
 static List *generate_queries_for_path_pattern(RangeTblEntry *rte, List *element_patterns);
 static Query *generate_query_for_graph_path(RangeTblEntry *rte, List *path);
 static Const *build_labels_const(Oid elemoid);
-static RangeTblEntry *create_element_subquery_rte(struct path_element *pe, Oid reloid, AttrNumber *labels_attnum);
+static Const *build_property_names_const(Oid elemoid);
+static RangeTblEntry *create_element_subquery_rte(struct path_element *pe, Oid reloid, AttrNumber *labels_attnum, AttrNumber *property_names_attnum);
 static Node *generate_setop_from_pathqueries(List *pathqueries, List **rtable, List **targetlist);
 static List *generate_queries_for_path_pattern_recurse(RangeTblEntry *rte, List *pathqueries, List *cur_path, List *path_pattern_lists, int elempos);
 static Query *generate_query_for_empty_path_pattern(RangeTblEntry *rte);
@@ -423,6 +426,7 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 		RangeTblRef *rtr;
 		RangeTblEntry *subrte;
 		AttrNumber	labels_attnum;
+		AttrNumber	property_names_attnum;
 
 		Assert(pf->kind == VERTEX_PATTERN || IS_EDGE_PATTERN(pf->kind));
 
@@ -500,8 +504,9 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 		 * make property graphs as a hole for unprivileged data access. This
 		 * is inline with the views being security_invoker by default.
 		 */
-		subrte = create_element_subquery_rte(pe, pe->reloid, &labels_attnum);
+		subrte = create_element_subquery_rte(pe, pe->reloid, &labels_attnum, &property_names_attnum);
 		pe->labels_attnum = labels_attnum;
+		pe->property_names_attnum = property_names_attnum;
 		path_query->rtable = lappend(path_query->rtable, subrte);
 
 		rtr = makeNode(RangeTblRef);
@@ -1152,6 +1157,36 @@ replace_property_refs_mutator(Node *node, struct replace_property_refs_context *
 
 		return (Node *) var;
 	}
+	else if (IsA(node, GraphPropertyNamesRef))
+	{
+		GraphPropertyNamesRef *gpnr = (GraphPropertyNamesRef *) node;
+		struct path_element *found_mapping = NULL;
+		Var		   *var;
+
+		/* Find the element mapping for this variable */
+		foreach_ptr(struct path_element, m, context->mappings)
+		{
+			if (m->path_factor->variable &&
+				strcmp(gpnr->elvarname, m->path_factor->variable) == 0)
+			{
+				found_mapping = m;
+				break;
+			}
+		}
+		if (!found_mapping)
+			elog(ERROR, "undefined element variable \"%s\"", gpnr->elvarname);
+
+		/*
+		 * Return a Var referencing the __property_names__ column in the
+		 * subquery RTE. This allows the optimizer to push down predicates
+		 * involving PROPERTY_NAMES().
+		 */
+		var = makeVar(found_mapping->path_factor->factorpos + 1,
+					  found_mapping->property_names_attnum,
+					  TEXTARRAYOID, -1, InvalidOid, 0);
+
+		return (Node *) var;
+	}
 
 	return expression_tree_mutator(node, replace_property_refs_mutator, context);
 }
@@ -1533,14 +1568,109 @@ build_labels_const(Oid elemoid)
 }
 
 /*
- * Create a subquery RTE that wraps the element table and adds a __labels__
- * column.  The subquery structure is:
+ * Build a Const node containing an array of property names for the given element.
+ * Returns text[] constant like ARRAY['name', 'age', 'email']::text[].
+ *
+ * Property names are collected from all labels associated with the element,
+ * with duplicates removed (since multiple labels can define the same property).
+ */
+static Const *
+build_property_names_const(Oid elemoid)
+{
+	List	   *prop_names = NIL;
+	ArrayType  *arr;
+	Datum	   *elems;
+	int			nelems;
+	int			i;
+	Relation	elem_label_rel;
+	SysScanDesc elem_label_scan;
+	ScanKeyData elem_label_key[1];
+	HeapTuple	elem_label_tup;
+
+	/* Get all element-label associations for this element */
+	elem_label_rel = table_open(PropgraphElementLabelRelationId, AccessShareLock);
+	ScanKeyInit(&elem_label_key[0],
+				Anum_pg_propgraph_element_label_pgelelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(elemoid));
+	elem_label_scan = systable_beginscan(elem_label_rel,
+										 PropgraphElementLabelElementLabelIndexId,
+										 true, NULL, 1, elem_label_key);
+
+	while (HeapTupleIsValid(elem_label_tup = systable_getnext(elem_label_scan)))
+	{
+		Form_pg_propgraph_element_label elem_label_form =
+			(Form_pg_propgraph_element_label) GETSTRUCT(elem_label_tup);
+		Oid			element_label_oid = elem_label_form->oid;
+		Relation	label_prop_rel;
+		SysScanDesc label_prop_scan;
+		ScanKeyData label_prop_key[1];
+		HeapTuple	label_prop_tup;
+
+		/* Get all properties for this element-label association */
+		label_prop_rel = table_open(PropgraphLabelPropertyRelationId, AccessShareLock);
+		ScanKeyInit(&label_prop_key[0],
+					Anum_pg_propgraph_label_property_plpellabelid,
+					BTEqualStrategyNumber, F_OIDEQ,
+					ObjectIdGetDatum(element_label_oid));
+		label_prop_scan = systable_beginscan(label_prop_rel,
+											 PropgraphLabelPropertyLabelPropIndexId,
+											 true, NULL, 1, label_prop_key);
+
+		while (HeapTupleIsValid(label_prop_tup = systable_getnext(label_prop_scan)))
+		{
+			Form_pg_propgraph_label_property label_prop_form =
+				(Form_pg_propgraph_label_property) GETSTRUCT(label_prop_tup);
+			char	   *propname = get_propgraph_property_name(label_prop_form->plppropid);
+			bool		found = false;
+
+			/* Deduplicate: check if this property name is already in the list */
+			foreach_ptr(String, existing, prop_names)
+			{
+				if (strcmp(strVal(existing), propname) == 0)
+				{
+					found = true;
+					break;
+				}
+			}
+
+			if (!found)
+				prop_names = lappend(prop_names, makeString(propname));
+		}
+
+		systable_endscan(label_prop_scan);
+		table_close(label_prop_rel, AccessShareLock);
+	}
+
+	systable_endscan(elem_label_scan);
+	table_close(elem_label_rel, AccessShareLock);
+
+	/* Build ARRAY['prop1', 'prop2', ...]::text[] */
+	nelems = list_length(prop_names);
+	elems = (Datum *) palloc(nelems * sizeof(Datum));
+	i = 0;
+	foreach_ptr(String, s, prop_names)
+	{
+		elems[i++] = CStringGetTextDatum(strVal(s));
+	}
+
+	arr = construct_array(elems, nelems, TEXTOID, -1, false, TYPALIGN_INT);
+
+	return makeConst(TEXTARRAYOID, -1, InvalidOid,
+					 -1, PointerGetDatum(arr), false, false);
+}
+
+/*
+ * Create a subquery RTE that wraps the element table and adds virtual columns
+ * for graph element functions.  The subquery structure is:
  *
- *     SELECT *, ARRAY['Label1', 'Label2']::text[] AS __labels__
+ *     SELECT *, ARRAY['Label1', 'Label2']::text[] AS __labels__,
+ *               ARRAY['prop1', 'prop2']::text[] AS __property_names__
  *     FROM element_table
  *
- * This allows LABELS(v) to reference a Var instead of a constant, enabling
- * the planner to push down predicates involving LABELS().
+ * This allows LABELS(v) and PROPERTY_NAMES(v) to reference Vars instead of
+ * constants, enabling the planner to push down predicates and prune branches
+ * in UNION queries when filtering by specific labels or properties.
  *
  * The returned subquery RTE does NOT have relid/relkind/perminfoindex set,
  * matching how regular SQL subqueries work.  Permission checking (including
@@ -1548,7 +1678,8 @@ build_labels_const(Oid elemoid)
  * where the actual RTE_RELATION lives.
  */
 static RangeTblEntry *
-create_element_subquery_rte(struct path_element *pe, Oid reloid, AttrNumber *labels_attnum)
+create_element_subquery_rte(struct path_element *pe, Oid reloid,
+							AttrNumber *labels_attnum, AttrNumber *property_names_attnum)
 {
 	Query	   *subquery;
 	RangeTblEntry *subrte;
@@ -1559,7 +1690,9 @@ create_element_subquery_rte(struct path_element *pe, Oid reloid, AttrNumber *lab
 	int			attno;
 	ParseNamespaceItem *pni;
 	Const	   *labels_const;
+	Const	   *property_names_const;
 	TargetEntry *labels_te;
+	TargetEntry *property_names_te;
 
 	subquery = makeNode(Query);
 	subquery->commandType = CMD_SELECT;
@@ -1591,7 +1724,7 @@ create_element_subquery_rte(struct path_element *pe, Oid reloid, AttrNumber *lab
 	rtr->rtindex = 1;
 	subquery->jointree = makeFromExpr(list_make1(rtr), NULL);
 
-	/* Build targetlist: all columns from the table + __labels__ */
+	/* Build targetlist: all columns from the table + virtual columns */
 	subquery->targetList = NIL;
 	attno = 1;
 	for (int i = 0; i < tupdesc->natts; i++)
@@ -1615,7 +1748,14 @@ create_element_subquery_rte(struct path_element *pe, Oid reloid, AttrNumber *lab
 	labels_te = makeTargetEntry((Expr *) labels_const, attno,
 								pstrdup("__labels__"), false);
 	subquery->targetList = lappend(subquery->targetList, labels_te);
-	*labels_attnum = attno;
+	*labels_attnum = attno++;
+
+	/* Add __property_names__ column */
+	property_names_const = build_property_names_const(pe->elemoid);
+	property_names_te = makeTargetEntry((Expr *) property_names_const, attno,
+										pstrdup("__property_names__"), false);
+	subquery->targetList = lappend(subquery->targetList, property_names_te);
+	*property_names_attnum = attno;
 
 	/* Create the wrapper subquery RTE */
 	subrte = makeNode(RangeTblEntry);
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 9aff5b32527..72db4c87a79 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -11151,6 +11151,14 @@ get_rule_expr(Node *node, deparse_context *context,
 				break;
 			}
 
+		case T_GraphPropertyNamesRef:
+			{
+				GraphPropertyNamesRef *gpnr = (GraphPropertyNamesRef *) node;
+
+				appendStringInfo(buf, "PROPERTY_NAMES(%s)", quote_identifier(gpnr->elvarname));
+				break;
+			}
+
 		default:
 			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(node));
 			break;
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index b658bb2be8c..980ddd5d093 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -2211,6 +2211,16 @@ typedef struct GraphLabelsRef
 	ParseLoc	location;
 } GraphLabelsRef;
 
+/*
+ * GraphPropertyNamesRef - PROPERTY_NAMES(element_variable) inside GRAPH_TABLE clause
+ */
+typedef struct GraphPropertyNamesRef
+{
+	Expr		xpr;
+	const char *elvarname;		/* element variable name */
+	ParseLoc	location;
+} GraphPropertyNamesRef;
+
 /*--------------------
  * TargetEntry -
  *	   a target entry (used in query target lists)
diff --git a/src/test/regress/expected/graph_table.out b/src/test/regress/expected/graph_table.out
index c82d7fd7291..a7377a6d768 100644
--- a/src/test/regress/expected/graph_table.out
+++ b/src/test/regress/expected/graph_table.out
@@ -1164,4 +1164,222 @@ EXPLAIN (VERBOSE, COSTS OFF) EXECUTE labels_test('orders');
 (2 rows)
 
 DEALLOCATE labels_test;
+-- PROPERTY_NAMES() function tests
+-- basic PROPERTY_NAMES() in COLUMNS clause
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (c.name, PROPERTY_NAMES(c) AS props)) ORDER BY 1;
+   name    |           props            
+-----------+----------------------------
+ customer1 | {name,customer_id,address}
+ customer2 | {name,customer_id,address}
+ customer3 | {name,customer_id,address}
+(3 rows)
+
+-- PROPERTY_NAMES() for vertices and edges
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers)-[e IS customer_orders]->(o IS orders) COLUMNS (c.name, PROPERTY_NAMES(c) AS cprops, PROPERTY_NAMES(e) AS eprops, PROPERTY_NAMES(o) AS oprops)) ORDER BY 1;
+   name    |           cprops           |                      eprops                       |                  oprops                   
+-----------+----------------------------+---------------------------------------------------+-------------------------------------------
+ customer1 | {name,customer_id,address} | {customer_id,order_id,customer_orders_id,link_id} | {order_id,ordered_when,node_id,list_type}
+ customer2 | {name,customer_id,address} | {customer_id,order_id,customer_orders_id,link_id} | {order_id,ordered_when,node_id,list_type}
+(2 rows)
+
+-- PROPERTY_NAMES() in WHERE clause
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c) WHERE 'name' = ANY(PROPERTY_NAMES(c)) COLUMNS (c.name)) ORDER BY 1;
+   name    
+-----------
+ customer1
+ customer2
+ customer3
+ product1
+ product2
+ product3
+(6 rows)
+
+-- PROPERTY_NAMES() with array functions
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c) WHERE array_length(PROPERTY_NAMES(c), 1) >= 1 COLUMNS (c.name, PROPERTY_NAMES(c) AS props)) ORDER BY 1;
+   name    |                     props                     
+-----------+-----------------------------------------------
+ customer1 | {name,customer_id,address}
+ customer2 | {name,customer_id,address}
+ customer3 | {name,customer_id,address}
+ product1  | {product_no,name,price}
+ product2  | {product_no,name,price}
+ product3  | {product_no,name,price}
+           | {node_id,list_type,wishlist_id,wishlist_name}
+           | {node_id,list_type,wishlist_id,wishlist_name}
+           | {node_id,list_type,wishlist_id,wishlist_name}
+           | {order_id,ordered_when,node_id,list_type}
+           | {order_id,ordered_when,node_id,list_type}
+           | {order_id,ordered_when,node_id,list_type}
+(12 rows)
+
+-- PROPERTY_NAMES() with element that has no properties (g3 has no PROPERTIES clauses)
+SELECT * FROM GRAPH_TABLE (g3 MATCH (s IS pv) COLUMNS (s.val, PROPERTY_NAMES(s) AS props)) ORDER BY 1;
+ val |  props   
+-----+----------
+  10 | {id,val}
+  20 | {id,val}
+  30 | {id,val}
+(3 rows)
+
+SELECT * FROM GRAPH_TABLE (g3 MATCH (s)-[e]->(d) COLUMNS (e.val, PROPERTY_NAMES(e) AS edge_props)) ORDER BY 1;
+ val |    edge_props     
+-----+-------------------
+ 100 | {id,val,src,dest}
+ 200 | {id,val,src,dest}
+ 300 | {id,val,src,dest}
+(3 rows)
+
+-- PROPERTY_NAMES() EXPLAIN tests - check query plan generation
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+);
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on graph_table_tests.customers
+   Output: customers.name, '{name,customer_id,address}'::text[]
+(2 rows)
+
+-- PROPERTY_NAMES() filtering in WHERE clause
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    WHERE 'name' = ANY(PROPERTY_NAMES(c))
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+);
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on graph_table_tests.customers
+   Output: customers.name, '{name,customer_id,address}'::text[]
+(2 rows)
+
+-- PROPERTY_NAMES() filtering outside GRAPH_TABLE (in outer WHERE clause)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+) WHERE 'address' = ANY(props);
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on graph_table_tests.customers
+   Output: customers.name, '{name,customer_id,address}'::text[]
+(2 rows)
+
+-- PROPERTY_NAMES() filtering with nonexistent property (should optimize away with One-Time Filter: false)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    WHERE 'nonexistent_prop' = ANY(PROPERTY_NAMES(c))
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+);
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Result
+   Output: customers.name, '{name,customer_id,address}'::text[]
+   Replaces: Scan on customers
+   One-Time Filter: false
+(4 rows)
+
+-- PROPERTY_NAMES() filtering with existing property (should NOT optimize away)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    WHERE 'name' = ANY(PROPERTY_NAMES(c))
+    COLUMNS (c.name)
+);
+               QUERY PLAN                
+-----------------------------------------
+ Seq Scan on graph_table_tests.customers
+   Output: customers.name
+(2 rows)
+
+-- PROPERTY_NAMES() with shared labels optimization pruning
+-- The 'lists' label is shared by 'orders' and 'wishlists', both have same properties (node_id, list_type)
+-- Filter by property that exists - both branches should remain
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n IS lists)
+    WHERE 'node_id' = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props, n.node_id)
+);
+                                           QUERY PLAN                                           
+------------------------------------------------------------------------------------------------
+ Append
+   ->  Seq Scan on graph_table_tests.orders
+         Output: '{order_id,ordered_when,node_id,list_type}'::text[], orders.order_id
+   ->  Seq Scan on graph_table_tests.wishlists
+         Output: '{node_id,list_type,wishlist_id,wishlist_name}'::text[], wishlists.wishlist_id
+(5 rows)
+
+-- Filter by property that doesn't exist - should show One-Time Filter: false for all branches
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n IS lists)
+    WHERE 'nonexistent' = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props, n.node_id)
+);
+                      QUERY PLAN                      
+------------------------------------------------------
+ Result
+   Output: "graph_table".props, "graph_table".node_id
+   Replaces: Scan on graph_table
+   One-Time Filter: false
+(4 rows)
+
+-- PROPERTY_NAMES() with no IS label - match all vertices, filter by property
+-- Only tables with 'name' property (customers, products) remain in the plan
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n)
+    WHERE 'name' = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props)
+);
+                      QUERY PLAN                      
+------------------------------------------------------
+ Append
+   ->  Seq Scan on graph_table_tests.customers
+         Output: '{name,customer_id,address}'::text[]
+   ->  Seq Scan on graph_table_tests.products
+         Output: '{product_no,name,price}'::text[]
+(5 rows)
+
+-- PROPERTY_NAMES() with host variable (parameter) - optimizer cannot fold at plan time
+PREPARE prop_test(text) AS
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n)
+    WHERE $1 = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props)
+);
+EXPLAIN (VERBOSE, COSTS OFF) EXECUTE prop_test('name');
+                      QUERY PLAN                      
+------------------------------------------------------
+ Append
+   ->  Seq Scan on graph_table_tests.customers
+         Output: '{name,customer_id,address}'::text[]
+   ->  Seq Scan on graph_table_tests.products
+         Output: '{product_no,name,price}'::text[]
+(5 rows)
+
+DEALLOCATE prop_test;
+-- PROPERTY_NAMES() error: undefined variable
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(undefined_var)));
+ERROR:  element variable "undefined_var" does not exist
+LINE 1: ...op MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(undefined_...
+                                                             ^
+-- PROPERTY_NAMES() error: no argument
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES()));
+ERROR:  PROPERTY_NAMES() requires exactly one argument
+LINE 1: ...APH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_N...
+                                                             ^
+-- PROPERTY_NAMES() error: too many arguments
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(c, c)));
+ERROR:  PROPERTY_NAMES() requires exactly one argument
+LINE 1: ...APH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_N...
+                                                             ^
+-- PROPERTY_NAMES() error: non-variable argument
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(123)));
+ERROR:  PROPERTY_NAMES() argument must be an element variable
+LINE 1: ...APH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_N...
+                                                             ^
 -- leave the objects behind for pg_upgrade/pg_dump tests
diff --git a/src/test/regress/sql/graph_table.sql b/src/test/regress/sql/graph_table.sql
index ba841dd8ab1..3e0b10e39d2 100644
--- a/src/test/regress/sql/graph_table.sql
+++ b/src/test/regress/sql/graph_table.sql
@@ -663,4 +663,108 @@ SELECT * FROM GRAPH_TABLE (myshop
 EXPLAIN (VERBOSE, COSTS OFF) EXECUTE labels_test('orders');
 DEALLOCATE labels_test;
 
+-- PROPERTY_NAMES() function tests
+-- basic PROPERTY_NAMES() in COLUMNS clause
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (c.name, PROPERTY_NAMES(c) AS props)) ORDER BY 1;
+
+-- PROPERTY_NAMES() for vertices and edges
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers)-[e IS customer_orders]->(o IS orders) COLUMNS (c.name, PROPERTY_NAMES(c) AS cprops, PROPERTY_NAMES(e) AS eprops, PROPERTY_NAMES(o) AS oprops)) ORDER BY 1;
+
+-- PROPERTY_NAMES() in WHERE clause
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c) WHERE 'name' = ANY(PROPERTY_NAMES(c)) COLUMNS (c.name)) ORDER BY 1;
+
+-- PROPERTY_NAMES() with array functions
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c) WHERE array_length(PROPERTY_NAMES(c), 1) >= 1 COLUMNS (c.name, PROPERTY_NAMES(c) AS props)) ORDER BY 1;
+
+-- PROPERTY_NAMES() with element that has no properties (g3 has no PROPERTIES clauses)
+SELECT * FROM GRAPH_TABLE (g3 MATCH (s IS pv) COLUMNS (s.val, PROPERTY_NAMES(s) AS props)) ORDER BY 1;
+SELECT * FROM GRAPH_TABLE (g3 MATCH (s)-[e]->(d) COLUMNS (e.val, PROPERTY_NAMES(e) AS edge_props)) ORDER BY 1;
+
+-- PROPERTY_NAMES() EXPLAIN tests - check query plan generation
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+);
+
+-- PROPERTY_NAMES() filtering in WHERE clause
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    WHERE 'name' = ANY(PROPERTY_NAMES(c))
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+);
+
+-- PROPERTY_NAMES() filtering outside GRAPH_TABLE (in outer WHERE clause)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+) WHERE 'address' = ANY(props);
+
+-- PROPERTY_NAMES() filtering with nonexistent property (should optimize away with One-Time Filter: false)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    WHERE 'nonexistent_prop' = ANY(PROPERTY_NAMES(c))
+    COLUMNS (c.name, PROPERTY_NAMES(c) AS props)
+);
+
+-- PROPERTY_NAMES() filtering with existing property (should NOT optimize away)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (c IS customers)
+    WHERE 'name' = ANY(PROPERTY_NAMES(c))
+    COLUMNS (c.name)
+);
+
+-- PROPERTY_NAMES() with shared labels optimization pruning
+-- The 'lists' label is shared by 'orders' and 'wishlists', both have same properties (node_id, list_type)
+-- Filter by property that exists - both branches should remain
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n IS lists)
+    WHERE 'node_id' = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props, n.node_id)
+);
+
+-- Filter by property that doesn't exist - should show One-Time Filter: false for all branches
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n IS lists)
+    WHERE 'nonexistent' = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props, n.node_id)
+);
+
+-- PROPERTY_NAMES() with no IS label - match all vertices, filter by property
+-- Only tables with 'name' property (customers, products) remain in the plan
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n)
+    WHERE 'name' = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props)
+);
+
+-- PROPERTY_NAMES() with host variable (parameter) - optimizer cannot fold at plan time
+PREPARE prop_test(text) AS
+SELECT * FROM GRAPH_TABLE (myshop
+    MATCH (n)
+    WHERE $1 = ANY(PROPERTY_NAMES(n))
+    COLUMNS (PROPERTY_NAMES(n) AS props)
+);
+EXPLAIN (VERBOSE, COSTS OFF) EXECUTE prop_test('name');
+DEALLOCATE prop_test;
+
+-- PROPERTY_NAMES() error: undefined variable
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(undefined_var)));
+
+-- PROPERTY_NAMES() error: no argument
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES()));
+
+-- PROPERTY_NAMES() error: too many arguments
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(c, c)));
+
+-- PROPERTY_NAMES() error: non-variable argument
+SELECT * FROM GRAPH_TABLE (myshop MATCH (c IS customers) COLUMNS (PROPERTY_NAMES(123)));
+
 -- leave the objects behind for pg_upgrade/pg_dump tests
-- 
2.50.1 (Apple Git-155)

