Skip to content

Commit a4742c6

Browse files
yt-msMidnighter
authored andcommitted
feat: add implied relationships to ancestors
1 parent 3811ba9 commit a4742c6

File tree

5 files changed

+302
-9
lines changed

5 files changed

+302
-9
lines changed

src/structurizr/model/element.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ def get_afferent_relationships(self) -> Iterator[Relationship]:
100100
)
101101

102102
def add_relationship(
103-
self, relationship: Optional[Relationship] = None, **kwargs
103+
self,
104+
relationship: Optional[Relationship] = None,
105+
*,
106+
create_implied_relationships: bool = True,
107+
**kwargs,
104108
) -> Relationship:
105109
"""Add a new relationship from this element to another.
106110
@@ -119,7 +123,10 @@ def add_relationship(
119123
f"Cannot add relationship {relationship} to element {self} that is not its source."
120124
)
121125
self.relationships.add(relationship)
122-
self.get_model().add_relationship(relationship)
126+
self.get_model().add_relationship(
127+
relationship,
128+
create_implied_relationships=create_implied_relationships
129+
)
123130
return relationship
124131

125132
@classmethod
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# https://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
14+
"""
15+
Implement various strategies for adding implied relationships to the model.
16+
17+
Implied relationship strategies are used to add relationships to parents when a
18+
relationship is added to their children. For example, assuming systems A and B with
19+
child containers A1 and B1 respectively, then saying that A1 uses B1 implies that
20+
A also uses B (and A uses B1 and A1 uses B). Each strategy is represented as a
21+
function that can be set on `Model.implied_relationship_strategy`, with the default
22+
being to do nothing.
23+
"""
24+
25+
from itertools import product
26+
from typing import List
27+
28+
from .element import Element
29+
from .relationship import Relationship
30+
from .software_system import SoftwareSystem
31+
32+
33+
def default_implied_relationship_strategy(relationship: Relationship):
34+
"""Don't create any implied relationships."""
35+
pass
36+
37+
38+
def create_implied_relationships_unless_any_exist(relationship: Relationship):
39+
"""
40+
Create implied relationships unless there is any existing.
41+
42+
This strategy creates implied relationships between all valid combinations of the
43+
parent elements, unless any relationship already exists between them.
44+
"""
45+
source = relationship.source
46+
destination = relationship.destination
47+
ancestor_pairs = product(_get_ancestors(source), _get_ancestors(destination))
48+
for new_source, new_destination in ancestor_pairs:
49+
if _implied_relationship_is_allowed(new_source, new_destination):
50+
if not any(
51+
r.destination is new_destination
52+
for r in new_source.get_efferent_relationships()
53+
):
54+
_clone_relationship(relationship, new_source, new_destination)
55+
56+
57+
def create_implied_relationships_unless_same_exists(relationship: Relationship):
58+
"""
59+
Create implied relationships unless there is a existing one with the same description.
60+
61+
This strategy creates implied relationships between all valid combinations of the
62+
parent elements, unless any relationship already exists between them which has the
63+
same description as the original.
64+
"""
65+
source = relationship.source
66+
destination = relationship.destination
67+
ancestor_pairs = product(_get_ancestors(source), _get_ancestors(destination))
68+
for new_source, new_destination in ancestor_pairs:
69+
if _implied_relationship_is_allowed(new_source, new_destination):
70+
if not any(
71+
r.destination is new_destination
72+
and r.description == relationship.description
73+
for r in new_source.get_efferent_relationships()
74+
):
75+
_clone_relationship(relationship, new_source, new_destination)
76+
77+
78+
def _implied_relationship_is_allowed(source: Element, destination: Element):
79+
return source not in _get_ancestors(
80+
destination
81+
) and destination not in _get_ancestors(source)
82+
83+
84+
def _get_ancestors(element: Element, include_self=True) -> List[Element]:
85+
"""Get the ancestors of an element."""
86+
result = []
87+
current = element
88+
while current is not None:
89+
result.append(current)
90+
current = None if isinstance(current, SoftwareSystem) else current.parent
91+
if not include_self:
92+
result.remove(element)
93+
return result
94+
95+
96+
def _clone_relationship(
97+
relationship: Relationship, new_source: Element, new_destination: Element
98+
) -> Relationship:
99+
print(f"{new_source.name}->{new_destination.name}")
100+
return new_source.add_relationship(
101+
destination=new_destination,
102+
description=relationship.description,
103+
technology=relationship.technology,
104+
interaction_style=relationship.interaction_style,
105+
tags=relationship.tags,
106+
properties=relationship.properties,
107+
create_implied_relationships=False,
108+
)

src/structurizr/model/model.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818

