Skip to content
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
30 changes: 22 additions & 8 deletions src/Runtime.Engine/RuleEngine/GraphRuleEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,18 @@ private async Task ValidateTarget(IOctoSession session, IRepositoryDataSource re
}
}

if (changeDelta > 0)
if (changeDelta > 0 &&
(multiplicity == MultiplicitiesDto.One ||
multiplicity == MultiplicitiesDto.ZeroOrOne))
{
if (currentMultiplicity == CurrentMultiplicity.One &&
(multiplicity == MultiplicitiesDto.One ||
multiplicity == MultiplicitiesDto.ZeroOrOne))
// Validate the RESULTING cardinality after applying the batch, not just the current
// DB state. A to-one role must end up with at most one association. This is violated if:
// - one association already exists and we net-add (result >= 2)
// - the state is already corrupt (Many) and we net-add (stays > 1)
// - no association exists yet but the batch creates two or more (result >= 2)
if (currentMultiplicity == CurrentMultiplicity.One ||
currentMultiplicity == CurrentMultiplicity.Many ||
(currentMultiplicity == CurrentMultiplicity.Zero && changeDelta >= 2))
{
operationResult.AddMessage(MessageCodes.AssociationCardinalityViolationOnModification(
originFileResolver.Resolve(repositoryDataSource.TenantId), repositoryDataSource.TenantId,
Expand Down Expand Up @@ -367,11 +374,18 @@ private async Task ValidateOrigin(IOctoSession session, IRepositoryDataSource re
}
}

if (changeDelta > 0)
if (changeDelta > 0 &&
(multiplicity == MultiplicitiesDto.One ||
multiplicity == MultiplicitiesDto.ZeroOrOne))
{
if (currentMultiplicity == CurrentMultiplicity.One &&
(multiplicity == MultiplicitiesDto.One ||
multiplicity == MultiplicitiesDto.ZeroOrOne))
// Validate the RESULTING cardinality after applying the batch, not just the current
// DB state. A to-one role must end up with at most one association. This is violated if:
// - one association already exists and we net-add (result >= 2)
// - the state is already corrupt (Many) and we net-add (stays > 1)
// - no association exists yet but the batch creates two or more (result >= 2)
if (currentMultiplicity == CurrentMultiplicity.One ||
currentMultiplicity == CurrentMultiplicity.Many ||
(currentMultiplicity == CurrentMultiplicity.Zero && changeDelta >= 2))
{
operationResult.AddMessage(MessageCodes.AssociationCardinalityViolationOnModification(
originFileResolver.Resolve(repositoryDataSource.TenantId), repositoryDataSource.TenantId,
Expand Down
62 changes: 62 additions & 0 deletions tests/Runtime.Engine.Tests/RuleEngine/GraphRuleEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,68 @@ await engine.ValidateAsync(session, dataSource,
Assert.Equal(14, operationResult.Messages[0].MessageNumber);
}

[Fact]
public async Task ValidateAsync_CreateMultipleParentsFromEmpty_AddsError()
{
// Bug 1922: a to-one role (ParentChild outbound = Parent) must not gain multiple targets
// in a single mutation even when no association exists yet (currentMultiplicity == Zero).
// Arrange
var (engine, session, dataSource, operationResult, originFileResolver) = CreateTestObjects();
var origin = CreateEntity(TestCkIds.RtCkCountryTypeId);
var target1 = CreateEntity(TestCkIds.RtCkContinentTypeId);
var target2 = CreateEntity(TestCkIds.RtCkContinentTypeId);

SetupEntityRetrieval(dataSource, [origin, target1, target2]);
SetupEmptyAssociations(dataSource);
// Note: no SetupMultiplicity → currentMultiplicity defaults to Zero (node has no parent yet).

// Act — create two parents in one batch
await engine.ValidateAsync(session, dataSource,
[EntityUpdateInfo<RtEntity>.CreateUpdate(origin.ToRtEntityId(), origin)],
[
AssociationUpdateInfo.CreateInsert(origin.ToRtEntityId(), target1.ToRtEntityId(),
SystemCkIds.RtCkParentChildRoleId),
AssociationUpdateInfo.CreateInsert(origin.ToRtEntityId(), target2.ToRtEntityId(),
SystemCkIds.RtCkParentChildRoleId)
],
originFileResolver, operationResult);

// Assert
Assert.Single(operationResult.Messages);
Assert.True(operationResult.HasFatalErrors);
Assert.Equal(14, operationResult.Messages[0].MessageNumber);
}

[Fact]
public async Task ValidateAsync_AddParentToExistingParent_AddsError()
{
// Bug 1922: adding a second parent to a node that already has one (currentMultiplicity == One)
// must be rejected for the to-one ParentChild role.
// Arrange
var (engine, session, dataSource, operationResult, originFileResolver) = CreateTestObjects();
var origin = CreateEntity(TestCkIds.RtCkCountryTypeId);
var newParent = CreateEntity(TestCkIds.RtCkContinentTypeId);

SetupEntityRetrieval(dataSource, [origin, newParent]);
SetupEmptyAssociations(dataSource);
SetupMultiplicity(dataSource, origin, SystemCkIds.RtCkParentChildRoleId, GraphDirections.Outbound,
CurrentMultiplicity.One);

// Act — add a single additional parent without removing the existing one
await engine.ValidateAsync(session, dataSource,
[EntityUpdateInfo<RtEntity>.CreateUpdate(origin.ToRtEntityId(), origin)],
[
AssociationUpdateInfo.CreateInsert(origin.ToRtEntityId(), newParent.ToRtEntityId(),
SystemCkIds.RtCkParentChildRoleId)
],
originFileResolver, operationResult);

// Assert
Assert.Single(operationResult.Messages);
Assert.True(operationResult.HasFatalErrors);
Assert.Equal(14, operationResult.Messages[0].MessageNumber);
}

#endregion

#region Entity Deletion Tests
Expand Down