8000 Add `common_parent` relationship attribute by gmazoyer · Pull Request #6626 · opsmill/infrahub · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add common_parent relationship attribute #6626

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/infrahub/core/constraint/node/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ async def check(
relationship_manager: RelationshipManager = getattr(node, relationship_name)
await relationship_manager.fetch_relationship_ids(db=db, force_refresh=True)
for relationship_constraint in self.relationship_manager_constraints:
await relationship_constraint.check(relm=relationship_manager, node_schema=node.get_schema())
await relationship_constraint.check(
relm=relationship_manager, node_schema=node.get_schema(), node=node
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we're adding the node as an argument here and we can access the schema with node.get_schema() the node_schema parameter gets a bit redundant now. But we can perhaps consider removing that as a cleanup step.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will do that in a dedicated PR as it needs to change some code in other constraint checkers.

)
9 changes: 9 additions & 0 deletions backend/infrahub/core/node/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,3 +962,12 @@ def validate_relationships(self) -> None:
for name in self._relationships:
relm: RelationshipManager = getattr(self, name)
relm.validate()

async def get_parent_relationship_peer(self, db: InfrahubDatabase, name: str) -> Node | None:
"""When a node has a parent relationship of a given name, this method returns the peer of that relationship."""
relationship = self.get_schema().get_relationship(name=name)
if relationship.kind != RelationshipKind.PARENT:
raise ValueError(f"Relationship '{name}' is not of kind 'parent'")

relm: RelationshipManager = getattr(self, name)
return await relm.get_peer(db=db)
19 changes: 10 additions & 9 deletions backend/infrahub/core/relationship/constraints/count.py
5D39
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from infrahub.core import registry
from infrahub.core.branch import Branch
from infrahub.core.constants import RelationshipCardinality, RelationshipDirection
from infrahub.core.node import Node
from infrahub.core.query.relationship import RelationshipCountPerNodeQuery
from infrahub.core.schema import MainSchemaTypes
from infrahub.database import InfrahubDatabase
Expand All @@ -25,7 +26,7 @@ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
self.db = db
self.branch = branch

async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None: # noqa: ARG002
async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
branch = await registry.get_branch(db=self.db) if not self.branch else self.branch

# NOTE adding resolve here because we need to retrieve the real ID
Expand Down Expand Up @@ -63,7 +64,7 @@ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -

query = await RelationshipCountPerNodeQuery.init(
db=self.db,
node_ids=[node.uuid for node in nodes_to_validate],
node_ids=[n.uuid for n in nodes_to_validate],
identifier=relm.schema.identifier,
direction=relm.schema.direction.neighbor_direction,
branch=branch,
Expand All @@ -74,14 +75,14 @@ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -
# Need to adjust the number based on what we will add / remove
# +1 for max_count
# -1 for min_count
for node in nodes_to_validate:
if node.max_count and count_per_peer[node.uuid] + 1 > node.max_count:
for node_to_validate in nodes_to_validate:
if node_to_validate.max_count and count_per_peer[node_to_validate.uuid] + 1 > node_to_validate.max_count:
raise ValidationError(
f"Node {node.uuid} has {count_per_peer[node.uuid] + 1} peers "
f"for {relm.schema.identifier}, maximum of {node.max_count} allowed",
f"Node {node_to_validate.uuid} has {count_per_peer[node_to_validate.uuid] + 1} peers "
f"for {relm.schema.identifier}, maximum of {node_to_validate.max_count} allowed",
)
if node.min_count and count_per_peer[node.uuid] - 1 < node.min_count:
if node_to_validate.min_count and count_per_peer[node_to_validate.uuid] - 1 < node_to_validate.min_count:
raise ValidationError(
f"Node {node.uuid} has {count_per_peer[node.uuid] - 1} peers "
f"for {relm.schema.identifier}, no fewer than {node.min_count} allowed",
f"Node {node_to_validate.uuid} has {count_per_peer[node_to_validate.uuid] - 1} peers "
f"for {relm.schema.identifier}, no fewer than {node_to_validate.min_count} allowed",
)
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from abc import ABC, abstractmethod

from infrahub.core.node import Node
from infrahub.core.schema import MainSchemaTypes

from ..model import RelationshipManager


class RelationshipManagerConstraintInterface(ABC):
@abstractmethod
async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None: ...
async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: ...
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from infrahub.core import registry
from infrahub.core.branch import Branch
from infrahub.core.constants import RelationshipCardinality
from infrahub.core.node import Node
from infrahub.core.query.node import NodeListGetInfoQuery
from infrahub.core.schema import MainSchemaTypes
from infrahub.core.schema.generic_schema import GenericSchema
Expand All @@ -26,7 +27,7 @@ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
self.db = db
self.branch = branch

async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None: # noqa: ARG002
async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
branch = await registry.get_branch(db=self.db) if not self.branch else self.branch
peer_schema = registry.schema.get(name=relm.schema.peer, branch=branch, duplicate=False)
if isinstance(peer_schema, GenericSchema):
Expand Down
56 changes: 56 additions & 0 deletions backend/infrahub/core/relationship/constraints/peer_parent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Mapping

from infrahub.exceptions import ValidationError

from .interface import RelationshipManagerConstraintInterface

if TYPE_CHECKING:
from infrahub.core.branch import Branch
from infrahub.core.node import Node
from infrahub.core.schema import MainSchemaTypes
from infrahub.database import InfrahubDatabase

from ..model import RelationshipManager


class RelationshipPeerParentConstraint(RelationshipManagerConstraintInterface):
def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
self.db = db
self.branch = branch

async def _check_relationship_peers_parent(
self, relm: RelationshipManager, parent_rel_name: str, node: Node, peers: Mapping[str, Node]
) -> None:
"""Validate that all peers of a given `relm` have the same parent for the given `relationship_name`."""
node_parent = await node.get_parent_relationship_peer(db=self.db, name=parent_rel_name)
if not node_parent:
# If the schema is properly validated we are not expecting this to happen
raise ValidationError(f"Node {node.id} ({node.get_kind()}) does not have a parent peer")

parents: set[str] = {node_parent.id}
for peer in peers.values():
parent = await peer.get_parent_relationship_peer(db=self.db, name=parent_rel_name)
if not parent:
# If the schema is properly validated we are not expecting this to happen
raise ValidationError(f"Peer {peer.id} ({peer.get_kind()}) does not have a parent peer")
parents.add(parent.id)

if len(parents) != 1:
raise ValidationError(
f"All the elements of the '{relm.name}' relationship on node {node.id} ({node.get_kind()}) must have the same parent "
"as the node"
)

async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
if not relm.schema.common_parent:
return

peers = await relm.get_peers(db=self.db)
if not peers:
return

await self._check_relationship_peers_parent(
relm=relm, parent_rel_name=relm.schema.common_parent, node=node, peers=peers
)
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async def _check_relationship_peers_relatives(
f"for their '{node.schema.kind}.{relationship_name}' relationship"
)

async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None:
async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
if relm.schema.cardinality != RelationshipCardinality.MANY or not relm.schema.common_relatives:
return

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
self.branch = branch
self.schema_branch = registry.schema.get_schema_branch(branch.name if branch else registry.default_branch)

async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None:
async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
if relm.name != "profiles" or not isinstance(node_schema, NodeSchema):
return

Expand Down
9 changes: 8 additions & 1 deletion backend/infrahub/core/schema/definitions/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,13 +754,20 @@ def to_dict(self) -> dict[str, Any]:
optional=True,
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
),
SchemaAttribute(
name="common_parent",
kind="Text",
optional=True,
description="Name of a parent relationship on the peer schema that must share the same related object with the object's parent.",
extra={"update": UpdateSupport.ALLOWED},
),
SchemaAttribute(
name="common_relatives",
kind="List",
internal_kind=str,
optional=True,
description="List of relationship names on the peer schema for which all objects must share the same set of peers.",
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
extra={"update": UpdateSupport.ALLOWED},
),
SchemaAttribute(
name="order_weight",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ class GeneratedRelationshipSchema(HashableModel):
description="Defines the maximum objects allowed on the other side of the relationship.",
json_schema_extra={"update": "validate_constraint"},
)
common_parent: str | None = Field(
default=None,
description="Name of a parent relationship on the peer schema that must share the same related object with the object's parent.",
json_schema_extra={"update": "allowed"},
)
common_relatives: list[str] | None = Field(
default=None,
description="List of relationship names on the peer schema for which all objects must share the same set of peers.",
json_schema_extra={"update": "validate_constraint"},
json_schema_extra={"update": "allowed"},
)
order_weight: int | None = Field(
default=None,
Expand Down
25 changes: 25 additions & 0 deletions backend/infrahub/core/schema/schema_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,28 @@ def validate_names(self) -> None:
):
raise ValueError(f"{node.kind}: {rel.name} isn't allowed as a relationship name.")

def _validate_common_parent(self, node: NodeSchema, rel: RelationshipSchema) -> None:
if not rel.common_parent:
return

peer_schema = self.get(name=rel.peer, duplicate=False)
if not node.has_parent_relationship:
raise ValueError(
f"{node.kind}: Relationship {rel.name!r} defines 'common_parent' but node does not have a parent relationship"
)

try:
parent_rel = peer_schema.get_relationship(name=rel.common_parent)
except ValueError as exc:
raise ValueError(
f"{node.kind}: Relationship {rel.name!r} defines 'common_parent' but '{rel.peer}.{rel.common_parent}' does not exist"
) from exc

if parent_rel.kind != RelationshipKind.PARENT:
raise ValueError(
f"{node.kind}: Relationship {rel.name!r} defines 'common_parent' but '{rel.peer}.{rel.common_parent} is not of kind 'parent'"
)

def validate_kinds(self) -> None:
for name in list(self.nodes.keys()):
node = self.get_node(name=name, duplicate=False)
Expand All @@ -997,6 +1019,9 @@ def validate_kinds(self) -> None:
raise ValueError(
f"{node.kind}: Relationship {rel.name!r} is referring an invalid peer {rel.peer!r}"
) from None

self._validate_common_parent(node=node, rel=rel)

if rel.common_relatives:
peer_schema = self.get(name=rel.peer, duplicate=False)
for common_relatives_rel_name in rel.common_relatives:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from ..node.grouped_uniqueness import NodeGroupedUniquenessConstraintDependency
from ..relationship_manager.count import RelationshipCountConstraintDependency
from ..relationship_manager.peer_kind import RelationshipPeerKindConstraintDependency
from ..relationship_manager.peer_parent import RelationshipPeerParentConstraintDependency
from ..relationship_manager.peer_relatives import RelationshipPeerRelativesConstraintDependency
from ..relationship_manager.profiles_kind import RelationshipProfilesKindConstraintDependency

Expand All @@ -19,6 +20,7 @@ def build(cls, context: DependencyBuilderContext) -> NodeConstraintRunner:
RelationshipPeerKindConstraintDependency.build(context=context),
RelationshipCountConstraintDependency.build(context=context),
RelationshipProfilesKindConstraintDependency.build(context=context),
RelationshipPeerParentConstraintDependency.build(context=context),
RelationshipPeerRelativesConstraintDependency.build(context=context),
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from infrahub.core.relationship.constraints.peer_parent import RelationshipPeerParentConstraint
from infrahub.dependencies.interface import DependencyBuilder, DependencyBuilderContext


class RelationshipPeerParentConstraintDependency(DependencyBuilder[RelationshipPeerParentConstraint]):
@classmethod
def build(cls, context: DependencyBuilderContext) -> RelationshipPeerParentConstraint:
return RelationshipPeerParentConstraint(db=context.db, branch=context.branch)
2 changes: 2 additions & 0 deletions backend/infrahub/dependencies/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .builder.constraint.node.uniqueness import NodeAttributeUniquenessConstraintDependency
from .builder.constraint.relationship_manager.count import RelationshipCountConstraintDependency
from .builder.constraint.relationship_manager.peer_kind import RelationshipPeerKindConstraintDependency
from .builder.constraint.relationship_manager.peer_parent import RelationshipPeerParentConstraintDependency
from .builder.constraint.relationship_manager.peer_relatives import RelationshipPeerRelativesConstraintDependency
from .builder.constraint.relationship_manager.profiles_kind import RelationshipProfilesKindConstraintDependency
from .builder.constraint.schema.aggregated import AggregatedSchemaConstraintsDependency
Expand Down Expand Up @@ -38,6 +39,7 @@ def build_component_registry() -> ComponentDependencyRegistry:
component_registry.track_dependency(RelationshipCountConstraintDependency)
component_registry.track_dependency(RelationshipProfilesKindConstraintDependency)
component_registry.track_dependency(RelationshipPeerKindConstraintDependency)
component_registry.track_dependency(RelationshipPeerParentConstraintDependency)
component_registry.track_dependency(RelationshipPeerRelativesConstraintDependency)
component_registry.track_dependency(NodeConstraintRunnerDependency)
component_registry.track_dependency(NodeDeleteValidatorDependency)
Expand Down
32 changes: 32 additions & 0 deletions backend/infrahub/graphql/mutations/relationship.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ async def mutate(
nodes = await _validate_peers(info=info, data=data)
await _validate_permissions(info=info, source_node=source, peers=nodes)
await _validate_peer_types(info=info, data=data, source_node=source, peers=nodes)
await _validate_peer_parents(info=info, data=data, source_node=source, peers=nodes)

# This has to be done after validating the permissions
await apply_external_context(graphql_context=graphql_context, context_input=context)
Expand Down Expand Up @@ -406,6 +407,37 @@ async def _validate_peer_types(
)


async def _validate_peer_parents(
info: GraphQLResolveInfo, data: RelationshipNodesInput, source_node: Node, peers: dict[str, Node]
) -> None:
relationship_name = str(data.name)
rel_schema = source_node.get_schema().get_relationship(name=relationship_name)
if not rel_schema.common_parent:
return

graphql_context: GraphqlContext = info.context

source_node_parent = await source_node.get_parent_relationship_peer(
db=graphql_context.db, name=rel_schema.common_parent
)
if not source_node_parent:
# If the schema is properly validated we are not expecting this to happen
raise ValidationError(f"Node {source_node.id} ({source_node.get_kind()!r}) does not have a parent peer")

parents: set[str] = {source_node_parent.id}
for peer in peers.values():
peer_parent = await peer.get_parent_relationship_peer(db=graphql_context.db, name=rel_schema.common_parent)
if not peer_parent:
# If the schema is properly validated we are not expecting this to happen
raise ValidationError(f"Peer {peer.id} ({peer.get_kind()!r}) does not have a parent peer")
parents.add(peer_parent.id)

if len(parents) > 1:
raise ValidationError(
f"Cannot relate {source_node.id!r} to '{relationship_name}' peers that do not have the same parent"
)


async def _collect_current_peers(
info: GraphQLResolveInfo, data: RelationshipNodesInput, source_node: Node
) -> dict[str, RelationshipPeerData]:
Expand Down
Loading
Loading
0