1919
import logging
20-
from typing import Iterable, List, Optional, Set, ValuesView
20+
from typing import Callable, Iterable, List, Optional, Set, ValuesView
2121

2222
from pydantic import Field
2323

@@ -85,7 +85,9 @@ class Model(AbstractBase):
8585
to this model.
8686
deployment_nodes (set of DeploymentNode): The set of deployment nodes belonging
8787
to this model.
88-
88+
implied_relationship_strategy: Function used to create implied relationships.
89+
By default (or if set to `None`), no implied relationships will be created.
90+
See `implied_relationship_strategies.py` for more details.
8991
"""
9092

9193
def __init__(
@@ -94,6 +96,7 @@ def __init__(
9496
people: Optional[Iterable[Person]] = (),
9597
software_systems: Iterable[SoftwareSystem] = (),
9698
deployment_nodes: Iterable[DeploymentNode] = (),
99+
implied_relationship_strategy: Optional[Callable[[Relationship], None]] = None,
97100
**kwargs,
98101
) -> None:
99102
"""
@@ -106,6 +109,7 @@ def __init__(
106109
super().__init__(**kwargs)
107110
self.enterprise = enterprise
108111
self.deployment_nodes = set(deployment_nodes)
112+
self.implied_relationship_strategy = implied_relationship_strategy
109113
# TODO: simply iterate attributes
110114
self._elements_by_id = {}
111115
self._relationships_by_id = {}
@@ -151,7 +155,7 @@ def hydrate(cls, model_io: ModelIO) -> "Model":
151155
relationship.destination = model.get_element(
152156
relationship.destination_id
153157
)
154-
model.add_relationship(relationship)
158+
model.add_relationship(relationship, create_implied_relationships=False)
155159

156160
return model
157161

@@ -282,7 +286,11 @@ def add_deployment_node(
282286
return deployment_node
283287

284288
def add_relationship(
285-
self, relationship: Relationship = None, **kwargs
289+
self,
290+
relationship: Relationship = None,
291+
*,
292+
create_implied_relationships: bool = True,
293+
**kwargs,
286294
) -> Optional[Relationship]:
287295
"""
288296
Add a relationship to the model.
@@ -292,6 +300,9 @@ def add_relationship(
292300
`Relationship` instance or
293301
**kwargs: Provide keyword arguments for instantiating a `Relationship`
294302
(recommended).
303+
create_implied_relationships (bool, optional): If `True` (default) then use
304+
the `implied_relationship_strategy` to create relationships implied by
305+
this one. See `implied_relationship_strategies.py` for details.
295306
296307
Returns:
297308
Relationship: Either the same or a new instance, depending on arguments.
@@ -306,7 +317,7 @@ def add_relationship(
306317
if relationship is None:
307318
relationship = Relationship(**kwargs)
308319
# Check
309-
if self._add_relationship(relationship):
320+
if self._add_relationship(relationship, create_implied_relationships):
310321
return relationship
311322
else:
312323
return
@@ -364,7 +375,9 @@ def _add_element(self, element: Element) -> None:
364375
element.set_model(self)
365376
self._id_generator.found(element.id)
366377

367-
def _add_relationship(self, relationship: Relationship) -> bool:
378+
def _add_relationship(
379+
self, relationship: Relationship, create_implied_relationships: bool
380+
) -> bool:
368381
if relationship in self.get_relationships():
369382
return True
370383
if not relationship.id:
@@ -379,8 +392,16 @@ def _add_relationship(self, relationship: Relationship) -> bool:
379392
f"{relationship} has the same ID as "
380393
f"{self._relationships_by_id[relationship.id]}."
381394
)
382-
relationship.source.add_relationship(relationship)
395+
relationship.source.add_relationship(
396+
relationship, create_implied_relationships=False
397+
)
383398
self._add_relationship_to_internal_structures(relationship)
399+
400+
if (
401+
create_implied_relationships
402+
and self.implied_relationship_strategy is not None
403+
):
404+
self.implied_relationship_strategy(relationship)
384405
return True
385406

386407
def _add_relationship_to_internal_structures(self, relationship: Relationship):

src/structurizr/model/relationship.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ def destination_id(self) -> str:
117117

118118
return self._destination_id
119119

120+
@property
121+
def interaction_style(self) -> str:
122+
"""Return the interaction style of the relationship based on its tags."""
123+
return (
124+
InteractionStyle.Synchronous
125+
if Tags.SYNCHRONOUS in self.tags
126+
else InteractionStyle.Asynchronous
127+
)
128+
120129
@classmethod
121130
def hydrate(cls, relationship_io: RelationshipIO) -> "Relationship":
122131
""""""

0 commit comments

Comments
 (0)