v20251226-0002-SQL-PGQ-Add-PROPERTY_NAMES-graph-element-function.patch
application/octet-stream
Filename: v20251226-0002-SQL-PGQ-Add-PROPERTY_NAMES-graph-element-function.patch
Type: application/octet-stream
Part: 0
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)