built-ins: add graph.reachable_paths (#4205)

This new built-in functionality allows callers to find all reachable
paths in a graph based on an array or set of root nodes. See the
updates to policy-reference.md for more details and usage information.

Signed-off-by: Justin Lindh <justin.lindh@webfilings.com>
This commit is contained in:
Justin Lindh
2022-01-17 01:31:31 -07:00
committed by GitHub
parent 4700768448
commit 258501134d
5 changed files with 368 additions and 1 deletions
+21
View File
@@ -211,6 +211,7 @@ var DefaultBuiltins = [...]*Builtin{
// Graphs
WalkBuiltin,
ReachableBuiltin,
ReachablePathsBuiltin,
// Sort
Sort,
@@ -2007,6 +2008,26 @@ var ReachableBuiltin = &Builtin{
),
}
// ReachablePathsBuiltin computes the set of reachable paths in the graph from a set
// of starting nodes.
var ReachablePathsBuiltin = &Builtin{
Name: "graph.reachable_paths",
Decl: types.NewFunction(
types.Args(
types.NewObject(
nil,
types.NewDynamicProperty(
types.A,
types.NewAny(
types.NewSet(types.A),
types.NewArray(nil, types.A)),
)),
types.NewAny(types.NewSet(types.A), types.NewArray(nil, types.A)),
),
types.NewSet(types.NewArray(nil, types.A)),
),
}
/**
* Sorting
*/
+59
View File
@@ -979,6 +979,65 @@
"type": "function"
}
},
{
"name": "graph.reachable_paths",
"decl": {
"args": [
{
"dynamic": {
"key": {
"type": "any"
},
"value": {
"of": [
{
"dynamic": {
"type": "any"
},
"type": "array"
},
{
"of": {
"type": "any"
},
"type": "set"
}
],
"type": "any"
}
},
"type": "object"
},
{
"of": [
{
"dynamic": {
"type": "any"
},
"type": "array"
},
{
"of": {
"type": "any"
},
"type": "set"
}
],
"type": "any"
}
],
"result": {
"of": {
"dynamic": {
"type": "any"
},
"type": "array"
},
"type": "set"
},
"type": "function"
}
},
{
"name": "gt",
"decl": {
+25 -1
View File
@@ -842,9 +842,10 @@ Note that the opa executable will need access to the timezone files in the envir
### Graphs
| Built-in | Description | Wasm Support |
| ------- |-------------|---------------|
| ------- |-------------|--------------|
| <span class="opa-keep-it-together">``walk(x, [path, value])``</span> | ``walk`` is a relation that produces ``path`` and ``value`` pairs for documents under ``x``. ``path`` is ``array`` representing a pointer to ``value`` in ``x``. Queries can use ``walk`` to traverse documents nested under ``x`` (recursively). | ✅ |
| <span class="opa-keep-it-together">``output := graph.reachable(graph, initial)``</span> | ``output`` is the set of vertices [reachable](https://en.wikipedia.org/wiki/Reachability) from the ``initial`` vertices in the directed ``graph``. ``initial`` is a set or array of vertices, and ``graph`` is an object containing a set or array of neighboring vertices. | ✅ |
| <span class="opa-keep-it-together">``output := graph.reachable_paths(graph, initial)``</span> | ``output`` is the set of arrays of paths reachable from the ``initial`` vertices in the directed ``graph``. ``initial`` is a set or array of paths, and ``graph`` is an object containing a set or array of root vertices. | `SDK-dependent` |
A common class of recursive rules can be reduced to a graph reachability
problem, so `graph.reachable` is useful for more than just graph analysis.
@@ -878,6 +879,29 @@ org_chart_permissions[entity_name]
```live:graph/reachable/example:output
```
It may be useful to find all reachable paths from a root element. `graph.reachable_paths` can be used for this. Note that cyclical paths will terminate on the repeated node. If an element references a nonexistent element, the path will be terminated, and excludes the nonexistent node.
```live:graph/reachable_paths/example:module
package graph_reachable_paths_example
path_data = {
"aTop": [],
"cMiddle": ["aTop"],
"bBottom": ["cMiddle"],
"dIgnored": []
}
all_paths[root] = paths {
path_data[root]
paths := graph.reachable_paths(path_data, {root})
}
```
```live:graph/reachable_paths/example:query
all_paths[entity_name]
```
```live:graph/reachable_paths/example:output
```
### HTTP
| Built-in | Description | Wasm Support |
@@ -0,0 +1,190 @@
cases:
- data: {}
modules:
- |
package reachable
p = result {
graph.reachable_paths({}, {"a"}, result)
}
note: reachable_paths/empty
query: data.reachable.p = x
want_result:
- x: []
- data: {}
input_term: '{
"graph": {
"a": {"b"},
"b": {"c"},
"c": {"a"},
},
"initial": {"a"}
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/cycle
query: data.reachable.p = x
want_result:
- x:
- - a
- b
- c
- data: {}
input_term: '{
"graph": {
"a": {"b", "c"},
"b": {"d"},
"c": {"d"},
"d": set(),
"e": {"f"},
"f": {"e"},
"x": {"x"},
},
"initial": {
"b", "e"
}
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/components
query: data.reachable.p = x
want_result:
- x:
- - b
- d
- - e
- f
- data: {}
input_term: '{
"graph": {
"a": ["b"],
"b": ["c"],
"c": ["a"],
},
"initial": ["a"]
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/arrays
query: data.reachable.p = x
want_result:
- x:
- - a
- b
- c
- data: {}
input_term: '{
"graph": 1,
"initial": [1]
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/malformed 1
query: data.reachable.p = x
want_error_code: eval_type_error
strict_error: true
- data: {}
input_term: '{
"graph": {
"a": null
},
"initial": ["a"]
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/malformed 2
query: data.reachable.p = x
want_result:
- x:
- - a
- data: {}
input_term: '{
"graph": {
"a": []
},
"initial": "a"
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/malformed 3
query: data.reachable.p = x
want_result:
- x: []
- data: {}
input_term: '{
"graph": {
"a": ["b", "c"],
"b": ["c"],
"c": [],
},
"initial": {"a"}
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/multiple_paths
query: data.reachable.p = x
want_result:
- x:
- - a
- b
- c
- - a
- c
- data: {}
input_term: '{
"graph": {
"a": ["b"],
"b": ["nonexistent"],
},
"initial": {"a", "b"}
}'
modules:
- |
package reachable
p = result {
graph.reachable_paths(input.graph, input.initial, result)
}
note: reachable_paths/invalid_end
query: data.reachable.p = x
want_result:
- x:
- - a
- b
- - b
+73
View File
@@ -6,6 +6,7 @@ package topdown
import (
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/topdown/builtins"
)
// Helper: sets of vertices can be represented as Arrays or Sets.
@@ -18,6 +19,18 @@ func foreachVertex(collection *ast.Term, f func(*ast.Term)) {
}
}
// numberOfEdges returns the number of elements of an array or a set (of edges)
func numberOfEdges(collection *ast.Term) int {
switch v := collection.Value.(type) {
case ast.Set:
return v.Len()
case *ast.Array:
return v.Len()
}
return 0
}
func builtinReachable(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term) error) error {
// Return the empty set if the first argument is not an object.
graph, ok := args[0].Value.(ast.Object)
@@ -56,6 +69,66 @@ func builtinReachable(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term
return iter(ast.NewTerm(reached))
}
// pathBuilder is called recursively to build an array of paths that are reachable from the root
func pathBuilder(graph ast.Object, root *ast.Term, path []*ast.Term, paths []*ast.Term, reached ast.Set) []*ast.Term {
if edges := graph.Get(root); edges != nil {
path = append(path, root)
if numberOfEdges(edges) >= 1 {
foreachVertex(edges, func(neighbor *ast.Term) {
if reached.Contains(neighbor) {
// If we've already reached this node, return current path (avoid infinite recursion)
paths = append(paths, path...)
} else {
reached.Add(root)
paths = pathBuilder(graph, neighbor, path, paths, reached)
}
})
} else {
paths = append(paths, path...)
}
} else {
// Node is nonexistent (not in graph). Commit the current path (without adding this root)
paths = append(paths, path...)
}
return paths
}
func builtinReachablePaths(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term) error) error {
// Return an error if the first argument is not an object.
graph, err := builtins.ObjectOperand(args[0].Value, 1)
if err != nil {
return err
}
// This is a queue that holds all nodes we still need to visit. It is
// initialised to the initial set of nodes we start out with.
var queue []*ast.Term
foreachVertex(args[1], func(t *ast.Term) {
queue = append(queue, t)
})
results := ast.NewSet()
for _, node := range queue {
// Find reachable paths from edges in root node in queue and append arrays to the results set
if edges := graph.Get(node); edges != nil {
if numberOfEdges(edges) >= 1 {
foreachVertex(edges, func(neighbor *ast.Term) {
paths := pathBuilder(graph, neighbor, []*ast.Term{node}, []*ast.Term{}, ast.NewSet(node))
results.Add(ast.ArrayTerm(paths...))
})
} else {
results.Add(ast.ArrayTerm(node))
}
}
}
return iter(ast.NewTerm(results))
}
func init() {
RegisterBuiltinFunc(ast.ReachableBuiltin.Name, builtinReachable)
RegisterBuiltinFunc(ast.ReachablePathsBuiltin.Name, builtinReachablePaths)
}