Skip to content

Commit 36ddfa5

Browse files
authored
[core][feat] Allow filtering edge properties (#2186)
1 parent fe60047 commit 36ddfa5

File tree

11 files changed

+204
-108
lines changed

11 files changed

+204
-108
lines changed

fixcore/fixcore/db/arango_query.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -877,14 +877,15 @@ def traversal_filter(cl: WithClause, in_crs: str, depth: int) -> str:
877877
)
878878
return out
879879

880-
def inout(in_crsr: str, start: int, until: int, edge_type: str, direction: str) -> str:
880+
def inout(
881+
in_crsr: str, start: int, until: int, edge_type: str, direction: str, edge_filter: Optional[Term]
882+
) -> str:
881883
nonlocal query_part
882884
in_c = ctx.next_crs("io_in")
883885
out = ctx.next_crs("io_out")
884886
out_crsr = ctx.next_crs("io_crs")
885887
e = ctx.next_crs("io_link")
886888
unique = "uniqueEdges: 'path'" if with_edges else "uniqueVertices: 'global'"
887-
link_str = f", {e}" if with_edges else ""
888889
dir_bound = "OUTBOUND" if direction == Direction.outbound else "INBOUND"
889890
inout_result = (
890891
# merge edge and vertex properties - will be split in the output transformer
@@ -899,12 +900,17 @@ def inout(in_crsr: str, start: int, until: int, edge_type: str, direction: str)
899900
graph_cursor = in_c
900901
outer_for = f"FOR {in_c} in {in_crsr} "
901902

903+
# optional: add the edge filter to the query
904+
pre, fltr, post = term(e, edge_filter) if edge_filter else (None, None, None)
905+
pre_string = " " + pre if pre else ""
906+
post_string = f" AND ({post})" if post else ""
907+
filter_string = "" if not fltr and not post_string else f"{pre_string} FILTER {fltr}{post_string}"
902908
query_part += (
903909
f"LET {out} =({outer_for}"
904910
# suggested by jsteemann: use crs._id instead of crs (stored in the view and more efficient)
905-
f"FOR {out_crsr}{link_str} IN {start}..{until} {dir_bound} {graph_cursor}._id "
906-
f"`{db.edge_collection(edge_type)}` OPTIONS {{ bfs: true, {unique} }} "
907-
f"RETURN DISTINCT {inout_result}) "
911+
f"FOR {out_crsr}, {e} IN {start}..{until} {dir_bound} {graph_cursor}._id "
912+
f"`{db.edge_collection(edge_type)}` OPTIONS {{ bfs: true, {unique} }}{filter_string} "
913+
f"RETURN DISTINCT {inout_result})"
908914
)
909915
return out
910916

@@ -913,12 +919,12 @@ def navigation(in_crsr: str, nav: Navigation) -> str:
913919
all_walks = []
914920
if nav.direction == Direction.any:
915921
for et in nav.edge_types:
916-
all_walks.append(inout(in_crsr, nav.start, nav.until, et, Direction.inbound))
922+
all_walks.append(inout(in_crsr, nav.start, nav.until, et, Direction.inbound, nav.edge_filter))
917923
for et in nav.maybe_two_directional_outbound_edge_type or nav.edge_types:
918-
all_walks.append(inout(in_crsr, nav.start, nav.until, et, Direction.outbound))
924+
all_walks.append(inout(in_crsr, nav.start, nav.until, et, Direction.outbound, nav.edge_filter))
919925
else:
920926
for et in nav.edge_types:
921-
all_walks.append(inout(in_crsr, nav.start, nav.until, et, nav.direction))
927+
all_walks.append(inout(in_crsr, nav.start, nav.until, et, nav.direction, nav.edge_filter))
922928

923929
if len(all_walks) == 1:
924930
return all_walks[0]

fixcore/fixcore/query/model.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ class Navigation:
605605
maybe_edge_types: Optional[List[EdgeType]] = None
606606
direction: str = Direction.outbound
607607
maybe_two_directional_outbound_edge_type: Optional[List[EdgeType]] = None
608+
edge_filter: Optional[Term] = None
608609

609610
@property
610611
def edge_types(self) -> List[EdgeType]:
@@ -617,14 +618,18 @@ def __str__(self) -> str:
617618
mo = self.maybe_two_directional_outbound_edge_type
618619
depth = ("" if start == 1 else f"[{start}]") if start == until and not mo else f"[{start}:{until_str}]"
619620
out_nav = ",".join(mo) if mo else ""
620-
nav = f'{",".join(self.edge_types)}{depth}{out_nav}'
621+
fltr = f"{{{self.edge_filter}}}" if self.edge_filter else ""
622+
nav = f'{",".join(self.edge_types)}{depth}{fltr}{out_nav}'
621623
if self.direction == Direction.outbound:
622624
return f"-{nav}->"
623625
elif self.direction == Direction.inbound:
624626
return f"<-{nav}-"
625627
else:
626628
return f"<-{nav}->"
627629

630+
def change_variable(self, fn: Callable[[str], str]) -> Navigation:
631+
return evolve(self, edge_filter=self.edge_filter.change_variable(fn)) if self.edge_filter else self
632+
628633

629634
NavigateUntilRoot = Navigation(
630635
start=1, until=Navigation.Max, maybe_edge_types=[EdgeTypes.default], direction=Direction.inbound
@@ -740,6 +745,7 @@ def change_variable(self, fn: Callable[[str], str]) -> Part:
740745
term=self.term.change_variable(fn),
741746
with_clause=self.with_clause.change_variable(fn) if self.with_clause else None,
742747
sort=[sort.change_variable(fn) for sort in self.sort],
748+
navigation=self.navigation.change_variable(fn) if self.navigation else None,
743749
)
744750

745751
# ancestor.some_type.reported.prop -> MergeQuery
@@ -1012,17 +1018,40 @@ def filter_with(self, clause: WithClause) -> Query:
10121018
first_part = evolve(self.parts[0], with_clause=clause)
10131019
return evolve(self, parts=[first_part, *self.parts[1:]])
10141020

1015-
def traverse_out(self, start: int = 1, until: int = 1, edge_type: EdgeType = EdgeTypes.default) -> Query:
1016-
return self.traverse(start, until, edge_type, Direction.outbound)
1017-
1018-
def traverse_in(self, start: int = 1, until: int = 1, edge_type: EdgeType = EdgeTypes.default) -> Query:
1019-
return self.traverse(start, until, edge_type, Direction.inbound)
1020-
1021-
def traverse_inout(self, start: int = 1, until: int = 1, edge_type: EdgeType = EdgeTypes.default) -> Query:
1022-
return self.traverse(start, until, edge_type, Direction.any)
1021+
def traverse_out(
1022+
self,
1023+
start: int = 1,
1024+
until: int = 1,
1025+
edge_type: EdgeType = EdgeTypes.default,
1026+
edge_filter: Optional[Term] = None,
1027+
) -> Query:
1028+
return self.traverse(start, until, edge_type, Direction.outbound, edge_filter)
1029+
1030+
def traverse_in(
1031+
self,
1032+
start: int = 1,
1033+
until: int = 1,
1034+
edge_type: EdgeType = EdgeTypes.default,
1035+
edge_filter: Optional[Term] = None,
1036+
) -> Query:
1037+
return self.traverse(start, until, edge_type, Direction.inbound, edge_filter)
1038+
1039+
def traverse_inout(
1040+
self,
1041+
start: int = 1,
1042+
until: int = 1,
1043+
edge_type: EdgeType = EdgeTypes.default,
1044+
edge_filter: Optional[Term] = None,
1045+
) -> Query:
1046+
return self.traverse(start, until, edge_type, Direction.any, edge_filter)
10231047

10241048
def traverse(
1025-
self, start: int, until: int, edge_type: EdgeType = EdgeTypes.default, direction: str = Direction.outbound
1049+
self,
1050+
start: int,
1051+
until: int,
1052+
edge_type: EdgeType = EdgeTypes.default,
1053+
direction: str = Direction.outbound,
1054+
edge_filter: Optional[Term] = None,
10261055
) -> Query:
10271056
parts = self.parts.copy()
10281057
p0 = parts[0]
@@ -1034,9 +1063,15 @@ def traverse(
10341063
parts[0] = evolve(p0, navigation=evolve(p0.navigation, start=start_m, until=until_m))
10351064
# this is another traversal: so we need to start a new part
10361065
else:
1037-
parts.insert(0, Part(AllTerm(), navigation=Navigation(start, until, [edge_type], direction)))
1066+
parts.insert(
1067+
0,
1068+
Part(
1069+
AllTerm(),
1070+
navigation=Navigation(start, until, [edge_type], direction, edge_filter=edge_filter),
1071+
),
1072+
)
10381073
else:
1039-
parts[0] = evolve(p0, navigation=Navigation(start, until, [edge_type], direction))
1074+
parts[0] = evolve(p0, navigation=Navigation(start, until, [edge_type], direction, edge_filter=edge_filter))
10401075
return evolve(self, parts=parts)
10411076

10421077
def group_by(self, variables: List[AggregateVariable], funcs: List[AggregateFunction]) -> Query:

fixcore/fixcore/query/query_parser.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -224,47 +224,62 @@ def range_parser() -> Parser:
224224
return start, end
225225

226226

227-
edge_type_p = lexeme(regex("[A-Za-z][A-Za-z0-9_]*"))
227+
edge_type_p = reduce(lambda x, y: x | y, [lexeme(string(a)) for a in EdgeTypes.all])
228228

229229

230230
@make_parser
231231
def edge_type_parser() -> Parser:
232232
edge_types = yield edge_type_p.sep_by(comma_p).map(set)
233-
for et in edge_types:
234-
if et not in EdgeTypes.all:
235-
raise AttributeError(f"Given EdgeType is not known: {et}")
236233
return list(edge_types)
237234

238235

236+
@make_parser
237+
def combined_edge_term() -> Parser:
238+
left = yield simple_edge_term_p
239+
result = left
240+
while True:
241+
op = yield bool_op_p.optional()
242+
if op is None:
243+
break
244+
right = yield simple_edge_term_p
245+
result = CombinedTerm(result, op, right)
246+
return result
247+
248+
249+
leaf_edge_term_p = predicate_term | context_term | not_term | match_all_term
250+
simple_edge_term_p = (lparen_p >> combined_edge_term << rparen_p) | leaf_edge_term_p
251+
edge_term_p = l_curly_p >> combined_edge_term << r_curly_p
252+
253+
239254
@make_parser
240255
def edge_definition_parser() -> Parser:
241256
edge_types = yield edge_type_parser
242257
maybe_range = yield range_parser.optional()
243258
start, until = maybe_range if maybe_range else (1, 1)
259+
edge_filter = yield edge_term_p.optional()
244260
after_bracket_edge_types = yield edge_type_parser
245261
if edge_types and after_bracket_edge_types:
246262
raise AttributeError("Edge types can not be defined before and after the [start,until] definition.")
247-
return start, until, edge_types or after_bracket_edge_types
263+
return Navigation(start, until, maybe_edge_types=edge_types or after_bracket_edge_types, edge_filter=edge_filter)
248264

249265

250266
@make_parser
251267
def two_directional_edge_definition_parser() -> Parser:
252268
edge_types = yield edge_type_parser
253269
maybe_range = yield range_parser.optional()
270+
edge_filter = yield edge_term_p.optional()
254271
outbound_edge_types = yield edge_type_parser
255272
start, until = maybe_range if maybe_range else (1, 1)
256-
return start, until, edge_types, outbound_edge_types
273+
return Navigation(start, until, edge_types, Direction.any, outbound_edge_types, edge_filter)
257274

258275

259276
out_p = lexeme(string("-") >> edge_definition_parser << string("->")).map(
260-
lambda nav: Navigation(nav[0], nav[1], nav[2], Direction.outbound)
277+
lambda nav: evolve(nav, direction=Direction.outbound)
261278
)
262279
in_p = lexeme(string("<-") >> edge_definition_parser << string("-")).map(
263-
lambda nav: Navigation(nav[0], nav[1], nav[2], Direction.inbound)
264-
)
265-
in_out_p = lexeme(string("<-") >> two_directional_edge_definition_parser << string("->")).map(
266-
lambda nav: Navigation(nav[0], nav[1], nav[2], Direction.any, nav[3])
280+
lambda nav: evolve(nav, direction=Direction.inbound)
267281
)
282+
in_out_p = lexeme(string("<-") >> two_directional_edge_definition_parser << string("->"))
268283
navigation_parser = in_out_p | out_p | in_p
269284

270285
tag_parser = lexeme(string("#") >> literal_p).optional()

fixcore/tests/fixcore/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def create_graph(bla_text: str, width: int = 10) -> MultiDiGraph:
1313

1414
def add_edge(from_node: str, to_node: str, edge_type: EdgeType = EdgeTypes.default) -> None:
1515
key = GraphAccess.edge_key(from_node, to_node, edge_type)
16-
graph.add_edge(from_node, to_node, key, edge_type=edge_type)
16+
graph.add_edge(from_node, to_node, key, reported=dict(a=1, b=[{"c": 1, "d": 2}, {"c": 2, "d": 2}]))
1717

1818
def add_node(uid: str, kind: str, node: Optional[Json] = None, replace: bool = False) -> None:
1919
reported = {**(node if node else to_js(Foo(uid))), "kind": kind}

fixcore/tests/fixcore/db/graphdb_test.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import string
33
from abc import ABC, abstractmethod
44
from datetime import date, datetime, timedelta
5+
from functools import partial
56
from random import SystemRandom
67
from typing import List, Optional, Any, Dict, cast, AsyncIterator, Tuple, Union, Literal
78

@@ -945,6 +946,22 @@ async def assert_security(
945946
await assert_security("change4", 10, 2, reopen=1, added_vulnerable=2)
946947

947948

949+
async def test_graph_edge_filter(filled_graph_db: GraphDB, foo_model: Model) -> None:
950+
query_list = partial(query_list_on, filled_graph_db, foo_model)
951+
assert len(await query_list("is(foo) and id==9 -{a=1}->")) == 10
952+
assert len(await query_list("is(foo) and id==9 -{a=1 and b[*].{c=1 and d=2}}->")) == 10
953+
assert len(await query_list("is(foo) and id==9 -{a=1 and b[*].{c=2 and d=2}}->")) == 10
954+
assert len(await query_list("is(foo) and id==9 -{a=1 and b[*].{c=3 and d=2}}->")) == 0
955+
assert len(await query_list("is(foo) and id==9 -{a=2 and b[*].{c=2 and d=2}}->")) == 0
956+
assert len(await query_list("is(foo) and id==9 -{a=1 and b[*].{c=1 and d=2} and b[0].c=1}->")) == 10
957+
958+
959+
async def query_list_on(db: GraphDB, model: Model, q: str) -> List[Json]:
960+
query = parse_query(q).on_section("reported")
961+
async with await db.search_list(QueryModel(query, model)) as cursor:
962+
return [entry async for entry in cursor]
963+
964+
948965
def to_json(obj: BaseResource) -> Json:
949966
return {"kind": obj.kind(), **to_js(obj)}
950967

fixcore/tests/fixcore/hypothesis_extension.py

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,20 @@
1414
lists,
1515
composite,
1616
sampled_from,
17+
DrawFn,
1718
)
1819

1920
from fixcore.model.resolve_in_graph import NodePath
2021
from fixcore.types import JsonElement, Json
2122
from fixcore.util import value_in_path, interleave
2223

2324
T = TypeVar("T")
24-
UD = Callable[[SearchStrategy[Any]], Any]
2525

2626

2727
def optional(st: SearchStrategy[T]) -> SearchStrategy[Optional[T]]:
2828
return st | just(None)
2929

3030

31-
class Drawer:
32-
"""
33-
Only here for getting a drawer for typed drawings.
34-
"""
35-
36-
def __init__(self, hypo_drawer: Callable[[SearchStrategy[Any]], Any]):
37-
self._drawer = hypo_drawer
38-
39-
def draw(self, st: SearchStrategy[T]) -> T:
40-
return cast(T, self._drawer(st))
41-
42-
def optional(self, st: SearchStrategy[T]) -> Optional[T]:
43-
return self.draw(optional(st))
44-
45-
4631
any_ws_digits_string = text(alphabet=string.ascii_letters + " " + string.digits, min_size=0, max_size=10)
4732
any_string = text(alphabet=string.ascii_letters, min_size=3, max_size=10)
4833
kind_gen = sampled_from(["volume", "instance", "load_balancer", "volume_type"])
@@ -51,8 +36,8 @@ def optional(self, st: SearchStrategy[T]) -> Optional[T]:
5136

5237

5338
@composite
54-
def json_element_gen(ud: UD) -> JsonElement:
55-
return cast(JsonElement, ud(json_object_gen | json_simple_element_gen | json_array_gen))
39+
def json_element_gen(draw: DrawFn) -> JsonElement:
40+
return cast(JsonElement, draw(json_object_gen | json_simple_element_gen | json_array_gen))
5641

5742

5843
json_simple_element_gen = any_ws_digits_string | booleans() | integers(min_value=0, max_value=100000) | just(None)
@@ -61,14 +46,13 @@ def json_element_gen(ud: UD) -> JsonElement:
6146

6247

6348
@composite
64-
def node_gen(ud: UD) -> Json:
65-
d = Drawer(ud)
66-
uid = d.draw(any_string)
67-
name = d.draw(any_string)
68-
kind = d.draw(kind_gen)
69-
reported = d.draw(json_object_gen)
70-
metadata = d.draw(json_object_gen)
71-
desired = d.draw(json_object_gen)
49+
def node_gen(draw: DrawFn) -> Json:
50+
uid = draw(any_string)
51+
name = draw(any_string)
52+
kind = draw(kind_gen)
53+
reported = draw(json_object_gen)
54+
metadata = draw(json_object_gen)
55+
desired = draw(json_object_gen)
7256
return {
7357
"id": uid,
7458
"kinds": [kind],

fixcore/tests/fixcore/model/db_updater_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ def to_b(a: Any) -> bytes:
4646

4747
for node in graph.nodes():
4848
yield to_b(graph.nodes[node])
49-
for from_node, to_node, data in graph.edges(data=True):
50-
yield to_b({"from": from_node, "to": to_node, "edge_type": data["edge_type"]})
49+
for from_node, to_node, key in graph.edges(keys=True):
50+
yield to_b({"from": from_node, "to": to_node, "edge_type": key.edge_type})
5151
yield to_b(
5252
{"from_selector": {"node_id": "id_123"}, "to_selector": {"node_id": "id_456"}, "edge_type": "delete"}
5353
)

0 commit comments

Comments
 (0)