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 5abb02f8 Add shortest_path / all_shortest_paths SRFs (#2430)
5abb02f8 is described below
commit 5abb02f8033db15d43a7dabce367155978294806
Author: John Gemignani <[email protected]>
AuthorDate: Sun Jun 21 14:35:36 2026 -0700
Add shortest_path / all_shortest_paths SRFs (#2430)
Add two C set-returning functions that compute unweighted (hop-count)
shortest paths over the cached global graph adjacency via BFS, callable
both at the SQL top level and inside a cypher() RETURN:
- age_shortest_path(...) -> the single shortest path (0 or 1 rows)
- age_all_shortest_paths(...) -> every shortest path, one per row
The signature follows the natural Cypher argument order
(graph, start, end, edge_types, direction, min_hops, max_hops), registered
in sql/agtype_typecast.sql (install) and age--1.7.0--y.y.y.sql (upgrade).
Unimplemented parameters fail loudly: multiple relationship types and a
non-zero min_hops raise ERRCODE_FEATURE_NOT_SUPPORTED. A single edge type
(string or one-element array) is honored, and a NULL endpoint yields no
rows per Cypher null semantics (wrong-typed endpoints / NULL graph still
error).
To call the SRFs inside a cypher() RETURN, transform_cypher_return now sets
query->hasTargetSRFs (it was the only results-producing clause that didn't,
so the planner never added a ProjectSet node), and transform_FuncCall
auto-prepends the graph name for snake_case shortest_path /
all_shortest_paths. camelCase names are reserved for the future native
grammar.
Robustness:
- BFS guards against non-existent endpoints (returns 0 rows instead of
crashing) and honors CHECK_FOR_INTERRUPTS.
- An unknown edge label now matches no edges instead of silently
traversing all of them (get_label_relation returns InvalidOid).
Adds the age_shortest_path regression test (directed/undirected, label
filtering, parallel edges, self-loops, max_hops, the not-supported stubs,
NULL and non-existent endpoint/graph guards).
38/38 installcheck pass.
Co-authored-by: Copilot <[email protected]>
modified: Makefile
modified: age--1.7.0--y.y.y.sql
modified: sql/agtype_typecast.sql
modified: src/backend/parser/cypher_clause.c
modified: src/backend/parser/cypher_expr.c
modified: src/backend/utils/adt/age_vle.c
new file: regress/expected/age_shortest_path.out
new file: regress/sql/age_shortest_path.sql
---
Makefile | 1 +
age--1.7.0--y.y.y.sql | 31 ++
regress/expected/age_shortest_path.out | 977 +++++++++++++++++++++++++++++++++
regress/sql/age_shortest_path.sql | 630 +++++++++++++++++++++
sql/agtype_typecast.sql | 31 ++
src/backend/parser/cypher_clause.c | 1 +
src/backend/parser/cypher_expr.c | 13 +-
src/backend/utils/adt/age_vle.c | 796 +++++++++++++++++++++++++++
8 files changed, 2475 insertions(+), 5 deletions(-)
diff --git a/Makefile b/Makefile
index b2d93ff4..41208ee0 100644
--- a/Makefile
+++ b/Makefile
@@ -201,6 +201,7 @@ REGRESS = scan \
cypher_delete \
cypher_with \
cypher_vle \
+ age_shortest_path \
cypher_union \
cypher_call \
cypher_merge \
diff --git a/age--1.7.0--y.y.y.sql b/age--1.7.0--y.y.y.sql
index ad2ce20f..b40cde09 100644
--- a/age--1.7.0--y.y.y.sql
+++ b/age--1.7.0--y.y.y.sql
@@ -549,6 +549,37 @@ CALLED ON NULL INPUT
PARALLEL UNSAFE
AS 'MODULE_PATHNAME';
+-- Unweighted (hop-count) shortest path between two vertices, computed over the
+-- cached global graph adjacency via BFS. Returns a single path (0 or 1 rows).
+-- Argument order mirrors the Cypher shortestPath() pattern
+-- (a)-[:type*min_hops..max_hops]->(b):
+-- (graph_name, start, end, edge_types, direction, min_hops, max_hops)
+CREATE FUNCTION ag_catalog.age_shortest_path(IN agtype, IN agtype, IN agtype,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL)
+ RETURNS SETOF agtype
+LANGUAGE C
+STABLE
+CALLED ON NULL INPUT
+PARALLEL UNSAFE
+AS 'MODULE_PATHNAME';
+
+-- All unweighted shortest paths between two vertices (one path per row).
+-- Same argument order as age_shortest_path.
+CREATE FUNCTION ag_catalog.age_all_shortest_paths(IN agtype, IN agtype, IN
agtype,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL)
+ RETURNS SETOF agtype
+LANGUAGE C
+STABLE
+CALLED ON NULL INPUT
+PARALLEL UNSAFE
+AS 'MODULE_PATHNAME';
+
--
-- Composite types for vertex and edge
--
diff --git a/regress/expected/age_shortest_path.out
b/regress/expected/age_shortest_path.out
new file mode 100644
index 00000000..7eb751d1
--- /dev/null
+++ b/regress/expected/age_shortest_path.out
@@ -0,0 +1,977 @@
+/*
+ * 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;
+--
+-- age_shortest_path / age_all_shortest_paths
+--
+SELECT * FROM create_graph('sp_graph');
+NOTICE: graph "sp_graph" has been created
+ create_graph
+--------------
+
+(1 row)
+
+-- Build a small deterministic graph:
+--
+-- A
+-- / \
+-- B C (A->B, A->C, B->D, C->D : two shortest A..D paths)
+-- \ /
+-- D
+-- |
+-- E (D->E : unique 3-hop path A..E)
+--
+-- Z (isolated, unreachable)
+--
+SELECT * FROM cypher('sp_graph', $$
+ CREATE (a:Person {name: 'A'}),
+ (b:Person {name: 'B'}),
+ (c:Person {name: 'C'}),
+ (d:Person {name: 'D'}),
+ (e:Person {name: 'E'}),
+ (z:Person {name: 'Z'}),
+ (a)-[:KNOWS]->(b),
+ (a)-[:KNOWS]->(c),
+ (b)-[:KNOWS]->(d),
+ (c)-[:KNOWS]->(d),
+ (d)-[:KNOWS]->(e)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- materialize the global graph context
+SELECT * FROM cypher('sp_graph', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY
id(u) $$)
+ AS (result agtype);
+ result
+----------------------------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "Person", "in_degree": 0, "out_degree": 2,
"self_loops": 0}
+ {"id": 844424930131970, "label": "Person", "in_degree": 1, "out_degree": 1,
"self_loops": 0}
+ {"id": 844424930131971, "label": "Person", "in_degree": 1, "out_degree": 1,
"self_loops": 0}
+ {"id": 844424930131972, "label": "Person", "in_degree": 2, "out_degree": 1,
"self_loops": 0}
+ {"id": 844424930131973, "label": "Person", "in_degree": 1, "out_degree": 0,
"self_loops": 0}
+ {"id": 844424930131974, "label": "Person", "in_degree": 0, "out_degree": 0,
"self_loops": 0}
+(6 rows)
+
+-- A -> D shortest path (length 2); expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype))
+);
+ path_count
+------------
+ 1
+(1 row)
+
+-- all shortest A -> D; expected: 2 paths (A-B-D and A-C-D), each length 2
+SELECT path
+FROM age_all_shortest_paths(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype))
+) AS path
+ORDER BY path;
+
path
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "Person", "properties": {"name":
"A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id":
844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id":
844424930131970, "label": "Person", "properties": {"name": "B"}}::vertex,
{"id": 1125899906842627, "label": "KNOWS", "end_id": 844424930131972,
"start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131972,
"label": "Person", "properties": {"name": "D"}}::vertex]::path
+ [{"id": 844424930131969, "label": "Person", "properties": {"name":
"A"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id":
844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id":
844424930131971, "label": "Person", "properties": {"name": "C"}}::vertex,
{"id": 1125899906842628, "label": "KNOWS", "end_id": 844424930131972,
"start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131972,
"label": "Person", "properties": {"name": "D"}}::vertex]::path
+(2 rows)
+
+-- A -> E unique 3-hop path; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n)
$$) AS (id agtype))
+);
+ path_count
+------------
+ 1
+(1 row)
+
+-- A -> E with max_hops = 2; expected: path_count = 0 (E is 3 hops away)
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, NULL, NULL, 2::agtype
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- zero-length path, start == end; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype))
+);
+ path_count
+------------
+ 1
+(1 row)
+
+-- unreachable vertex Z; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'Z'}) RETURN id(n)
$$) AS (id agtype))
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- direction 'in': D -> A traversing edges backwards; expected: path_count = 2
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, '"in"'::agtype
+);
+ path_count
+------------
+ 2
+(1 row)
+
+-- direction 'out': D -> A not reachable forwards; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, '"out"'::agtype
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- label filter 'KNOWS': A -> D still found; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ '"KNOWS"'::agtype
+);
+ path_count
+------------
+ 1
+(1 row)
+
+-- error: invalid direction string; expected: ERROR (must be 'out', 'in', or
'any')
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, '"sideways"'::agtype
+);
+ERROR: direction argument must be one of 'out', 'in', or 'any'
+-- error: start argument is neither a vertex nor an integer id; expected: ERROR
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ '"not_a_vertex"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype))
+);
+ERROR: start vertex argument must be a vertex or the integer id
+--
+-- Non-existent endpoint guards. These must NOT crash the backend and must
+-- return no rows (a path can only exist between vertices in the graph).
+-- Previously, start == end on a non-existent vertex id was matched at BFS
+-- depth 0 and path reconstruction dereferenced a missing vertex, crashing
+-- the server.
+--
+-- start == end on a non-existent integer id; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path('"sp_graph"'::agtype, 999999::agtype, 999999::agtype);
+ path_count
+------------
+ 0
+(1 row)
+
+-- existing start -> non-existent end; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ 999999::agtype
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- non-existent start -> existing end; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ 999999::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype))
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- all-shortest-paths with start == end non-existent; expected: 0 rows
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths('"sp_graph"'::agtype, 999999::agtype,
999999::agtype);
+ path_count
+------------
+ 0
+(1 row)
+
+-- cleanup
+SELECT * FROM drop_graph('sp_graph', true);
+NOTICE: drop cascades to 4 other objects
+DETAIL: drop cascades to table sp_graph._ag_label_vertex
+drop cascades to table sp_graph._ag_label_edge
+drop cascades to table sp_graph."Person"
+drop cascades to table sp_graph."KNOWS"
+NOTICE: graph "sp_graph" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
+--
+-- Empty graph: a graph that exists but has no vertices must return no rows
+-- (and must not hang or crash) for any endpoint query.
+--
+SELECT * FROM create_graph('sp_empty');
+NOTICE: graph "sp_empty" has been created
+ create_graph
+--------------
+
+(1 row)
+
+SELECT count(*) AS path_count
+FROM age_shortest_path('"sp_empty"'::agtype, 0::agtype, 1::agtype);
+ path_count
+------------
+ 0
+(1 row)
+
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths('"sp_empty"'::agtype, 0::agtype, 0::agtype);
+ path_count
+------------
+ 0
+(1 row)
+
+SELECT * FROM drop_graph('sp_empty', true);
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table sp_empty._ag_label_vertex
+drop cascades to table sp_empty._ag_label_edge
+NOTICE: graph "sp_empty" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
+--
+-- A large, programmatically generated graph (120 nodes) exercising long
+-- shortest paths (length up to 20), high-multiplicity all-shortest-paths,
+-- label filtering, and directed vs. undirected reachability.
+--
+-- Nodes: (:N {id: 0..119}). Structures built on top of them:
+--
+-- * Main chain 0 -> 1 -> ... -> 20 (unique 20-hop path)
+-- * Alternate chain 0 -> 50 -> 51 -> ... -> 68 -> 20
+-- (a second, disjoint 20-hop path 0..20)
+-- => all-shortest-paths 0..20 under KNOWS = 2 paths of length 20
+-- * 3x3 lattice on ids 70..78, id = 70 + 3*row + col, edges go right
+-- (id->id+1) and down (id->id+3). Monotone 70..78 paths:
+-- => all-shortest-paths 70..78 = C(4,2) = 6 paths of length 4
+-- * LIKES shortcut 0 -[:LIKES]-> 20 (1 hop; only visible when the edge
+-- label filter is NOT restricted to KNOWS)
+-- * Back-edge triangle 0 -> 96 -> 95 -> 0
+-- => directed 0->95 = 2 hops (0-96-95); undirected 0..95 = 1 hop
+-- * Many unused ids (e.g. 119) remain isolated / unreachable.
+--
+SELECT * FROM create_graph('sp_big');
+NOTICE: graph "sp_big" has been created
+ create_graph
+--------------
+
+(1 row)
+
+-- 120 vertices, ids 0..119
+SELECT * FROM cypher('sp_big', $$
+ UNWIND range(0, 119) AS i CREATE (:N {id: i})
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- main chain 0->1->...->20 (KNOWS)
+SELECT * FROM cypher('sp_big', $$
+ UNWIND range(0, 19) AS i
+ MATCH (a:N {id: i}), (b:N {id: i + 1})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- alternate, disjoint 20-hop path 0->50->51->...->68->20 (KNOWS)
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 0}), (b:N {id: 50}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+SELECT * FROM cypher('sp_big', $$
+ UNWIND range(50, 67) AS i
+ MATCH (a:N {id: i}), (b:N {id: i + 1})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 68}), (b:N {id: 20}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- 3x3 lattice on ids 70..78: right edges (id -> id+1)
+SELECT * FROM cypher('sp_big', $$
+ UNWIND [0, 1, 2] AS r
+ UNWIND [0, 1] AS c
+ MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * r + c + 1})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- 3x3 lattice: down edges (id -> id+3)
+SELECT * FROM cypher('sp_big', $$
+ UNWIND [0, 1] AS r
+ UNWIND [0, 1, 2] AS c
+ MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * (r + 1) + c})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- back-edge triangle 0 -> 96 -> 95 -> 0 (KNOWS)
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 0}), (b:N {id: 96}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 96}), (b:N {id: 95}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 95}), (b:N {id: 0}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- labelled shortcut 0 -[:LIKES]-> 20
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 0}), (b:N {id: 20}) CREATE (a)-[:LIKES]->(b)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- sanity: vertex count (also materializes the global context); expected:
count = 120
+SELECT * FROM cypher('sp_big', $$ MATCH (n) RETURN count(n) $$) AS (n agtype);
+ n
+-----
+ 120
+(1 row)
+
+-- all shortest 0 -> 20 under KNOWS (main chain + disjoint alternate);
+-- expected: 2 paths, each exactly 20 hops
+SELECT path
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype
+) AS path
+ORDER BY path;
+
[...]
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
[...]
+ [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex,
{"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970,
"label": "N", "properties": {"id": 1}}::vertex, {"id": 1125899906842626,
"label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970,
"properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties":
{"id": 2}}::vertex, {"id": 1125899906842627, " [...]
+ [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex,
{"id": 1125899906842645, "label": "KNOWS", "end_id": 844424930132019,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930132019,
"label": "N", "properties": {"id": 50}}::vertex, {"id": 1125899906842646,
"label": "KNOWS", "end_id": 844424930132020, "start_id": 844424930132019,
"properties": {}}::edge, {"id": 844424930132020, "label": "N", "properties":
{"id": 51}}::vertex, {"id": 1125899906842647, [...]
+(2 rows)
+
+-- any label: the LIKES shortcut collapses 0 -> 20; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ NULL, '"out"'::agtype
+);
+ path_count
+------------
+ 1
+(1 row)
+
+-- all shortest 70 -> 78 across the 3x3 lattice; expected: path_count = 6
(C(4,2))
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype
+);
+ path_count
+------------
+ 6
+(1 row)
+
+-- the lattice paths listed; expected: 6 paths, each 4 hops
+SELECT path
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype
+) AS path
+ORDER BY path;
+
path
[...]
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
[...]
+ [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex,
{"id": 1125899906842665, "label": "KNOWS", "end_id": 844424930132040,
"start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132040,
"label": "N", "properties": {"id": 71}}::vertex, {"id": 1125899906842666,
"label": "KNOWS", "end_id": 844424930132041, "start_id": 844424930132040,
"properties": {}}::edge, {"id": 844424930132041, "label": "N", "properties":
{"id": 72}}::vertex, {"id": 1125899906842673 [...]
+ [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex,
{"id": 1125899906842665, "label": "KNOWS", "end_id": 844424930132040,
"start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132040,
"label": "N", "properties": {"id": 71}}::vertex, {"id": 1125899906842672,
"label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132040,
"properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties":
{"id": 74}}::vertex, {"id": 1125899906842668 [...]
+ [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex,
{"id": 1125899906842665, "label": "KNOWS", "end_id": 844424930132040,
"start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132040,
"label": "N", "properties": {"id": 71}}::vertex, {"id": 1125899906842672,
"label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132040,
"properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties":
{"id": 74}}::vertex, {"id": 1125899906842675 [...]
+ [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex,
{"id": 1125899906842671, "label": "KNOWS", "end_id": 844424930132042,
"start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132042,
"label": "N", "properties": {"id": 73}}::vertex, {"id": 1125899906842667,
"label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132042,
"properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties":
{"id": 74}}::vertex, {"id": 1125899906842668 [...]
+ [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex,
{"id": 1125899906842671, "label": "KNOWS", "end_id": 844424930132042,
"start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132042,
"label": "N", "properties": {"id": 73}}::vertex, {"id": 1125899906842667,
"label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132042,
"properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties":
{"id": 74}}::vertex, {"id": 1125899906842675 [...]
+ [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex,
{"id": 1125899906842671, "label": "KNOWS", "end_id": 844424930132042,
"start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132042,
"label": "N", "properties": {"id": 73}}::vertex, {"id": 1125899906842674,
"label": "KNOWS", "end_id": 844424930132045, "start_id": 844424930132042,
"properties": {}}::edge, {"id": 844424930132045, "label": "N", "properties":
{"id": 76}}::vertex, {"id": 1125899906842669 [...]
+(6 rows)
+
+-- max_hops = 19, one short of the 20-hop route; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, NULL, 19::agtype
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- max_hops = 20 admits the full route; expected: path_count = 2
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, NULL, 20::agtype
+);
+ path_count
+------------
+ 2
+(1 row)
+
+-- DIRECTED out: 0 -> 95 must traverse 0->96->95; expected: 1 path (length 2)
+SELECT path
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$)
AS (id agtype)),
+ NULL, '"out"'::agtype
+) AS path
+ORDER BY path;
+
path
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex,
{"id": 1125899906842677, "label": "KNOWS", "end_id": 844424930132065,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930132065,
"label": "N", "properties": {"id": 96}}::vertex, {"id": 1125899906842678,
"label": "KNOWS", "end_id": 844424930132064, "start_id": 844424930132065,
"properties": {}}::edge, {"id": 844424930132064, "label": "N", "properties":
{"id": 95}}::vertex]::path
+(1 row)
+
+-- UNDIRECTED: 0 .. 95 via the 95->0 back edge; expected: 1 path (length 1)
+SELECT path
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$)
AS (id agtype))
+) AS path
+ORDER BY path;
+
path
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex,
{"id": 1125899906842679, "label": "KNOWS", "end_id": 844424930131969,
"start_id": 844424930132064, "properties": {}}::edge, {"id": 844424930132064,
"label": "N", "properties": {"id": 95}}::vertex]::path
+(1 row)
+
+-- DIRECTED out: 78 -> 70 against lattice flow; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype)),
+ NULL, '"out"'::agtype
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- UNDIRECTED: 78 .. 70 reverses the lattice; expected: path_count = 6
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype))
+);
+ path_count
+------------
+ 6
+(1 row)
+
+-- isolated id 119 unreachable from 0; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 119}) RETURN id(n) $$)
AS (id agtype))
+);
+ path_count
+------------
+ 0
+(1 row)
+
+-- zero-length path, start == end; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype))
+);
+ path_count
+------------
+ 1
+(1 row)
+
+-- cleanup
+SELECT * FROM drop_graph('sp_big', true);
+NOTICE: drop cascades to 5 other objects
+DETAIL: drop cascades to table sp_big._ag_label_vertex
+drop cascades to table sp_big._ag_label_edge
+drop cascades to table sp_big."N"
+drop cascades to table sp_big."KNOWS"
+drop cascades to table sp_big."LIKES"
+NOTICE: graph "sp_big" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
+--
+-- Calling the age_* SRFs from inside cypher() (Tier 1).
+--
+-- Because the functions are prefixed with age_, the cypher() parser resolves
+-- the unqualified names 'shortest_path' and 'all_shortest_paths' to
+-- ag_catalog.age_shortest_path / ag_catalog.age_all_shortest_paths, and the
+-- graph name is auto-injected as the first argument (like vle/vertex_stats),
+-- so callers pass only the bound endpoints. A whole vertex implicitly casts to
+-- agtype, so the argument types resolve. The SRFs are set-returning and now
+-- work in a cypher RETURN projection (ProjectSet), returning one row per path.
+--
+SELECT * FROM create_graph('sp_cy');
+NOTICE: graph "sp_cy" has been created
+ create_graph
+--------------
+
+(1 row)
+
+SELECT * FROM cypher('sp_cy', $$
+ CREATE (a:N {name: 'A'}),
+ (b:N {name: 'B'}),
+ (c:N {name: 'C'}),
+ (a)-[:KNOWS]->(b),
+ (b)-[:KNOWS]->(c)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- materialize the global graph context
+SELECT * FROM cypher('sp_cy', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY
id(u) $$)
+ AS (result agtype);
+ result
+-----------------------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 1,
"self_loops": 0}
+ {"id": 844424930131970, "label": "N", "in_degree": 1, "out_degree": 1,
"self_loops": 0}
+ {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 0,
"self_loops": 0}
+(3 rows)
+
+-- shortest_path() inside a cypher RETURN; the graph name is auto-injected and
+-- the bound vertices are passed; expected: 1 path A..C (length 2)
+SELECT * FROM cypher('sp_cy', $$
+ MATCH (a:N {name:'A'}), (c:N {name:'C'})
+ RETURN shortest_path(a, c)
+$$) AS (path agtype);
+
path
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex,
{"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970,
"label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626,
"label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970,
"properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties":
{"name": "C"}}::vertex]::path
+(1 row)
+
+-- all_shortest_paths() inside a cypher RETURN; expected: 1 path A..C (length
2)
+SELECT * FROM cypher('sp_cy', $$
+ MATCH (a:N {name:'A'}), (c:N {name:'C'})
+ RETURN all_shortest_paths(a, c)
+$$) AS (path agtype);
+
path
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex,
{"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970,
"label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626,
"label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970,
"properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties":
{"name": "C"}}::vertex]::path
+(1 row)
+
+-- in-cypher with an explicit edge-label filter; expected: 1 path A..C (length
2)
+SELECT * FROM cypher('sp_cy', $$
+ MATCH (a:N {name:'A'}), (c:N {name:'C'})
+ RETURN shortest_path(a, c, 'KNOWS')
+$$) AS (path agtype);
+
path
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex,
{"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970,
"label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626,
"label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970,
"properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties":
{"name": "C"}}::vertex]::path
+(1 row)
+
+-- still supported: call the SRF at the top level; expected: 1 path A..C
(length 2)
+SELECT path
+FROM age_shortest_path(
+ '"sp_cy"'::agtype,
+ (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype))
+) AS path
+ORDER BY path;
+
path
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex,
{"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970,
"start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970,
"label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626,
"label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970,
"properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties":
{"name": "C"}}::vertex]::path
+(1 row)
+
+-- cleanup
+SELECT * FROM drop_graph('sp_cy', true);
+NOTICE: drop cascades to 4 other objects
+DETAIL: drop cascades to table sp_cy._ag_label_vertex
+drop cascades to table sp_cy._ag_label_edge
+drop cascades to table sp_cy."N"
+drop cascades to table sp_cy."KNOWS"
+NOTICE: graph "sp_cy" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
+--
+-- Edge cases: parallel/multi-edges, self-loops, unknown edge labels,
+-- max_hops boundaries (0 and negative), explicit 'any' direction, and
+-- NULL / unknown-graph argument errors.
+--
+SELECT * FROM create_graph('sp_edge');
+NOTICE: graph "sp_edge" has been created
+ create_graph
+--------------
+
+(1 row)
+
+-- A and B are connected by TWO parallel KNOWS edges plus one LIKES edge.
+-- B->C is a single KNOWS edge. S has a self-loop. These exercise the
+-- multi-predecessor (parallel edge) logic and the label filter.
+SELECT * FROM cypher('sp_edge', $$
+ CREATE (a:N {name: 'A'}),
+ (b:N {name: 'B'}),
+ (c:N {name: 'C'}),
+ (s:N {name: 'S'}),
+ (a)-[:KNOWS]->(b),
+ (a)-[:KNOWS]->(b),
+ (a)-[:LIKES]->(b),
+ (b)-[:KNOWS]->(c),
+ (s)-[:KNOWS]->(s)
+$$) AS (result agtype);
+ result
+--------
+(0 rows)
+
+-- materialize the global graph context
+SELECT * FROM cypher('sp_edge', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY
id(u) $$)
+ AS (result agtype);
+ result
+-----------------------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 3,
"self_loops": 0}
+ {"id": 844424930131970, "label": "N", "in_degree": 3, "out_degree": 1,
"self_loops": 0}
+ {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 0,
"self_loops": 0}
+ {"id": 844424930131972, "label": "N", "in_degree": 1, "out_degree": 1,
"self_loops": 1}
+(4 rows)
+
+-- parallel edges: two distinct KNOWS edges A->B are two distinct shortest
+-- paths; expected count 2
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype);
+ count
+-------
+ 2
+(1 row)
+
+-- no label filter: 2 KNOWS + 1 LIKES edge A->B are three distinct shortest
+-- paths; expected count 3
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, '"out"'::agtype);
+ count
+-------
+ 3
+(1 row)
+
+-- single shortest path A->B picks exactly one of the parallel edges; count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)));
+ count
+-------
+ 1
+(1 row)
+
+-- self-loop: a vertex with an edge to itself yields only the zero-length
+-- path for start == end (the self-loop is never used); count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)));
+ count
+-------
+ 1
+(1 row)
+
+-- all_shortest_paths with start == end (existing vertex): one zero-length
+-- path; count 1
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)));
+ count
+-------
+ 1
+(1 row)
+
+-- unknown relationship type matches no edges: A..C filtered by a label that
+-- does not exist must return no path (NOT silently fall back to all edges);
+-- count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"NOSUCHLABEL"'::agtype, '"out"'::agtype);
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"NOSUCHLABEL"'::agtype, '"out"'::agtype);
+ count
+-------
+ 0
+(1 row)
+
+-- the zero-length (start == end) path has no edges, so an unknown label
+-- still matches it; count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ '"NOSUCHLABEL"'::agtype, '"out"'::agtype);
+ count
+-------
+ 1
+(1 row)
+
+-- existing label that does not connect the endpoints: LIKES only exists on
+-- A->B, so A..C filtered by LIKES is unreachable; count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"LIKES"'::agtype, '"out"'::agtype);
+ count
+-------
+ 0
+(1 row)
+
+-- max_hops = 0 with start == end: the zero-length path is still returned;
+-- count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype);
+ count
+-------
+ 1
+(1 row)
+
+-- max_hops = 0 with adjacent distinct endpoints: no path within zero hops;
+-- count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype);
+ count
+-------
+ 0
+(1 row)
+
+-- negative max_hops is treated as unbounded: A..C (length 2) is found;
+-- count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, NULL::agtype, NULL::agtype, '-1'::agtype);
+ count
+-------
+ 1
+(1 row)
+
+-- explicit 'any' direction string (vs the default NULL == undirected);
+-- two parallel KNOWS edges A->B give two shortest paths; count 2
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"any"'::agtype);
+ count
+-------
+ 2
+(1 row)
+
+-- NULL start (or end) vertex yields no rows (Cypher null semantics: a null
+-- endpoint simply produces no match, it is not an error); count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ NULL::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)));
+ count
+-------
+ 0
+(1 row)
+
+-- NULL end vertex likewise yields no rows; count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype);
+ count
+-------
+ 0
+(1 row)
+
+-- all_shortest_paths with a NULL endpoint also yields no rows; count 0
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype);
+ count
+-------
+ 0
+(1 row)
+
+-- a single relationship type may be passed as a one-element array; expected:
+-- same as the bare-string form, A..C under KNOWS (length 2); count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '["KNOWS"]'::agtype, '"out"'::agtype);
+ count
+-------
+ 1
+(1 row)
+
+-- multiple relationship types are not yet supported; expected: ERROR
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype);
+ERROR: age_shortest_path: multiple relationship types are not yet supported
+-- a non-zero minimum hop count is not yet supported; expected: ERROR
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype);
+ERROR: age_shortest_path: a minimum hop count is not yet supported
+-- a minimum hop count of 0 is the default and is accepted; A..C (length 2);
+-- count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, 0::agtype);
+ count
+-------
+ 1
+(1 row)
+
+-- a graph name that does not exist is an error
+SELECT count(*) FROM age_shortest_path('"no_such_graph"'::agtype, '1'::agtype,
'2'::agtype);
+ERROR: schema "no_such_graph" does not exist
+-- cleanup
+SELECT * FROM drop_graph('sp_edge', true);
+NOTICE: drop cascades to 5 other objects
+DETAIL: drop cascades to table sp_edge._ag_label_vertex
+drop cascades to table sp_edge._ag_label_edge
+drop cascades to table sp_edge."N"
+drop cascades to table sp_edge."KNOWS"
+drop cascades to table sp_edge."LIKES"
+NOTICE: graph "sp_edge" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
diff --git a/regress/sql/age_shortest_path.sql
b/regress/sql/age_shortest_path.sql
new file mode 100644
index 00000000..82b4d66b
--- /dev/null
+++ b/regress/sql/age_shortest_path.sql
@@ -0,0 +1,630 @@
+/*
+ * 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;
+
+--
+-- age_shortest_path / age_all_shortest_paths
+--
+
+SELECT * FROM create_graph('sp_graph');
+
+-- Build a small deterministic graph:
+--
+-- A
+-- / \
+-- B C (A->B, A->C, B->D, C->D : two shortest A..D paths)
+-- \ /
+-- D
+-- |
+-- E (D->E : unique 3-hop path A..E)
+--
+-- Z (isolated, unreachable)
+--
+SELECT * FROM cypher('sp_graph', $$
+ CREATE (a:Person {name: 'A'}),
+ (b:Person {name: 'B'}),
+ (c:Person {name: 'C'}),
+ (d:Person {name: 'D'}),
+ (e:Person {name: 'E'}),
+ (z:Person {name: 'Z'}),
+ (a)-[:KNOWS]->(b),
+ (a)-[:KNOWS]->(c),
+ (b)-[:KNOWS]->(d),
+ (c)-[:KNOWS]->(d),
+ (d)-[:KNOWS]->(e)
+$$) AS (result agtype);
+
+-- materialize the global graph context
+SELECT * FROM cypher('sp_graph', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY
id(u) $$)
+ AS (result agtype);
+
+-- A -> D shortest path (length 2); expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype))
+);
+
+-- all shortest A -> D; expected: 2 paths (A-B-D and A-C-D), each length 2
+SELECT path
+FROM age_all_shortest_paths(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype))
+) AS path
+ORDER BY path;
+
+-- A -> E unique 3-hop path; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n)
$$) AS (id agtype))
+);
+
+-- A -> E with max_hops = 2; expected: path_count = 0 (E is 3 hops away)
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, NULL, NULL, 2::agtype
+);
+
+-- zero-length path, start == end; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype))
+);
+
+-- unreachable vertex Z; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'Z'}) RETURN id(n)
$$) AS (id agtype))
+);
+
+-- direction 'in': D -> A traversing edges backwards; expected: path_count = 2
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, '"in"'::agtype
+);
+
+-- direction 'out': D -> A not reachable forwards; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, '"out"'::agtype
+);
+
+-- label filter 'KNOWS': A -> D still found; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ '"KNOWS"'::agtype
+);
+
+-- error: invalid direction string; expected: ERROR (must be 'out', 'in', or
'any')
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype)),
+ NULL, '"sideways"'::agtype
+);
+
+-- error: start argument is neither a vertex nor an integer id; expected: ERROR
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ '"not_a_vertex"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n)
$$) AS (id agtype))
+);
+
+--
+-- Non-existent endpoint guards. These must NOT crash the backend and must
+-- return no rows (a path can only exist between vertices in the graph).
+-- Previously, start == end on a non-existent vertex id was matched at BFS
+-- depth 0 and path reconstruction dereferenced a missing vertex, crashing
+-- the server.
+--
+
+-- start == end on a non-existent integer id; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path('"sp_graph"'::agtype, 999999::agtype, 999999::agtype);
+
+-- existing start -> non-existent end; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype)),
+ 999999::agtype
+);
+
+-- non-existent start -> existing end; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_graph"'::agtype,
+ 999999::agtype,
+ (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n)
$$) AS (id agtype))
+);
+
+-- all-shortest-paths with start == end non-existent; expected: 0 rows
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths('"sp_graph"'::agtype, 999999::agtype,
999999::agtype);
+
+-- cleanup
+SELECT * FROM drop_graph('sp_graph', true);
+
+
+--
+-- Empty graph: a graph that exists but has no vertices must return no rows
+-- (and must not hang or crash) for any endpoint query.
+--
+SELECT * FROM create_graph('sp_empty');
+SELECT count(*) AS path_count
+FROM age_shortest_path('"sp_empty"'::agtype, 0::agtype, 1::agtype);
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths('"sp_empty"'::agtype, 0::agtype, 0::agtype);
+SELECT * FROM drop_graph('sp_empty', true);
+
+
+
+--
+-- A large, programmatically generated graph (120 nodes) exercising long
+-- shortest paths (length up to 20), high-multiplicity all-shortest-paths,
+-- label filtering, and directed vs. undirected reachability.
+--
+-- Nodes: (:N {id: 0..119}). Structures built on top of them:
+--
+-- * Main chain 0 -> 1 -> ... -> 20 (unique 20-hop path)
+-- * Alternate chain 0 -> 50 -> 51 -> ... -> 68 -> 20
+-- (a second, disjoint 20-hop path 0..20)
+-- => all-shortest-paths 0..20 under KNOWS = 2 paths of length 20
+-- * 3x3 lattice on ids 70..78, id = 70 + 3*row + col, edges go right
+-- (id->id+1) and down (id->id+3). Monotone 70..78 paths:
+-- => all-shortest-paths 70..78 = C(4,2) = 6 paths of length 4
+-- * LIKES shortcut 0 -[:LIKES]-> 20 (1 hop; only visible when the edge
+-- label filter is NOT restricted to KNOWS)
+-- * Back-edge triangle 0 -> 96 -> 95 -> 0
+-- => directed 0->95 = 2 hops (0-96-95); undirected 0..95 = 1 hop
+-- * Many unused ids (e.g. 119) remain isolated / unreachable.
+--
+SELECT * FROM create_graph('sp_big');
+
+-- 120 vertices, ids 0..119
+SELECT * FROM cypher('sp_big', $$
+ UNWIND range(0, 119) AS i CREATE (:N {id: i})
+$$) AS (result agtype);
+
+-- main chain 0->1->...->20 (KNOWS)
+SELECT * FROM cypher('sp_big', $$
+ UNWIND range(0, 19) AS i
+ MATCH (a:N {id: i}), (b:N {id: i + 1})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+
+-- alternate, disjoint 20-hop path 0->50->51->...->68->20 (KNOWS)
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 0}), (b:N {id: 50}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+SELECT * FROM cypher('sp_big', $$
+ UNWIND range(50, 67) AS i
+ MATCH (a:N {id: i}), (b:N {id: i + 1})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 68}), (b:N {id: 20}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+
+-- 3x3 lattice on ids 70..78: right edges (id -> id+1)
+SELECT * FROM cypher('sp_big', $$
+ UNWIND [0, 1, 2] AS r
+ UNWIND [0, 1] AS c
+ MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * r + c + 1})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+
+-- 3x3 lattice: down edges (id -> id+3)
+SELECT * FROM cypher('sp_big', $$
+ UNWIND [0, 1] AS r
+ UNWIND [0, 1, 2] AS c
+ MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * (r + 1) + c})
+ CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+
+-- back-edge triangle 0 -> 96 -> 95 -> 0 (KNOWS)
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 0}), (b:N {id: 96}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 96}), (b:N {id: 95}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 95}), (b:N {id: 0}) CREATE (a)-[:KNOWS]->(b)
+$$) AS (result agtype);
+
+-- labelled shortcut 0 -[:LIKES]-> 20
+SELECT * FROM cypher('sp_big', $$
+ MATCH (a:N {id: 0}), (b:N {id: 20}) CREATE (a)-[:LIKES]->(b)
+$$) AS (result agtype);
+
+-- sanity: vertex count (also materializes the global context); expected:
count = 120
+SELECT * FROM cypher('sp_big', $$ MATCH (n) RETURN count(n) $$) AS (n agtype);
+
+-- all shortest 0 -> 20 under KNOWS (main chain + disjoint alternate);
+-- expected: 2 paths, each exactly 20 hops
+SELECT path
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype
+) AS path
+ORDER BY path;
+
+-- any label: the LIKES shortcut collapses 0 -> 20; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ NULL, '"out"'::agtype
+);
+
+-- all shortest 70 -> 78 across the 3x3 lattice; expected: path_count = 6
(C(4,2))
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype
+);
+
+-- the lattice paths listed; expected: 6 paths, each 4 hops
+SELECT path
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype
+) AS path
+ORDER BY path;
+
+-- max_hops = 19, one short of the 20-hop route; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, NULL, 19::agtype
+);
+
+-- max_hops = 20 admits the full route; expected: path_count = 2
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, NULL, 20::agtype
+);
+
+-- DIRECTED out: 0 -> 95 must traverse 0->96->95; expected: 1 path (length 2)
+SELECT path
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$)
AS (id agtype)),
+ NULL, '"out"'::agtype
+) AS path
+ORDER BY path;
+
+-- UNDIRECTED: 0 .. 95 via the 95->0 back edge; expected: 1 path (length 1)
+SELECT path
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$)
AS (id agtype))
+) AS path
+ORDER BY path;
+
+-- DIRECTED out: 78 -> 70 against lattice flow; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype)),
+ NULL, '"out"'::agtype
+);
+
+-- UNDIRECTED: 78 .. 70 reverses the lattice; expected: path_count = 6
+SELECT count(*) AS path_count
+FROM age_all_shortest_paths(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$)
AS (id agtype))
+);
+
+-- isolated id 119 unreachable from 0; expected: path_count = 0
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 119}) RETURN id(n) $$)
AS (id agtype))
+);
+
+-- zero-length path, start == end; expected: path_count = 1
+SELECT count(*) AS path_count
+FROM age_shortest_path(
+ '"sp_big"'::agtype,
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$)
AS (id agtype))
+);
+
+-- cleanup
+SELECT * FROM drop_graph('sp_big', true);
+
+--
+-- Calling the age_* SRFs from inside cypher() (Tier 1).
+--
+-- Because the functions are prefixed with age_, the cypher() parser resolves
+-- the unqualified names 'shortest_path' and 'all_shortest_paths' to
+-- ag_catalog.age_shortest_path / ag_catalog.age_all_shortest_paths, and the
+-- graph name is auto-injected as the first argument (like vle/vertex_stats),
+-- so callers pass only the bound endpoints. A whole vertex implicitly casts to
+-- agtype, so the argument types resolve. The SRFs are set-returning and now
+-- work in a cypher RETURN projection (ProjectSet), returning one row per path.
+--
+SELECT * FROM create_graph('sp_cy');
+
+SELECT * FROM cypher('sp_cy', $$
+ CREATE (a:N {name: 'A'}),
+ (b:N {name: 'B'}),
+ (c:N {name: 'C'}),
+ (a)-[:KNOWS]->(b),
+ (b)-[:KNOWS]->(c)
+$$) AS (result agtype);
+
+-- materialize the global graph context
+SELECT * FROM cypher('sp_cy', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY
id(u) $$)
+ AS (result agtype);
+
+-- shortest_path() inside a cypher RETURN; the graph name is auto-injected and
+-- the bound vertices are passed; expected: 1 path A..C (length 2)
+SELECT * FROM cypher('sp_cy', $$
+ MATCH (a:N {name:'A'}), (c:N {name:'C'})
+ RETURN shortest_path(a, c)
+$$) AS (path agtype);
+
+-- all_shortest_paths() inside a cypher RETURN; expected: 1 path A..C (length
2)
+SELECT * FROM cypher('sp_cy', $$
+ MATCH (a:N {name:'A'}), (c:N {name:'C'})
+ RETURN all_shortest_paths(a, c)
+$$) AS (path agtype);
+
+-- in-cypher with an explicit edge-label filter; expected: 1 path A..C (length
2)
+SELECT * FROM cypher('sp_cy', $$
+ MATCH (a:N {name:'A'}), (c:N {name:'C'})
+ RETURN shortest_path(a, c, 'KNOWS')
+$$) AS (path agtype);
+
+-- still supported: call the SRF at the top level; expected: 1 path A..C
(length 2)
+SELECT path
+FROM age_shortest_path(
+ '"sp_cy"'::agtype,
+ (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype))
+) AS path
+ORDER BY path;
+
+-- cleanup
+SELECT * FROM drop_graph('sp_cy', true);
+
+--
+-- Edge cases: parallel/multi-edges, self-loops, unknown edge labels,
+-- max_hops boundaries (0 and negative), explicit 'any' direction, and
+-- NULL / unknown-graph argument errors.
+--
+SELECT * FROM create_graph('sp_edge');
+
+-- A and B are connected by TWO parallel KNOWS edges plus one LIKES edge.
+-- B->C is a single KNOWS edge. S has a self-loop. These exercise the
+-- multi-predecessor (parallel edge) logic and the label filter.
+SELECT * FROM cypher('sp_edge', $$
+ CREATE (a:N {name: 'A'}),
+ (b:N {name: 'B'}),
+ (c:N {name: 'C'}),
+ (s:N {name: 'S'}),
+ (a)-[:KNOWS]->(b),
+ (a)-[:KNOWS]->(b),
+ (a)-[:LIKES]->(b),
+ (b)-[:KNOWS]->(c),
+ (s)-[:KNOWS]->(s)
+$$) AS (result agtype);
+
+-- materialize the global graph context
+SELECT * FROM cypher('sp_edge', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY
id(u) $$)
+ AS (result agtype);
+
+-- parallel edges: two distinct KNOWS edges A->B are two distinct shortest
+-- paths; expected count 2
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype);
+
+-- no label filter: 2 KNOWS + 1 LIKES edge A->B are three distinct shortest
+-- paths; expected count 3
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, '"out"'::agtype);
+
+-- single shortest path A->B picks exactly one of the parallel edges; count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)));
+
+-- self-loop: a vertex with an edge to itself yields only the zero-length
+-- path for start == end (the self-loop is never used); count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)));
+
+-- all_shortest_paths with start == end (existing vertex): one zero-length
+-- path; count 1
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)));
+
+-- unknown relationship type matches no edges: A..C filtered by a label that
+-- does not exist must return no path (NOT silently fall back to all edges);
+-- count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"NOSUCHLABEL"'::agtype, '"out"'::agtype);
+
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"NOSUCHLABEL"'::agtype, '"out"'::agtype);
+
+-- the zero-length (start == end) path has no edges, so an unknown label
+-- still matches it; count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$)
AS (id agtype)),
+ '"NOSUCHLABEL"'::agtype, '"out"'::agtype);
+
+-- existing label that does not connect the endpoints: LIKES only exists on
+-- A->B, so A..C filtered by LIKES is unreachable; count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"LIKES"'::agtype, '"out"'::agtype);
+
+-- max_hops = 0 with start == end: the zero-length path is still returned;
+-- count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype);
+
+-- max_hops = 0 with adjacent distinct endpoints: no path within zero hops;
+-- count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype);
+
+-- negative max_hops is treated as unbounded: A..C (length 2) is found;
+-- count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype, NULL::agtype, NULL::agtype, '-1'::agtype);
+
+-- explicit 'any' direction string (vs the default NULL == undirected);
+-- two parallel KNOWS edges A->B give two shortest paths; count 2
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"any"'::agtype);
+
+-- NULL start (or end) vertex yields no rows (Cypher null semantics: a null
+-- endpoint simply produces no match, it is not an error); count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ NULL::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$)
AS (id agtype)));
+
+-- NULL end vertex likewise yields no rows; count 0
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype);
+
+-- all_shortest_paths with a NULL endpoint also yields no rows; count 0
+SELECT count(*) FROM age_all_shortest_paths(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ NULL::agtype);
+
+-- a single relationship type may be passed as a one-element array; expected:
+-- same as the bare-string form, A..C under KNOWS (length 2); count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '["KNOWS"]'::agtype, '"out"'::agtype);
+
+-- multiple relationship types are not yet supported; expected: ERROR
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype);
+
+-- a non-zero minimum hop count is not yet supported; expected: ERROR
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype);
+
+-- a minimum hop count of 0 is the default and is accepted; A..C (length 2);
+-- count 1
+SELECT count(*) FROM age_shortest_path(
+ '"sp_edge"'::agtype,
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$)
AS (id agtype)),
+ (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$)
AS (id agtype)),
+ '"KNOWS"'::agtype, '"out"'::agtype, 0::agtype);
+
+-- a graph name that does not exist is an error
+SELECT count(*) FROM age_shortest_path('"no_such_graph"'::agtype, '1'::agtype,
'2'::agtype);
+
+-- cleanup
+SELECT * FROM drop_graph('sp_edge', true);
diff --git a/sql/agtype_typecast.sql b/sql/agtype_typecast.sql
index abca5e51..f12f215f 100644
--- a/sql/agtype_typecast.sql
+++ b/sql/agtype_typecast.sql
@@ -98,6 +98,37 @@ CALLED ON NULL INPUT
PARALLEL UNSAFE -- might be safe
AS 'MODULE_PATHNAME';
+-- Unweighted (hop-count) shortest path between two vertices, computed over the
+-- cached global graph adjacency via BFS. Returns a single path (0 or 1 rows).
+-- Argument order mirrors the Cypher shortestPath() pattern
+-- (a)-[:type*min_hops..max_hops]->(b):
+-- (graph_name, start, end, edge_types, direction, min_hops, max_hops)
+CREATE FUNCTION ag_catalog.age_shortest_path(IN agtype, IN agtype, IN agtype,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL)
+ RETURNS SETOF agtype
+LANGUAGE C
+STABLE
+CALLED ON NULL INPUT
+PARALLEL UNSAFE
+AS 'MODULE_PATHNAME';
+
+-- All unweighted shortest paths between two vertices (one path per row).
+-- Same argument order as age_shortest_path.
+CREATE FUNCTION ag_catalog.age_all_shortest_paths(IN agtype, IN agtype, IN
agtype,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL,
+ IN agtype DEFAULT NULL)
+ RETURNS SETOF agtype
+LANGUAGE C
+STABLE
+CALLED ON NULL INPUT
+PARALLEL UNSAFE
+AS 'MODULE_PATHNAME';
+
-- function to build an edge for a VLE match
CREATE FUNCTION ag_catalog.age_build_vle_match_edge(agtype, agtype)
RETURNS agtype
diff --git a/src/backend/parser/cypher_clause.c
b/src/backend/parser/cypher_clause.c
index dcd31b9d..5ac9dea6 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -2785,6 +2785,7 @@ static Query *transform_cypher_return(cypher_parsestate
*cpstate,
query->jointree = makeFromExpr(pstate->p_joinlist, NULL);
query->hasAggs = pstate->p_hasAggs;
query->hasSubLinks = pstate->p_hasSubLinks;
+ query->hasTargetSRFs = pstate->p_hasTargetSRFs;
assign_query_collations(pstate, query);
diff --git a/src/backend/parser/cypher_expr.c b/src/backend/parser/cypher_expr.c
index e62f2c6e..7e4f4460 100644
--- a/src/backend/parser/cypher_expr.c
+++ b/src/backend/parser/cypher_expr.c
@@ -2265,16 +2265,19 @@ static Node *transform_FuncCall(cypher_parsestate
*cpstate, FuncCall *fn)
fname = list_make2(makeString("ag_catalog"), makeString(ag_name));
/*
- * Currently 3 functions need the graph name passed in as the first
- * argument - in addition to the other arguments: startNode,
endNode,
- * and vle. So, check for those 3 functions here and that the arg
list
- * is not empty. Then prepend the graph name if necessary.
+ * Currently these functions need the graph name passed in as the
+ * first argument - in addition to the other arguments: startNode,
+ * endNode, vle, vertex_stats, shortest_path, and
all_shortest_paths.
+ * So, check for those functions here and that the arg list is not
+ * empty. Then prepend the graph name if necessary.
*/
if ((list_length(targs) != 0) &&
(strcasecmp("startNode", name) == 0 ||
strcasecmp("endNode", name) == 0 ||
strcasecmp("vle", name) == 0 ||
- strcasecmp("vertex_stats", name) == 0))
+ strcasecmp("vertex_stats", name) == 0 ||
+ strcasecmp("shortest_path", name) == 0 ||
+ strcasecmp("all_shortest_paths", name) == 0))
{
char *graph_name = cpstate->graph_name;
Datum d = string_to_agtype(graph_name);
diff --git a/src/backend/utils/adt/age_vle.c b/src/backend/utils/adt/age_vle.c
index 9e433b9e..804d9e17 100644
--- a/src/backend/utils/adt/age_vle.c
+++ b/src/backend/utils/adt/age_vle.c
@@ -65,6 +65,8 @@
#include "common/hashfn.h"
#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
#include "utils/datum.h"
#include "utils/lsyscache.h"
@@ -1065,6 +1067,14 @@ static bool dfs_find_a_path_between(VLE_local_context
*vlelctx)
bool found = false;
uint32 edge_hashvalue;
+ /*
+ * Allow this traversal to be cancelled (e.g. by a user Ctrl-C or a
+ * statement_timeout). On a large or densely connected graph this DFS
+ * can run for a long time, so we must yield to interrupt processing
+ * on every iteration.
+ */
+ CHECK_FOR_INTERRUPTS();
+
/* get an edge, but leave it on the stack for now */
edge_id = gid_stack_peek(edge_stack);
/*
@@ -1200,6 +1210,14 @@ static bool dfs_find_a_path_from(VLE_local_context
*vlelctx)
bool found = false;
uint32 edge_hashvalue;
+ /*
+ * Allow this traversal to be cancelled (e.g. by a user Ctrl-C or a
+ * statement_timeout). On a large or densely connected graph this DFS
+ * can run for a long time, so we must yield to interrupt processing
+ * on every iteration.
+ */
+ CHECK_FOR_INTERRUPTS();
+
/* get an edge, but leave it on the stack for now */
edge_id = gid_stack_peek(edge_stack);
/*
@@ -2774,3 +2792,781 @@ Datum _ag_enforce_edge_uniqueness(PG_FUNCTION_ARGS)
hash_destroy(exists_hash);
PG_RETURN_BOOL(true);
}
+
+/*
+ * ---------------------------------------------------------------------------
+ * Shortest path / all shortest paths
+ * ---------------------------------------------------------------------------
+ *
+ * Plain (non-grammar) set-returning functions that compute the unweighted
+ * (hop-count) shortest path between two vertices, built directly on top of the
+ * cached global graph (GRAPH_global_context) and its flat-array adjacency
+ * (VertexEdgeArray). These do NOT go through the VLE grammar/transform path;
+ * they are user-callable helpers:
+ *
+ * ag_catalog.age_shortest_path(graph, start, end
+ * [, edge_types [, direction [, min_hops [, max_hops]]]])
+ * ag_catalog.age_all_shortest_paths(graph, start, end
+ * [, edge_types [, direction [, min_hops [, max_hops]]]])
+ *
+ * Both perform a breadth-first search from the start vertex. age_shortest_path
+ * returns a single path (0 or 1 rows); age_all_shortest_paths returns every
+ * path whose length equals the minimum hop count (one row per path), by
+ * recording a predecessor multiset during the BFS and enumerating the
+ * resulting shortest-path DAG.
+ *
+ * Because BFS depth strictly increases, every emitted path is simple (no
+ * repeated vertex and therefore no repeated edge), satisfying openCypher
+ * edge-isomorphism for these fixed-length results.
+ */
+
+/* Simple FIFO queue of graphids for the BFS frontier. */
+typedef struct sp_queue
+{
+ graphid *data;
+ int64 head;
+ int64 tail;
+ int64 cap;
+} sp_queue;
+
+/* One predecessor edge on a shortest path (all-shortest-paths mode). */
+typedef struct sp_pred
+{
+ graphid edge;
+ graphid parent_vertex;
+} sp_pred;
+
+/* Per-vertex BFS bookkeeping, keyed by vertex_id in the visited hashtable. */
+typedef struct sp_visit_entry
+{
+ graphid vertex_id; /* hash key — must be first */
+ int64 depth; /* BFS depth from the source vertex */
+ graphid parent_edge; /* single-path reconstruction */
+ graphid parent_vertex; /* single-path reconstruction */
+ List *preds; /* sp_pred * list for all-shortest-paths mode */
+} sp_visit_entry;
+
+/* Cross-call SRF state: the precomputed result paths streamed one per call. */
+typedef struct sp_srf_state
+{
+ Datum *paths;
+ int64 npaths;
+ int64 next;
+} sp_srf_state;
+
+static void sp_queue_init(sp_queue *q)
+{
+ q->cap = 1024;
+ q->head = 0;
+ q->tail = 0;
+ q->data = palloc(sizeof(graphid) * q->cap);
+}
+
+static void sp_queue_push(sp_queue *q, graphid v)
+{
+ if (q->tail == q->cap)
+ {
+ q->cap = q->cap * 2;
+ q->data = repalloc(q->data, sizeof(graphid) * q->cap);
+ }
+ q->data[q->tail] = v;
+ q->tail = q->tail + 1;
+}
+
+static bool sp_queue_is_empty(sp_queue *q)
+{
+ return q->head == q->tail;
+}
+
+static graphid sp_queue_pop(sp_queue *q)
+{
+ graphid v = q->data[q->head];
+
+ q->head = q->head + 1;
+ return v;
+}
+
+/* Resolve a vertex argument (a vertex agtype or an integer id) to a graphid.
*/
+static graphid sp_agtype_to_graphid(agtype *agt, const char *argname)
+{
+ agtype_value *agtv = NULL;
+
+ agtv = get_agtype_value("age_shortest_path", agt, AGTV_VERTEX, false);
+
+ if (agtv != NULL && agtv->type == AGTV_VERTEX)
+ {
+ agtv = GET_AGTYPE_VALUE_OBJECT_VALUE(agtv, "id");
+ }
+ else if (agtv == NULL || agtv->type != AGTV_INTEGER)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("%s argument must be a vertex or the integer id",
+ argname)));
+ }
+
+ return agtv->val.int_value;
+}
+
+/* Resolve the optional direction argument; NULL defaults to undirected. */
+static cypher_rel_dir sp_agtype_to_direction(agtype *agt)
+{
+ agtype_value *agtv = NULL;
+ char *s = NULL;
+ cypher_rel_dir dir = CYPHER_REL_DIR_NONE;
+
+ if (agt == NULL)
+ {
+ return CYPHER_REL_DIR_NONE;
+ }
+
+ agtv = get_agtype_value("age_shortest_path", agt, AGTV_STRING, true);
+ s = pnstrdup(agtv->val.string.val, agtv->val.string.len);
+
+ if (pg_strcasecmp(s, "out") == 0)
+ {
+ dir = CYPHER_REL_DIR_RIGHT;
+ }
+ else if (pg_strcasecmp(s, "in") == 0)
+ {
+ dir = CYPHER_REL_DIR_LEFT;
+ }
+ else if (pg_strcasecmp(s, "any") == 0)
+ {
+ dir = CYPHER_REL_DIR_NONE;
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("direction argument must be one of 'out', 'in', or
'any'")));
+ }
+
+ pfree_if_not_null(s);
+ return dir;
+}
+
+/*
+ * Wrap an interleaved [vertex, edge, vertex, ... , vertex] graphid array in a
+ * VLE_path_container and materialize it as an AGTV_PATH agtype Datum.
+ */
+static Datum sp_build_path_datum(Oid graph_oid, graphid *alt, int64 alt_len)
+{
+ VLE_path_container *vpc = NULL;
+ graphid *arr = NULL;
+ agtype_value *agtv_path = NULL;
+ agtype *agt = NULL;
+
+ vpc = create_VLE_path_container(alt_len);
+ vpc->graph_oid = graph_oid;
+
+ arr = GET_GRAPHID_ARRAY_FROM_CONTAINER(vpc);
+ memcpy(arr, alt, sizeof(graphid) * alt_len);
+
+ vpc->start_vid = alt[0];
+ vpc->end_vid = alt[alt_len - 1];
+
+ agtv_path = build_path(vpc);
+ agt = agtype_value_to_agtype(agtv_path);
+
+ return AGTYPE_P_GET_DATUM(agt);
+}
+
+/*
+ * Breadth-first search from source toward target over the flat-array
+ * adjacency. Returns the visited hashtable; sets *out_found and (if found)
+ * *out_target_depth (the shortest hop count). In all-shortest-paths mode
+ * (collect_all) every shortest-path predecessor is recorded per vertex.
+ */
+static HTAB *sp_run_bfs(GRAPH_global_context *ggctx, graphid source,
+ graphid target, bool filter_edges, Oid edge_label_oid,
+ cypher_rel_dir dir, int64 max_hops, bool collect_all,
+ int64 *out_target_depth, bool *out_found)
+{
+ HASHCTL ctl;
+ HTAB *visited = NULL;
+ sp_queue q;
+ sp_visit_entry *se = NULL;
+ bool found = false;
+ int64 target_depth = -1;
+ bool dir_out = (dir == CYPHER_REL_DIR_RIGHT || dir == CYPHER_REL_DIR_NONE);
+ bool dir_in = (dir == CYPHER_REL_DIR_LEFT || dir == CYPHER_REL_DIR_NONE);
+
+ /* visited hashtable: graphid -> sp_visit_entry */
+ MemSet(&ctl, 0, sizeof(ctl));
+ ctl.keysize = sizeof(int64);
+ ctl.entrysize = sizeof(sp_visit_entry);
+ ctl.hash = graphid_hash;
+ visited = hash_create("age shortest path visited", 1024, &ctl,
+ HASH_ELEM | HASH_FUNCTION);
+
+ /*
+ * A path can only exist between vertices that actually exist in the graph.
+ * If either endpoint is missing we are done: report "not found" and return
+ * the (empty) visited table. This guard is critical: without it a source
+ * that equals a non-existent target would be matched at depth 0 (see the
+ * "u == target" check below), and path reconstruction would then try to
+ * materialize a vertex that does not exist, dereferencing invalid memory
+ * and crashing the backend.
+ */
+ if (get_vertex_entry(ggctx, source) == NULL ||
+ get_vertex_entry(ggctx, target) == NULL)
+ {
+ *out_target_depth = -1;
+ *out_found = false;
+ return visited;
+ }
+
+ sp_queue_init(&q);
+
+ /* seed the frontier with the source vertex at depth 0 */
+ se = (sp_visit_entry *) hash_search(visited, &source, HASH_ENTER, NULL);
+ se->vertex_id = source;
+ se->depth = 0;
+ se->parent_edge = 0;
+ se->parent_vertex = source;
+ se->preds = NIL;
+ sp_queue_push(&q, source);
+
+ while (!sp_queue_is_empty(&q))
+ {
+ graphid u = sp_queue_pop(&q);
+ sp_visit_entry *ue = NULL;
+ vertex_entry *ve = NULL;
+ int64 du = 0;
+ int pass = 0;
+
+ /*
+ * Allow this search to be cancelled (e.g. by a user Ctrl-C or a
+ * statement_timeout). On a large graph the BFS frontier can grow very
+ * large, so we must yield to interrupt processing on every iteration.
+ */
+ CHECK_FOR_INTERRUPTS();
+
+ ue = (sp_visit_entry *) hash_search(visited, &u, HASH_FIND, NULL);
+ du = ue->depth;
+
+ /* target reached: record its (shortest) depth */
+ if (u == target)
+ {
+ found = true;
+ if (target_depth < 0)
+ {
+ target_depth = du;
+ }
+ /* single-path mode: the first discovery is sufficient */
+ if (!collect_all)
+ {
+ break;
+ }
+ }
+
+ /* never expand at or beyond the shortest target depth */
+ if (target_depth >= 0 && du >= target_depth)
+ {
+ continue;
+ }
+
+ /* respect the optional upper hop bound */
+ if (max_hops >= 0 && du >= max_hops)
+ {
+ continue;
+ }
+
+ ve = get_vertex_entry(ggctx, u);
+ if (ve == NULL)
+ {
+ continue;
+ }
+
+ /* pass 0 = outgoing edges, pass 1 = incoming edges */
+ for (pass = 0; pass < 2; pass++)
+ {
+ VertexEdgeArray *edges = NULL;
+ int32 i = 0;
+
+ if (pass == 0)
+ {
+ if (!dir_out)
+ {
+ continue;
+ }
+ edges = get_vertex_entry_edges_out_array(ve);
+ }
+ else
+ {
+ if (!dir_in)
+ {
+ continue;
+ }
+ edges = get_vertex_entry_edges_in_array(ve);
+ }
+
+ if (edges == NULL || edges->array == NULL)
+ {
+ continue;
+ }
+
+ for (i = 0; i < edges->size; i++)
+ {
+ graphid eid = edges->array[i];
+ edge_entry *ee = NULL;
+ graphid v = 0;
+ sp_visit_entry *vse = NULL;
+ bool was_present = false;
+
+ ee = get_edge_entry(ggctx, eid);
+ if (ee == NULL)
+ {
+ continue;
+ }
+
+ /*
+ * Optional edge label filter. When a label filter is active
+ * we keep only edges whose label table oid matches. Note that
+ * a label name which does not exist in this graph resolves to
+ * InvalidOid; because no real edge can have an InvalidOid
+ * label table, every edge is then skipped and only the
+ * zero-length (start == end) path can match -- matching the
+ * openCypher semantics that an unknown relationship type
+ * matches no relationships.
+ */
+ if (filter_edges &&
+ get_edge_entry_label_table_oid(ee) != edge_label_oid)
+ {
+ continue;
+ }
+
+ /* the neighbor depends on which side of the edge u is on */
+ if (pass == 0)
+ {
+ v = get_edge_entry_end_vertex_id(ee);
+ }
+ else
+ {
+ v = get_edge_entry_start_vertex_id(ee);
+ }
+
+ /* self loops never shorten a path to a different vertex */
+ if (v == u)
+ {
+ continue;
+ }
+
+ vse = (sp_visit_entry *) hash_search(visited, &v, HASH_ENTER,
+ &was_present);
+ if (!was_present)
+ {
+ vse->vertex_id = v;
+ vse->depth = du + 1;
+ vse->parent_edge = eid;
+ vse->parent_vertex = u;
+ vse->preds = NIL;
+
+ if (collect_all)
+ {
+ sp_pred *p = palloc(sizeof(sp_pred));
+
+ p->edge = eid;
+ p->parent_vertex = u;
+ vse->preds = lappend(vse->preds, p);
+ }
+
+ sp_queue_push(&q, v);
+ }
+ else if (collect_all && vse->depth == du + 1)
+ {
+ /* another equally-short predecessor of v */
+ sp_pred *p = palloc(sizeof(sp_pred));
+
+ p->edge = eid;
+ p->parent_vertex = u;
+ vse->preds = lappend(vse->preds, p);
+ }
+ }
+ }
+ }
+
+ *out_target_depth = target_depth;
+ *out_found = found;
+ return visited;
+}
+
+/*
+ * Recursively enumerate every shortest path by walking the predecessor DAG
+ * from target back to source. Each completed path is appended to *out as a
+ * freshly allocated interleaved graphid array of length alt_len.
+ */
+static void sp_enumerate(HTAB *visited, graphid source, graphid cur,
+ graphid *alt, int64 alt_len, int64 pos, List **out)
+{
+ sp_visit_entry *e = NULL;
+ ListCell *lc = NULL;
+
+ /*
+ * Enumerating every shortest path can be combinatorially expensive, so
+ * allow the user to cancel (Ctrl-C / statement_timeout) at each step.
+ */
+ CHECK_FOR_INTERRUPTS();
+
+ alt[pos] = cur;
+
+ if (cur == source)
+ {
+ /* a complete path only when we have consumed the whole array */
+ if (pos == 0)
+ {
+ graphid *copy = palloc(sizeof(graphid) * alt_len);
+
+ memcpy(copy, alt, sizeof(graphid) * alt_len);
+ *out = lappend(*out, copy);
+ }
+ return;
+ }
+
+ e = (sp_visit_entry *) hash_search(visited, &cur, HASH_FIND, NULL);
+ if (e == NULL)
+ {
+ return;
+ }
+
+ foreach(lc, e->preds)
+ {
+ sp_pred *p = (sp_pred *) lfirst(lc);
+
+ alt[pos - 1] = p->edge;
+ sp_enumerate(visited, source, p->parent_vertex, alt, alt_len, pos - 2,
+ out);
+ }
+}
+
+/*
+ * Resolve arguments, run the BFS, and materialize the result path(s) as an
+ * array of AGTV_PATH agtype Datums. Returns NULL with *out_count == 0 when no
+ * path exists. Caller must run in a context that survives the SRF.
+ */
+static Datum *sp_compute_paths(agtype *graph_name_agt, agtype *start_agt,
+ agtype *end_agt, agtype *label_agt,
+ agtype *dir_agt, agtype *minhops_agt,
+ agtype *maxhops_agt, bool collect_all,
+ int64 *out_count)
+{
+ agtype_value *agtv_temp = NULL;
+ char *graph_name = NULL;
+ Oid graph_oid = InvalidOid;
+ GRAPH_global_context *ggctx = NULL;
+ graphid source = 0;
+ graphid target = 0;
+ bool filter_edges = false;
+ Oid edge_label_oid = InvalidOid;
+ cypher_rel_dir dir = CYPHER_REL_DIR_NONE;
+ int64 max_hops = -1;
+ HTAB *visited = NULL;
+ int64 target_depth = -1;
+ bool found = false;
+ Datum *paths = NULL;
+
+ *out_count = 0;
+
+ /* the graph name is required */
+ if (graph_name_agt == NULL)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("age_shortest_path: graph name cannot be NULL")));
+ }
+
+ agtv_temp = get_agtype_value("age_shortest_path", graph_name_agt,
+ AGTV_STRING, true);
+ graph_name = pnstrdup(agtv_temp->val.string.val,
+ agtv_temp->val.string.len);
+ graph_oid = get_graph_oid(graph_name);
+
+ /*
+ * A NULL start or end vertex yields no rows, matching Cypher semantics
+ * where a null endpoint simply produces no match (it is not an error).
+ */
+ if (start_agt == NULL || end_agt == NULL)
+ {
+ return NULL;
+ }
+
+ source = sp_agtype_to_graphid(start_agt, "start vertex");
+ target = sp_agtype_to_graphid(end_agt, "end vertex");
+
+ /*
+ * Optional edge type filter. A single relationship type may be supplied
+ * either as a bare string or as a one-element array. Multiple relationship
+ * types (an array with more than one element) are not yet supported.
+ */
+ if (label_agt != NULL)
+ {
+ char *label_name = NULL;
+
+ if (AGT_ROOT_IS_ARRAY(label_agt) && !AGT_ROOT_IS_SCALAR(label_agt))
+ {
+ int nelems = AGT_ROOT_COUNT(label_agt);
+
+ if (nelems > 1)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("age_shortest_path: multiple relationship
types are not yet supported")));
+ }
+
+ if (nelems == 1)
+ {
+ agtv_temp = get_ith_agtype_value_from_container(
+ &label_agt->root, 0);
+ if (agtv_temp->type != AGTV_STRING)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("age_shortest_path: relationship type must
be a string")));
+ }
+ if (agtv_temp->val.string.len != 0)
+ {
+ label_name = pnstrdup(agtv_temp->val.string.val,
+ agtv_temp->val.string.len);
+ edge_label_oid = get_label_relation(label_name, graph_oid);
+ filter_edges = true;
+ }
+ }
+ }
+ else
+ {
+ agtv_temp = get_agtype_value("age_shortest_path", label_agt,
+ AGTV_STRING, true);
+ if (agtv_temp->val.string.len != 0)
+ {
+ label_name = pnstrdup(agtv_temp->val.string.val,
+ agtv_temp->val.string.len);
+ edge_label_oid = get_label_relation(label_name, graph_oid);
+ filter_edges = true;
+ }
+ }
+ }
+
+ /* optional direction (defaults to undirected) */
+ dir = sp_agtype_to_direction(dir_agt);
+
+ /*
+ * Optional minimum hop count. A genuine minimum-length constraint needs a
+ * different search than plain BFS, so for now only the default (NULL or 0)
+ * is accepted; any other value is rejected loudly.
+ */
+ if (minhops_agt != NULL)
+ {
+ int64 min_hops = 0;
+
+ agtv_temp = get_agtype_value("age_shortest_path", minhops_agt,
+ AGTV_INTEGER, true);
+ min_hops = agtv_temp->val.int_value;
+ if (min_hops != 0)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("age_shortest_path: a minimum hop count is not yet
supported")));
+ }
+ }
+
+ /* optional upper hop bound (NULL or negative means unbounded) */
+ if (maxhops_agt != NULL)
+ {
+ agtv_temp = get_agtype_value("age_shortest_path", maxhops_agt,
+ AGTV_INTEGER, true);
+ max_hops = agtv_temp->val.int_value;
+ if (max_hops < 0)
+ {
+ max_hops = -1;
+ }
+ }
+
+ /* build / fetch the global graph cache for this graph */
+ ggctx = manage_GRAPH_global_contexts(graph_name, graph_oid);
+ if (ggctx == NULL)
+ {
+ return NULL;
+ }
+
+ /* run the breadth-first search */
+ visited = sp_run_bfs(ggctx, source, target, filter_edges, edge_label_oid,
+ dir, max_hops, collect_all, &target_depth, &found);
+
+ if (!found)
+ {
+ hash_destroy(visited);
+ return NULL;
+ }
+
+ if (!collect_all)
+ {
+ /* reconstruct the single shortest path from the parent pointers */
+ int64 alt_len = (2 * target_depth) + 1;
+ graphid *alt = palloc(sizeof(graphid) * alt_len);
+ int64 pos = alt_len - 1;
+ graphid cur = target;
+
+ alt[pos] = cur;
+ pos = pos - 1;
+ while (cur != source)
+ {
+ sp_visit_entry *e = NULL;
+
+ e = (sp_visit_entry *) hash_search(visited, &cur, HASH_FIND, NULL);
+ alt[pos] = e->parent_edge;
+ pos = pos - 1;
+ alt[pos] = e->parent_vertex;
+ pos = pos - 1;
+ cur = e->parent_vertex;
+ }
+
+ paths = palloc(sizeof(Datum));
+ paths[0] = sp_build_path_datum(graph_oid, alt, alt_len);
+ *out_count = 1;
+ }
+ else
+ {
+ /* enumerate every equal-length shortest path */
+ int64 alt_len = (2 * target_depth) + 1;
+ graphid *alt = palloc(sizeof(graphid) * alt_len);
+ List *arrays = NIL;
+ ListCell *lc = NULL;
+ int64 n = 0;
+ int64 idx = 0;
+
+ sp_enumerate(visited, source, target, alt, alt_len, alt_len - 1,
+ &arrays);
+
+ n = list_length(arrays);
+ paths = palloc(sizeof(Datum) * (n > 0 ? n : 1));
+ foreach(lc, arrays)
+ {
+ graphid *a = (graphid *) lfirst(lc);
+
+ paths[idx] = sp_build_path_datum(graph_oid, a, alt_len);
+ idx = idx + 1;
+ }
+ *out_count = n;
+ }
+
+ hash_destroy(visited);
+ return paths;
+}
+
+/*
+ * Shared SRF driver for age_shortest_path / age_all_shortest_paths. The first
+ * call computes every result path up front and stores them; subsequent calls
+ * stream them one per row.
+ */
+static Datum sp_srf_impl(FunctionCallInfo fcinfo, bool collect_all)
+{
+ FuncCallContext *funcctx = NULL;
+ sp_srf_state *state = NULL;
+
+ if (SRF_IS_FIRSTCALL())
+ {
+ MemoryContext oldctx;
+ agtype *a_graph = NULL;
+ agtype *a_start = NULL;
+ agtype *a_end = NULL;
+ agtype *a_label = NULL;
+ agtype *a_dir = NULL;
+ agtype *a_min = NULL;
+ agtype *a_max = NULL;
+
+ funcctx = SRF_FIRSTCALL_INIT();
+ oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+
+ /*
+ * Argument order mirrors the Cypher shortestPath() pattern
+ * (a)-[:type*min_hops..max_hops]->(b):
+ * 0 graph, 1 start, 2 end, 3 edge_types, 4 direction,
+ * 5 min_hops, 6 max_hops
+ */
+ a_graph = PG_ARGISNULL(0) ? NULL : AG_GET_ARG_AGTYPE_P(0);
+ a_start = PG_ARGISNULL(1) ? NULL : AG_GET_ARG_AGTYPE_P(1);
+ a_end = PG_ARGISNULL(2) ? NULL : AG_GET_ARG_AGTYPE_P(2);
+ a_label = PG_ARGISNULL(3) ? NULL : AG_GET_ARG_AGTYPE_P(3);
+ a_dir = PG_ARGISNULL(4) ? NULL : AG_GET_ARG_AGTYPE_P(4);
+ a_min = PG_ARGISNULL(5) ? NULL : AG_GET_ARG_AGTYPE_P(5);
+ a_max = PG_ARGISNULL(6) ? NULL : AG_GET_ARG_AGTYPE_P(6);
+
+ /* treat an explicit agtype null the same as a SQL NULL */
+ if (a_start != NULL && is_agtype_null(a_start))
+ {
+ a_start = NULL;
+ }
+ if (a_end != NULL && is_agtype_null(a_end))
+ {
+ a_end = NULL;
+ }
+ if (a_label != NULL && is_agtype_null(a_label))
+ {
+ a_label = NULL;
+ }
+ if (a_dir != NULL && is_agtype_null(a_dir))
+ {
+ a_dir = NULL;
+ }
+ if (a_min != NULL && is_agtype_null(a_min))
+ {
+ a_min = NULL;
+ }
+ if (a_max != NULL && is_agtype_null(a_max))
+ {
+ a_max = NULL;
+ }
+
+ state = palloc0(sizeof(sp_srf_state));
+ state->next = 0;
+ state->paths = sp_compute_paths(a_graph, a_start, a_end, a_label,
+ a_dir, a_min, a_max, collect_all,
+ &state->npaths);
+ funcctx->user_fctx = state;
+
+ MemoryContextSwitchTo(oldctx);
+ }
+
+ funcctx = SRF_PERCALL_SETUP();
+ state = (sp_srf_state *) funcctx->user_fctx;
+
+ if (state->next < state->npaths)
+ {
+ Datum d = state->paths[state->next];
+
+ state->next = state->next + 1;
+ SRF_RETURN_NEXT(funcctx, d);
+ }
+
+ SRF_RETURN_DONE(funcctx);
+}
+
+/*
+ * age_shortest_path(graph_name, start, end [, edge_types [, direction
+ * [, min_hops [, max_hops]]]]) -> SETOF agtype
+ *
+ * Returns the single unweighted shortest path (as an AGTV_PATH) between the
+ * start and end vertices, or no rows if unreachable.
+ */
+PG_FUNCTION_INFO_V1(age_shortest_path);
+
+Datum age_shortest_path(PG_FUNCTION_ARGS)
+{
+ return sp_srf_impl(fcinfo, false);
+}
+
+/*
+ * age_all_shortest_paths(graph_name, start, end [, edge_types [, direction
+ * [, min_hops [, max_hops]]]]) -> SETOF agtype
+ *
+ * Returns every unweighted shortest path (one AGTV_PATH per row) between the
+ * start and end vertices, i.e. all paths whose length equals the minimum hop
+ * count, or no rows if unreachable.
+ */
+PG_FUNCTION_INFO_V1(age_all_shortest_paths);
+
+Datum age_all_shortest_paths(PG_FUNCTION_ARGS)
+{
+ return sp_srf_impl(fcinfo, true);
+}