This is an automated email from the ASF dual-hosted git repository.
muhammadshoaib pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git
The following commit(s) were added to refs/heads/master by this push:
new 310dc3f6 feature: add create_subgraph() (#2441)
310dc3f6 is described below
commit 310dc3f680a034d81baaf7176ca3359b67b44918
Author: John Gemignani <[email protected]>
AuthorDate: Sat Jun 20 09:55:09 2026 -0700
feature: add create_subgraph() (#2441)
Add the feature create_subgraph() for materialized induced-subgraph
extraction.
Add ag_catalog.create_subgraph(new_graph, from_graph, node_filter,
relationship_filter) which materializes a new, persistent, fully
Cypher-queryable AGE graph as the induced subgraph of an existing graph.
Selection follows the graph-theory induced-subgraph definition as
operationalized by Neo4j GDS gds.graph.filter():
* a vertex is kept iff node_filter holds ('*' keeps all);
* an edge is kept iff relationship_filter holds AND both of its
endpoints were kept (no dangling edges).
Filters are arbitrary Cypher predicates bound to `n` (nodes) and `r`
(relationships) and are evaluated by AGE's own Cypher engine against the
source graph, so the full predicate language is available; label
selection uses label(n)/label(r) since the match pattern is fixed.
Implementation notes:
* Result is a real, ACID, registered graph (create_graph + create_v/
elabel), not a virtual view; it composes with cypher() and itself.
* Entity graphids are reassigned from the destination labels' own
sequences (graphid encodes a per-graph label id), and edge endpoints
are remapped through an old->new vertex map, enforcing the induced
rule via inner joins.
* Source label tables are read with FROM ONLY to avoid double-copying
children under PostgreSQL table inheritance.
* Properties of any agtype are preserved; self-loops and parallel
edges (multigraph structure) are retained.
* SECURITY INVOKER: reads respect the caller's table privileges and
RLS; the new graph is owned by the caller.
* Validates NULL/identical graph names, missing source, pre-existing
destination, and a reserved dollar-quote token in predicates.
Wire-up:
* sql/age_subgraph.sql (new) registered in sql/sql_files after
age_pg_upgrade; identical body added to age--1.7.0--y.y.y.sql so the
upgrade-path catalog comparison matches.
* regress/sql/subgraph.sql + expected output (new), added to REGRESS.
Covers full copy, vertex-induced, node+rel, label-only edge drop,
bipartite, empty result, composability, self-loops/parallel edges,
property fidelity, and error cases over a ~4500-vertex / 2000-edge
source graph.
All 38 regression tests pass against PostgreSQL 18.
Co-authored-by: GitHub Copilot (Claude Opus 4.8) <[email protected]>
modified: Makefile
modified: age--1.7.0--y.y.y.sql
new file: regress/expected/subgraph.out
new file: regress/sql/subgraph.sql
new file: sql/age_subgraph.sql
modified: sql/sql_files
---
Makefile | 3 +-
age--1.7.0--y.y.y.sql | 257 +++++++++++++++++++++++++++++++
regress/expected/subgraph.out | 341 ++++++++++++++++++++++++++++++++++++++++++
regress/sql/subgraph.sql | 189 +++++++++++++++++++++++
sql/age_subgraph.sql | 294 ++++++++++++++++++++++++++++++++++++
sql/sql_files | 1 +
6 files changed, 1084 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index f2a0b9a6..3ea9236a 100644
--- a/Makefile
+++ b/Makefile
@@ -184,7 +184,8 @@ REGRESS = scan \
security \
reserved_keyword_alias \
agtype_jsonb_cast \
- containment_selectivity
+ containment_selectivity \
+ subgraph
ifneq ($(EXTRA_TESTS),)
REGRESS += $(EXTRA_TESTS)
diff --git a/age--1.7.0--y.y.y.sql b/age--1.7.0--y.y.y.sql
index a4cac0c5..282eaa0f 100644
--- a/age--1.7.0--y.y.y.sql
+++ b/age--1.7.0--y.y.y.sql
@@ -800,3 +800,260 @@ ALTER OPERATOR ag_catalog.?&(agtype, text[])
SET (RESTRICT = contsel, JOIN = contjoinsel);
ALTER OPERATOR ag_catalog.?&(agtype, agtype)
SET (RESTRICT = contsel, JOIN = contjoinsel);
+
+--
+-- create_subgraph(): materialized subgraph extraction (see
sql/age_subgraph.sql).
+-- Induced-subgraph semantics matching Neo4j GDS gds.graph.filter(): a vertex
is
+-- kept iff node_filter holds ('*' = all); an edge is kept iff
relationship_filter
+-- holds AND both endpoints are kept. Produces a persistent, Cypher-queryable
graph.
+--
+CREATE FUNCTION ag_catalog.create_subgraph(new_graph name,
+ from_graph name,
+ node_filter text DEFAULT '*',
+ relationship_filter text DEFAULT
'*')
+ RETURNS TABLE(node_count bigint, relationship_count bigint)
+ LANGUAGE plpgsql
+ VOLATILE
+ SET search_path = ag_catalog, pg_catalog
+ AS $function$
+DECLARE
+ from_oid oid;
+ new_oid oid;
+ v_node_count bigint := 0;
+ v_rel_count bigint := 0;
+ rec RECORD;
+ cypher_q text;
+ where_clause text;
+ dst_label_id int;
+ dst_seq_fqn text;
+ dst_relation text;
+ inserted bigint;
+ has_rows boolean;
+BEGIN
+ -- Argument validation.
+ IF new_graph IS NULL THEN
+ RAISE EXCEPTION 'new graph name must not be NULL';
+ END IF;
+ IF from_graph IS NULL THEN
+ RAISE EXCEPTION 'source graph name must not be NULL';
+ END IF;
+ IF new_graph = from_graph THEN
+ RAISE EXCEPTION 'cannot extract a subgraph of "%" into itself',
from_graph;
+ END IF;
+
+ -- NULL predicate is treated as the '*' wildcard (keep all).
+ IF node_filter IS NULL THEN
+ node_filter := '*';
+ END IF;
+ IF relationship_filter IS NULL THEN
+ relationship_filter := '*';
+ END IF;
+
+ -- The predicates are embedded into a dollar-quoted cypher() query using
the
+ -- $age_subgraph$ tag; reject predicates that contain the tag to keep the
+ -- quoting unambiguous.
+ IF position('$age_subgraph$' IN node_filter) > 0
+ OR position('$age_subgraph$' IN relationship_filter) > 0 THEN
+ RAISE EXCEPTION 'filter predicate must not contain the reserved token
$age_subgraph$';
+ END IF;
+
+ -- Validate source graph exists.
+ SELECT graphid INTO from_oid
+ FROM ag_catalog.ag_graph WHERE name = from_graph;
+ IF from_oid IS NULL THEN
+ RAISE EXCEPTION 'graph "%" does not exist', from_graph;
+ END IF;
+
+ -- Validate destination graph does not exist (create_graph also enforces
+ -- naming rules and uniqueness, but we give a clear early error).
+ IF EXISTS (SELECT 1 FROM ag_catalog.ag_graph WHERE name = new_graph) THEN
+ RAISE EXCEPTION 'graph "%" already exists', new_graph;
+ END IF;
+
+ -- Create the destination graph (default labels are created automatically).
+ PERFORM ag_catalog.create_graph(new_graph);
+
+ SELECT graphid INTO new_oid
+ FROM ag_catalog.ag_graph WHERE name = new_graph;
+
+ -- Working sets / mapping (uniquely named to avoid colliding with user
temps).
+ DROP TABLE IF EXISTS _ag_sg_kept_v;
+ DROP TABLE IF EXISTS _ag_sg_kept_e;
+ DROP TABLE IF EXISTS _ag_sg_vmap;
+ DROP TABLE IF EXISTS _ag_sg_vstage;
+ DROP TABLE IF EXISTS _ag_sg_estage;
+
+ --
+ -- Kept vertices: evaluate node_filter with AGE's Cypher engine. The node
+ -- variable `n` is bound exactly as in the spec; '*' selects all vertices.
+ --
+ IF node_filter IS NULL OR btrim(node_filter) = '*' THEN
+ where_clause := '';
+ ELSE
+ where_clause := ' WHERE ' || node_filter;
+ END IF;
+ cypher_q := 'MATCH (n)' || where_clause || ' RETURN id(n)';
+
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_kept_v ON COMMIT DROP AS '
+ 'SELECT DISTINCT ag_catalog.agtype_to_graphid(vid) AS gid '
+ 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (vid
agtype)',
+ from_graph, cypher_q);
+ CREATE INDEX ON _ag_sg_kept_v (gid);
+
+ --
+ -- Kept edges: evaluate relationship_filter with AGE's Cypher engine. The
+ -- relationship variable `r` is bound exactly as in the spec.
+ --
+ IF relationship_filter IS NULL OR btrim(relationship_filter) = '*' THEN
+ where_clause := '';
+ ELSE
+ where_clause := ' WHERE ' || relationship_filter;
+ END IF;
+ cypher_q := 'MATCH ()-[r]->()' || where_clause || ' RETURN id(r)';
+
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_kept_e ON COMMIT DROP AS '
+ 'SELECT DISTINCT ag_catalog.agtype_to_graphid(eid) AS gid '
+ 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (eid
agtype)',
+ from_graph, cypher_q);
+ CREATE INDEX ON _ag_sg_kept_e (gid);
+
+ -- old -> new vertex id mapping (graphid is unique within a graph).
+ CREATE TEMP TABLE _ag_sg_vmap (old_id graphid PRIMARY KEY,
+ new_id graphid NOT NULL) ON COMMIT DROP;
+
+ --
+ -- PASS 1: copy kept vertices, label by label, assigning new graphids and
+ -- recording the old->new mapping for edge remapping.
+ --
+ FOR rec IN
+ SELECT name, id, relation, seq_name
+ FROM ag_catalog.ag_label
+ WHERE graph = from_oid AND kind = 'v'
+ ORDER BY id
+ LOOP
+ -- Skip labels with no surviving vertices. Read ONLY this label's own
+ -- rows: AGE label tables use table inheritance (custom labels inherit
+ -- from _ag_label_vertex), so a plain scan of a parent would also
return
+ -- its children and copy them twice.
+ EXECUTE format(
+ 'SELECT EXISTS (SELECT 1 FROM ONLY %s t '
+ 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id))',
+ rec.relation::regclass::text)
+ INTO has_rows;
+ IF NOT has_rows THEN
+ CONTINUE;
+ END IF;
+
+ -- Ensure the label exists in the destination graph.
+ IF rec.name <> '_ag_label_vertex' THEN
+ PERFORM 1 FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ IF NOT FOUND THEN
+ EXECUTE format('SELECT ag_catalog.create_vlabel(%L, %L)',
+ new_graph, rec.name);
+ END IF;
+ END IF;
+
+ SELECT id, seq_name, relation::regclass::text
+ INTO dst_label_id, dst_seq_fqn, dst_relation
+ FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn);
+
+ -- Stage surviving vertices with freshly generated ids in a real temp
+ -- table (single evaluation), then copy to the label table and record
+ -- the old->new mapping. A materialized stage avoids any ambiguity from
+ -- referencing a nextval-bearing CTE more than once.
+ DROP TABLE IF EXISTS _ag_sg_vstage;
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_vstage ON COMMIT DROP AS '
+ 'SELECT t.id AS old_id, '
+ ' ag_catalog._graphid(%s, nextval(%L)) AS new_id, '
+ ' t.properties AS props '
+ 'FROM ONLY %s t '
+ 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id)',
+ dst_label_id, dst_seq_fqn, rec.relation::regclass::text);
+
+ EXECUTE format('INSERT INTO %s (id, properties) '
+ 'SELECT new_id, props FROM _ag_sg_vstage',
dst_relation);
+
+ INSERT INTO _ag_sg_vmap (old_id, new_id)
+ SELECT old_id, new_id FROM _ag_sg_vstage;
+
+ DROP TABLE _ag_sg_vstage;
+ END LOOP;
+
+ SELECT count(*) INTO v_node_count FROM _ag_sg_vmap;
+
+ --
+ -- PASS 2: copy kept edges, remapping endpoints. The joins to _ag_sg_vmap
+ -- enforce the induced rule (an edge survives only if BOTH endpoints were
+ -- kept); membership in _ag_sg_kept_e applies relationship_filter.
+ --
+ FOR rec IN
+ SELECT name, id, relation, seq_name
+ FROM ag_catalog.ag_label
+ WHERE graph = from_oid AND kind = 'e'
+ ORDER BY id
+ LOOP
+ -- Skip labels with no surviving edges. Read ONLY this label's own rows
+ -- (see the vertex pass for why inheritance requires ONLY).
+ EXECUTE format(
+ 'SELECT EXISTS ('
+ ' SELECT 1 FROM ONLY %s x '
+ ' JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id '
+ ' JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id '
+ ' WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid =
x.id))',
+ rec.relation::regclass::text)
+ INTO has_rows;
+ IF NOT has_rows THEN
+ CONTINUE;
+ END IF;
+
+ IF rec.name <> '_ag_label_edge' THEN
+ PERFORM 1 FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ IF NOT FOUND THEN
+ EXECUTE format('SELECT ag_catalog.create_elabel(%L, %L)',
+ new_graph, rec.name);
+ END IF;
+ END IF;
+
+ SELECT id, seq_name, relation::regclass::text
+ INTO dst_label_id, dst_seq_fqn, dst_relation
+ FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn);
+
+ -- Stage surviving edges, remapping endpoints through _ag_sg_vmap. The
+ -- joins enforce the induced rule (both endpoints kept); membership in
+ -- _ag_sg_kept_e applies relationship_filter.
+ DROP TABLE IF EXISTS _ag_sg_estage;
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_estage ON COMMIT DROP AS '
+ 'SELECT ag_catalog._graphid(%s, nextval(%L)) AS new_id, '
+ ' vs.new_id AS new_start, ve.new_id AS new_end, '
+ ' x.properties AS props '
+ 'FROM ONLY %s x '
+ 'JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id '
+ 'JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id '
+ 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid = x.id)',
+ dst_label_id, dst_seq_fqn, rec.relation::regclass::text);
+
+ EXECUTE format('INSERT INTO %s (id, start_id, end_id, properties) '
+ 'SELECT new_id, new_start, new_end, props '
+ 'FROM _ag_sg_estage', dst_relation);
+ GET DIAGNOSTICS inserted = ROW_COUNT;
+ v_rel_count := v_rel_count + inserted;
+
+ DROP TABLE _ag_sg_estage;
+ END LOOP;
+
+ RETURN QUERY SELECT v_node_count, v_rel_count;
+END;
+$function$;
+
+COMMENT ON FUNCTION ag_catalog.create_subgraph(name, name, text, text) IS
+'Materializes a new persistent graph as the induced subgraph of from_graph
selected by a Cypher node predicate (on n) and relationship predicate (on r);
''*'' keeps all. An edge is kept only if its predicate holds and both endpoints
are kept. Returns (node_count, relationship_count).';
diff --git a/regress/expected/subgraph.out b/regress/expected/subgraph.out
new file mode 100644
index 00000000..a27569a5
--- /dev/null
+++ b/regress/expected/subgraph.out
@@ -0,0 +1,341 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+LOAD 'age';
+SET search_path TO ag_catalog;
+-- Suppress the create_graph / create_vlabel NOTICE chatter so the assertions
+-- below are the deterministic output. (The feature is exercised regardless.)
+SET client_min_messages = warning;
+--
+-- Build a "somewhat large" source graph with NO MATCH (fast bulk CREATE):
+-- * 2000 isolated components, each
(:Person{pid,age})-[:KNOWS{w}]->(:Friend{pid})
+-- => 2000 Person + 2000 Friend vertices, 2000 KNOWS edges
+-- * 500 isolated :Company vertices (no edges)
+-- Totals: 4500 vertices, 2000 edges, label set {Person,Friend,Company,KNOWS}.
+--
+SELECT create_graph('sg_src');
+ create_graph
+--------------
+
+(1 row)
+
+SELECT count(*) FROM cypher('sg_src', $$
+ UNWIND range(1, 2000) AS i
+ CREATE (:Person {pid: i, age: i % 100})-[:KNOWS {w: i}]->(:Friend {pid: i})
+$$) AS (a agtype);
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM cypher('sg_src', $$
+ UNWIND range(1, 500) AS i CREATE (:Company {cid: i})
+$$) AS (a agtype);
+ count
+-------
+ 0
+(1 row)
+
+-- Source baseline (printed for reference; deterministic).
+SELECT
+ (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n
agtype)) AS src_vertices,
+ (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype)) AS src_edges;
+ src_vertices | src_edges
+--------------+-----------
+ 4500 | 2000
+(1 row)
+
+--
+-- 1. Full copy ('*','*'): counts equal the source, and the new graph
round-trips.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_all', 'sg_src', '*', '*');
+ node_count | relationship_count
+------------+--------------------
+ 4500 | 2000
+(1 row)
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_all', $$ MATCH (n) RETURN n $$) AS (n
agtype))
+ = (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n
agtype)) AS nodes_match,
+ (SELECT count(*) FROM cypher('sg_all', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r
$$) AS (r agtype)) AS edges_match;
+ nodes_match | edges_match
+-------------+-------------
+ t | t
+(1 row)
+
+--
+-- 2. Vertex-induced (node filter only): keep pid <= 1000. An edge survives iff
+-- BOTH endpoints survive (induced rule), with no relationship filter.
+-- node_count is asserted against the function return; correctness is
verified
+-- by recomputing the induced set from the source (robust booleans).
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_v', 'sg_src', 'n.pid <= 1000', '*');
+ node_count | relationship_count
+------------+--------------------
+ 2000 | 1000
+(1 row)
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_v', $$ MATCH (n) RETURN n $$) AS (n
agtype))
+ = (SELECT count(*) FROM cypher('sg_src',
+ $$ MATCH (n) WHERE n.pid <= 1000 RETURN n $$) AS (n agtype)) AS
nodes_ok,
+ (SELECT count(*) FROM cypher('sg_v', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_src',
+ $$ MATCH (a)-[r]->(b) WHERE a.pid <= 1000 AND b.pid <= 1000 RETURN
r $$)
+ AS (r agtype)) AS edges_ok;
+ nodes_ok | edges_ok
+----------+----------
+ t | t
+(1 row)
+
+--
+-- 3. Node + relationship predicate: keep pid <= 1000 vertices and w <= 300
edges.
+-- Edge survives iff w<=300 AND both endpoints pid<=1000.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_nr', 'sg_src', 'n.pid <= 1000', 'r.w <= 300');
+ node_count | relationship_count
+------------+--------------------
+ 2000 | 300
+(1 row)
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_nr', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_src',
+ $$ MATCH (a)-[r]->(b) WHERE r.w <= 300 AND a.pid <= 1000 AND b.pid
<= 1000
+ RETURN r $$) AS (r agtype)) AS edges_ok;
+ edges_ok
+----------
+ t
+(1 row)
+
+--
+-- 4. Label filter excludes one endpoint type: keep only :Person. Every KNOWS
+-- edge points Person->Friend, so all edges must be dropped (induced rule).
+-- (AGE evaluates label predicates with label(n); GDS uses n:Person -- same
+-- containment semantics, different predicate syntax.)
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_person', 'sg_src', $f$label(n) = 'Person'$f$, '*');
+ node_count | relationship_count
+------------+--------------------
+ 2000 | 0
+(1 row)
+
+--
+-- 5. Bipartite (type filter): keep Person+Friend and KNOWS edges => all 2000.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_bip', 'sg_src',
+ $f$label(n) = 'Person' OR label(n) = 'Friend'$f$,
+ $f$label(r) = 'KNOWS'$f$);
+ node_count | relationship_count
+------------+--------------------
+ 4000 | 2000
+(1 row)
+
+--
+-- 6. Empty result: a predicate matching nothing yields an empty subgraph
+-- (not an error), with the default labels only.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_empty', 'sg_src', 'n.pid < 0', '*');
+ node_count | relationship_count
+------------+--------------------
+ 0 | 0
+(1 row)
+
+SELECT count(*) AS empty_vertices
+FROM cypher('sg_empty', $$ MATCH (n) RETURN n $$) AS (n agtype);
+ empty_vertices
+----------------
+ 0
+(1 row)
+
+--
+-- 7. Composability: extract a subgraph from an already-extracted subgraph.
+-- From sg_v (pid<=1000) keep pid<=500; verify against recomputation on
sg_v.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_v2', 'sg_v', 'n.pid <= 500', '*');
+ node_count | relationship_count
+------------+--------------------
+ 1000 | 500
+(1 row)
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_v2', $$ MATCH (n) RETURN n $$) AS (n
agtype))
+ = (SELECT count(*) FROM cypher('sg_v',
+ $$ MATCH (n) WHERE n.pid <= 500 RETURN n $$) AS (n agtype)) AS
nodes_ok,
+ (SELECT count(*) FROM cypher('sg_v2', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_v',
+ $$ MATCH (a)-[r]->(b) WHERE a.pid <= 500 AND b.pid <= 500 RETURN r
$$)
+ AS (r agtype)) AS edges_ok;
+ nodes_ok | edges_ok
+----------+----------
+ t | t
+(1 row)
+
+--
+-- 8. Self-loops and parallel edges (multigraph structure) are preserved.
+--
+SELECT create_graph('sg_multi');
+ create_graph
+--------------
+
+(1 row)
+
+SELECT * FROM cypher('sg_multi', $$
+ CREATE (a:N {k: 1}) CREATE (a)-[:E {t: 1}]->(a)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('sg_multi', $$
+ CREATE (a:N {k: 2}), (b:N {k: 3}),
+ (a)-[:E {t: 2}]->(b), (a)-[:E {t: 3}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_multi_sub', 'sg_multi', '*', '*');
+ node_count | relationship_count
+------------+--------------------
+ 3 | 3
+(1 row)
+
+-- self-loop preserved (exactly one edge from a node to itself)
+SELECT count(*) AS self_loops
+FROM cypher('sg_multi_sub', $$ MATCH (a)-[r]->(a) RETURN r $$) AS (r agtype);
+ self_loops
+------------
+ 1
+(1 row)
+
+-- parallel edges preserved (two edges between k=2 and k=3)
+SELECT count(*) AS parallel_edges
+FROM cypher('sg_multi_sub', $$ MATCH (a {k: 2})-[r]->(b {k: 3}) RETURN r $$)
AS (r agtype);
+ parallel_edges
+----------------
+ 2
+(1 row)
+
+--
+-- 9. Property fidelity: a copied vertex keeps its properties verbatim.
+--
+SELECT count(*) AS person_500_age_ok
+FROM cypher('sg_v', $$ MATCH (n:Person {pid: 500}) WHERE n.age = 0 RETURN n
$$) AS (n agtype);
+ person_500_age_ok
+-------------------
+ 1
+(1 row)
+
+--
+-- 10. Error handling / edge cases.
+--
+-- NULL graph name
+SELECT create_subgraph(NULL, 'sg_src', '*', '*');
+ERROR: new graph name must not be NULL
+CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 18 at
RAISE
+-- source does not exist
+SELECT create_subgraph('sg_x', 'no_such_graph', '*', '*');
+ERROR: graph "no_such_graph" does not exist
+CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 47 at
RAISE
+-- extracting into the source itself
+SELECT create_subgraph('sg_src', 'sg_src', '*', '*');
+ERROR: cannot extract a subgraph of "sg_src" into itself
+CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 24 at
RAISE
+-- destination already exists
+SELECT create_subgraph('sg_all', 'sg_src', '*', '*');
+ERROR: graph "sg_all" already exists
+CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 53 at
RAISE
+-- invalid Cypher predicate is reported (propagated from the engine)
+SELECT create_subgraph('sg_bad', 'sg_src', 'n.pid <<>> 1', '*');
+ERROR: operator does not exist: agtype <<>> agtype
+LINE 1: ...her('sg_src', $age_subgraph$MATCH (n) WHERE n.pid <<>> 1 RET...
+ ^
+HINT: No operator matches the given name and argument types. You might need
to add explicit type casts.
+QUERY: CREATE TEMP TABLE _ag_sg_kept_v ON COMMIT DROP AS SELECT DISTINCT
ag_catalog.agtype_to_graphid(vid) AS gid FROM ag_catalog.cypher('sg_src',
$age_subgraph$MATCH (n) WHERE n.pid <<>> 1 RETURN id(n)$age_subgraph$) AS (vid
agtype)
+CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 80 at
EXECUTE
+-- cleanup
+SELECT drop_graph('sg_v2', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_multi_sub', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_multi', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_empty', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_bip', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_person', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_nr', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_v', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_all', true);
+ drop_graph
+------------
+
+(1 row)
+
+SELECT drop_graph('sg_src', true);
+ drop_graph
+------------
+
+(1 row)
+
diff --git a/regress/sql/subgraph.sql b/regress/sql/subgraph.sql
new file mode 100644
index 00000000..0d01dfe6
--- /dev/null
+++ b/regress/sql/subgraph.sql
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+LOAD 'age';
+SET search_path TO ag_catalog;
+
+-- Suppress the create_graph / create_vlabel NOTICE chatter so the assertions
+-- below are the deterministic output. (The feature is exercised regardless.)
+SET client_min_messages = warning;
+
+--
+-- Build a "somewhat large" source graph with NO MATCH (fast bulk CREATE):
+-- * 2000 isolated components, each
(:Person{pid,age})-[:KNOWS{w}]->(:Friend{pid})
+-- => 2000 Person + 2000 Friend vertices, 2000 KNOWS edges
+-- * 500 isolated :Company vertices (no edges)
+-- Totals: 4500 vertices, 2000 edges, label set {Person,Friend,Company,KNOWS}.
+--
+SELECT create_graph('sg_src');
+
+SELECT count(*) FROM cypher('sg_src', $$
+ UNWIND range(1, 2000) AS i
+ CREATE (:Person {pid: i, age: i % 100})-[:KNOWS {w: i}]->(:Friend {pid: i})
+$$) AS (a agtype);
+
+SELECT count(*) FROM cypher('sg_src', $$
+ UNWIND range(1, 500) AS i CREATE (:Company {cid: i})
+$$) AS (a agtype);
+
+-- Source baseline (printed for reference; deterministic).
+SELECT
+ (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n
agtype)) AS src_vertices,
+ (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype)) AS src_edges;
+
+--
+-- 1. Full copy ('*','*'): counts equal the source, and the new graph
round-trips.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_all', 'sg_src', '*', '*');
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_all', $$ MATCH (n) RETURN n $$) AS (n
agtype))
+ = (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n
agtype)) AS nodes_match,
+ (SELECT count(*) FROM cypher('sg_all', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r
$$) AS (r agtype)) AS edges_match;
+
+--
+-- 2. Vertex-induced (node filter only): keep pid <= 1000. An edge survives iff
+-- BOTH endpoints survive (induced rule), with no relationship filter.
+-- node_count is asserted against the function return; correctness is
verified
+-- by recomputing the induced set from the source (robust booleans).
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_v', 'sg_src', 'n.pid <= 1000', '*');
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_v', $$ MATCH (n) RETURN n $$) AS (n
agtype))
+ = (SELECT count(*) FROM cypher('sg_src',
+ $$ MATCH (n) WHERE n.pid <= 1000 RETURN n $$) AS (n agtype)) AS
nodes_ok,
+ (SELECT count(*) FROM cypher('sg_v', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_src',
+ $$ MATCH (a)-[r]->(b) WHERE a.pid <= 1000 AND b.pid <= 1000 RETURN
r $$)
+ AS (r agtype)) AS edges_ok;
+
+--
+-- 3. Node + relationship predicate: keep pid <= 1000 vertices and w <= 300
edges.
+-- Edge survives iff w<=300 AND both endpoints pid<=1000.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_nr', 'sg_src', 'n.pid <= 1000', 'r.w <= 300');
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_nr', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_src',
+ $$ MATCH (a)-[r]->(b) WHERE r.w <= 300 AND a.pid <= 1000 AND b.pid
<= 1000
+ RETURN r $$) AS (r agtype)) AS edges_ok;
+
+--
+-- 4. Label filter excludes one endpoint type: keep only :Person. Every KNOWS
+-- edge points Person->Friend, so all edges must be dropped (induced rule).
+-- (AGE evaluates label predicates with label(n); GDS uses n:Person -- same
+-- containment semantics, different predicate syntax.)
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_person', 'sg_src', $f$label(n) = 'Person'$f$, '*');
+
+--
+-- 5. Bipartite (type filter): keep Person+Friend and KNOWS edges => all 2000.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_bip', 'sg_src',
+ $f$label(n) = 'Person' OR label(n) = 'Friend'$f$,
+ $f$label(r) = 'KNOWS'$f$);
+
+--
+-- 6. Empty result: a predicate matching nothing yields an empty subgraph
+-- (not an error), with the default labels only.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_empty', 'sg_src', 'n.pid < 0', '*');
+
+SELECT count(*) AS empty_vertices
+FROM cypher('sg_empty', $$ MATCH (n) RETURN n $$) AS (n agtype);
+
+--
+-- 7. Composability: extract a subgraph from an already-extracted subgraph.
+-- From sg_v (pid<=1000) keep pid<=500; verify against recomputation on
sg_v.
+--
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_v2', 'sg_v', 'n.pid <= 500', '*');
+
+SELECT
+ (SELECT count(*) FROM cypher('sg_v2', $$ MATCH (n) RETURN n $$) AS (n
agtype))
+ = (SELECT count(*) FROM cypher('sg_v',
+ $$ MATCH (n) WHERE n.pid <= 500 RETURN n $$) AS (n agtype)) AS
nodes_ok,
+ (SELECT count(*) FROM cypher('sg_v2', $$ MATCH ()-[r]->() RETURN r $$) AS
(r agtype))
+ = (SELECT count(*) FROM cypher('sg_v',
+ $$ MATCH (a)-[r]->(b) WHERE a.pid <= 500 AND b.pid <= 500 RETURN r
$$)
+ AS (r agtype)) AS edges_ok;
+
+--
+-- 8. Self-loops and parallel edges (multigraph structure) are preserved.
+--
+SELECT create_graph('sg_multi');
+SELECT * FROM cypher('sg_multi', $$
+ CREATE (a:N {k: 1}) CREATE (a)-[:E {t: 1}]->(a)
+$$) AS (a agtype);
+SELECT * FROM cypher('sg_multi', $$
+ CREATE (a:N {k: 2}), (b:N {k: 3}),
+ (a)-[:E {t: 2}]->(b), (a)-[:E {t: 3}]->(b)
+$$) AS (a agtype);
+
+SELECT node_count, relationship_count
+FROM create_subgraph('sg_multi_sub', 'sg_multi', '*', '*');
+
+-- self-loop preserved (exactly one edge from a node to itself)
+SELECT count(*) AS self_loops
+FROM cypher('sg_multi_sub', $$ MATCH (a)-[r]->(a) RETURN r $$) AS (r agtype);
+
+-- parallel edges preserved (two edges between k=2 and k=3)
+SELECT count(*) AS parallel_edges
+FROM cypher('sg_multi_sub', $$ MATCH (a {k: 2})-[r]->(b {k: 3}) RETURN r $$)
AS (r agtype);
+
+--
+-- 9. Property fidelity: a copied vertex keeps its properties verbatim.
+--
+SELECT count(*) AS person_500_age_ok
+FROM cypher('sg_v', $$ MATCH (n:Person {pid: 500}) WHERE n.age = 0 RETURN n
$$) AS (n agtype);
+
+--
+-- 10. Error handling / edge cases.
+--
+-- NULL graph name
+SELECT create_subgraph(NULL, 'sg_src', '*', '*');
+-- source does not exist
+SELECT create_subgraph('sg_x', 'no_such_graph', '*', '*');
+-- extracting into the source itself
+SELECT create_subgraph('sg_src', 'sg_src', '*', '*');
+-- destination already exists
+SELECT create_subgraph('sg_all', 'sg_src', '*', '*');
+-- invalid Cypher predicate is reported (propagated from the engine)
+SELECT create_subgraph('sg_bad', 'sg_src', 'n.pid <<>> 1', '*');
+
+-- cleanup
+SELECT drop_graph('sg_v2', true);
+SELECT drop_graph('sg_multi_sub', true);
+SELECT drop_graph('sg_multi', true);
+SELECT drop_graph('sg_empty', true);
+SELECT drop_graph('sg_bip', true);
+SELECT drop_graph('sg_person', true);
+SELECT drop_graph('sg_nr', true);
+SELECT drop_graph('sg_v', true);
+SELECT drop_graph('sg_all', true);
+SELECT drop_graph('sg_src', true);
diff --git a/sql/age_subgraph.sql b/sql/age_subgraph.sql
new file mode 100644
index 00000000..960790de
--- /dev/null
+++ b/sql/age_subgraph.sql
@@ -0,0 +1,294 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+--
+-- create_subgraph(): materialized subgraph extraction.
+--
+-- Builds a new, persistent AGE graph that is the subgraph of an existing graph
+-- selected by a node predicate and a relationship predicate. The semantics
+-- follow the graph-theory "induced subgraph" definition as operationalized by
+-- Neo4j GDS gds.graph.filter():
+--
+-- * a vertex is kept iff node_filter evaluates true ('*' keeps all);
+-- * an edge is kept iff relationship_filter evaluates true AND BOTH of its
+-- endpoints were kept (the induced rule -- no dangling edges).
+--
+-- Unlike the Neo4j in-memory projection, the result is a real, ACID,
+-- fully-Cypher-queryable AGE graph; properties of any agtype are preserved,
and
+-- self-loops / parallel edges (multigraph structure) are kept.
+--
+-- node_filter / relationship_filter are Cypher predicates bound to a single
+-- entity -- the node variable is `n`, the relationship variable is `r` -- or
+-- the literal '*' to keep all. They are evaluated by AGE's own Cypher engine
+-- against the source graph, so the full Cypher predicate language is
available.
+--
+-- Internal entity ids (graphids) are reassigned in the new graph (a graphid
+-- encodes the source graph's label id, which differs in the destination), and
+-- edge endpoints are remapped accordingly. Properties are copied verbatim.
+--
+CREATE FUNCTION ag_catalog.create_subgraph(new_graph name,
+ from_graph name,
+ node_filter text DEFAULT '*',
+ relationship_filter text DEFAULT
'*')
+ RETURNS TABLE(node_count bigint, relationship_count bigint)
+ LANGUAGE plpgsql
+ VOLATILE
+ SET search_path = ag_catalog, pg_catalog
+ AS $function$
+DECLARE
+ from_oid oid;
+ new_oid oid;
+ v_node_count bigint := 0;
+ v_rel_count bigint := 0;
+ rec RECORD;
+ cypher_q text;
+ where_clause text;
+ dst_label_id int;
+ dst_seq_fqn text;
+ dst_relation text;
+ inserted bigint;
+ has_rows boolean;
+BEGIN
+ -- Argument validation.
+ IF new_graph IS NULL THEN
+ RAISE EXCEPTION 'new graph name must not be NULL';
+ END IF;
+ IF from_graph IS NULL THEN
+ RAISE EXCEPTION 'source graph name must not be NULL';
+ END IF;
+ IF new_graph = from_graph THEN
+ RAISE EXCEPTION 'cannot extract a subgraph of "%" into itself',
from_graph;
+ END IF;
+
+ -- NULL predicate is treated as the '*' wildcard (keep all).
+ IF node_filter IS NULL THEN
+ node_filter := '*';
+ END IF;
+ IF relationship_filter IS NULL THEN
+ relationship_filter := '*';
+ END IF;
+
+ -- The predicates are embedded into a dollar-quoted cypher() query using
the
+ -- $age_subgraph$ tag; reject predicates that contain the tag to keep the
+ -- quoting unambiguous.
+ IF position('$age_subgraph$' IN node_filter) > 0
+ OR position('$age_subgraph$' IN relationship_filter) > 0 THEN
+ RAISE EXCEPTION 'filter predicate must not contain the reserved token
$age_subgraph$';
+ END IF;
+
+ -- Validate source graph exists.
+ SELECT graphid INTO from_oid
+ FROM ag_catalog.ag_graph WHERE name = from_graph;
+ IF from_oid IS NULL THEN
+ RAISE EXCEPTION 'graph "%" does not exist', from_graph;
+ END IF;
+
+ -- Validate destination graph does not exist (create_graph also enforces
+ -- naming rules and uniqueness, but we give a clear early error).
+ IF EXISTS (SELECT 1 FROM ag_catalog.ag_graph WHERE name = new_graph) THEN
+ RAISE EXCEPTION 'graph "%" already exists', new_graph;
+ END IF;
+
+ -- Create the destination graph (default labels are created automatically).
+ PERFORM ag_catalog.create_graph(new_graph);
+
+ SELECT graphid INTO new_oid
+ FROM ag_catalog.ag_graph WHERE name = new_graph;
+
+ -- Working sets / mapping (uniquely named to avoid colliding with user
temps).
+ DROP TABLE IF EXISTS _ag_sg_kept_v;
+ DROP TABLE IF EXISTS _ag_sg_kept_e;
+ DROP TABLE IF EXISTS _ag_sg_vmap;
+ DROP TABLE IF EXISTS _ag_sg_vstage;
+ DROP TABLE IF EXISTS _ag_sg_estage;
+
+ --
+ -- Kept vertices: evaluate node_filter with AGE's Cypher engine. The node
+ -- variable `n` is bound exactly as in the spec; '*' selects all vertices.
+ --
+ IF node_filter IS NULL OR btrim(node_filter) = '*' THEN
+ where_clause := '';
+ ELSE
+ where_clause := ' WHERE ' || node_filter;
+ END IF;
+ cypher_q := 'MATCH (n)' || where_clause || ' RETURN id(n)';
+
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_kept_v ON COMMIT DROP AS '
+ 'SELECT DISTINCT ag_catalog.agtype_to_graphid(vid) AS gid '
+ 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (vid
agtype)',
+ from_graph, cypher_q);
+ CREATE INDEX ON _ag_sg_kept_v (gid);
+
+ --
+ -- Kept edges: evaluate relationship_filter with AGE's Cypher engine. The
+ -- relationship variable `r` is bound exactly as in the spec.
+ --
+ IF relationship_filter IS NULL OR btrim(relationship_filter) = '*' THEN
+ where_clause := '';
+ ELSE
+ where_clause := ' WHERE ' || relationship_filter;
+ END IF;
+ cypher_q := 'MATCH ()-[r]->()' || where_clause || ' RETURN id(r)';
+
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_kept_e ON COMMIT DROP AS '
+ 'SELECT DISTINCT ag_catalog.agtype_to_graphid(eid) AS gid '
+ 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (eid
agtype)',
+ from_graph, cypher_q);
+ CREATE INDEX ON _ag_sg_kept_e (gid);
+
+ -- old -> new vertex id mapping (graphid is unique within a graph).
+ CREATE TEMP TABLE _ag_sg_vmap (old_id graphid PRIMARY KEY,
+ new_id graphid NOT NULL) ON COMMIT DROP;
+
+ --
+ -- PASS 1: copy kept vertices, label by label, assigning new graphids and
+ -- recording the old->new mapping for edge remapping.
+ --
+ FOR rec IN
+ SELECT name, id, relation, seq_name
+ FROM ag_catalog.ag_label
+ WHERE graph = from_oid AND kind = 'v'
+ ORDER BY id
+ LOOP
+ -- Skip labels with no surviving vertices. Read ONLY this label's own
+ -- rows: AGE label tables use table inheritance (custom labels inherit
+ -- from _ag_label_vertex), so a plain scan of a parent would also
return
+ -- its children and copy them twice.
+ EXECUTE format(
+ 'SELECT EXISTS (SELECT 1 FROM ONLY %s t '
+ 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id))',
+ rec.relation::regclass::text)
+ INTO has_rows;
+ IF NOT has_rows THEN
+ CONTINUE;
+ END IF;
+
+ -- Ensure the label exists in the destination graph.
+ IF rec.name <> '_ag_label_vertex' THEN
+ PERFORM 1 FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ IF NOT FOUND THEN
+ EXECUTE format('SELECT ag_catalog.create_vlabel(%L, %L)',
+ new_graph, rec.name);
+ END IF;
+ END IF;
+
+ SELECT id, seq_name, relation::regclass::text
+ INTO dst_label_id, dst_seq_fqn, dst_relation
+ FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn);
+
+ -- Stage surviving vertices with freshly generated ids in a real temp
+ -- table (single evaluation), then copy to the label table and record
+ -- the old->new mapping. A materialized stage avoids any ambiguity from
+ -- referencing a nextval-bearing CTE more than once.
+ DROP TABLE IF EXISTS _ag_sg_vstage;
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_vstage ON COMMIT DROP AS '
+ 'SELECT t.id AS old_id, '
+ ' ag_catalog._graphid(%s, nextval(%L)) AS new_id, '
+ ' t.properties AS props '
+ 'FROM ONLY %s t '
+ 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id)',
+ dst_label_id, dst_seq_fqn, rec.relation::regclass::text);
+
+ EXECUTE format('INSERT INTO %s (id, properties) '
+ 'SELECT new_id, props FROM _ag_sg_vstage',
dst_relation);
+
+ INSERT INTO _ag_sg_vmap (old_id, new_id)
+ SELECT old_id, new_id FROM _ag_sg_vstage;
+
+ DROP TABLE _ag_sg_vstage;
+ END LOOP;
+
+ SELECT count(*) INTO v_node_count FROM _ag_sg_vmap;
+
+ --
+ -- PASS 2: copy kept edges, remapping endpoints. The joins to _ag_sg_vmap
+ -- enforce the induced rule (an edge survives only if BOTH endpoints were
+ -- kept); membership in _ag_sg_kept_e applies relationship_filter.
+ --
+ FOR rec IN
+ SELECT name, id, relation, seq_name
+ FROM ag_catalog.ag_label
+ WHERE graph = from_oid AND kind = 'e'
+ ORDER BY id
+ LOOP
+ -- Skip labels with no surviving edges. Read ONLY this label's own rows
+ -- (see the vertex pass for why inheritance requires ONLY).
+ EXECUTE format(
+ 'SELECT EXISTS ('
+ ' SELECT 1 FROM ONLY %s x '
+ ' JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id '
+ ' JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id '
+ ' WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid =
x.id))',
+ rec.relation::regclass::text)
+ INTO has_rows;
+ IF NOT has_rows THEN
+ CONTINUE;
+ END IF;
+
+ IF rec.name <> '_ag_label_edge' THEN
+ PERFORM 1 FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ IF NOT FOUND THEN
+ EXECUTE format('SELECT ag_catalog.create_elabel(%L, %L)',
+ new_graph, rec.name);
+ END IF;
+ END IF;
+
+ SELECT id, seq_name, relation::regclass::text
+ INTO dst_label_id, dst_seq_fqn, dst_relation
+ FROM ag_catalog.ag_label
+ WHERE graph = new_oid AND name = rec.name;
+ dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn);
+
+ -- Stage surviving edges, remapping endpoints through _ag_sg_vmap. The
+ -- joins enforce the induced rule (both endpoints kept); membership in
+ -- _ag_sg_kept_e applies relationship_filter.
+ DROP TABLE IF EXISTS _ag_sg_estage;
+ EXECUTE format(
+ 'CREATE TEMP TABLE _ag_sg_estage ON COMMIT DROP AS '
+ 'SELECT ag_catalog._graphid(%s, nextval(%L)) AS new_id, '
+ ' vs.new_id AS new_start, ve.new_id AS new_end, '
+ ' x.properties AS props '
+ 'FROM ONLY %s x '
+ 'JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id '
+ 'JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id '
+ 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid = x.id)',
+ dst_label_id, dst_seq_fqn, rec.relation::regclass::text);
+
+ EXECUTE format('INSERT INTO %s (id, start_id, end_id, properties) '
+ 'SELECT new_id, new_start, new_end, props '
+ 'FROM _ag_sg_estage', dst_relation);
+ GET DIAGNOSTICS inserted = ROW_COUNT;
+ v_rel_count := v_rel_count + inserted;
+
+ DROP TABLE _ag_sg_estage;
+ END LOOP;
+
+ RETURN QUERY SELECT v_node_count, v_rel_count;
+END;
+$function$;
+
+COMMENT ON FUNCTION ag_catalog.create_subgraph(name, name, text, text) IS
+'Materializes a new persistent graph as the induced subgraph of from_graph
selected by a Cypher node predicate (on n) and relationship predicate (on r);
''*'' keeps all. An edge is kept only if its predicate holds and both endpoints
are kept. Returns (node_count, relationship_count).';
diff --git a/sql/sql_files b/sql/sql_files
index 32f9a709..996ad4b4 100644
--- a/sql/sql_files
+++ b/sql/sql_files
@@ -15,3 +15,4 @@ age_trig
age_aggregate
agtype_typecast
age_pg_upgrade
+age_subgraph