Skip to content
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

feat: Add restrictions to Transmissions reference hierarchy #1310

Merged
merged 41 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
87a1b32
--wip-- [skip ci]
oskogstad Oct 15, 2024
715894e
more test
oskogstad Oct 16, 2024
e944835
wat
oskogstad Oct 17, 2024
5dbc39d
f
oskogstad Oct 17, 2024
d4cdc58
wat
oskogstad Oct 17, 2024
04397cc
Merge branch 'main' into feat/restrict-related-transmission-tree
oskogstad Oct 17, 2024
4a15f72
what
oskogstad Oct 18, 2024
7a620e5
WIP
MagnusSandgren Oct 18, 2024
1579a00
cln
oskogstad Oct 18, 2024
d72b235
cln
oskogstad Oct 18, 2024
036cb88
fx
oskogstad Oct 18, 2024
392f6ae
fx 4 reel
oskogstad Oct 18, 2024
c124eae
Merge branch 'main' into feat/restrict-related-transmission-tree
oskogstad Oct 18, 2024
bf69c58
Add ReadOnlyCollectionExtensions.cs
MagnusSandgren Oct 21, 2024
1fdda82
Merge remote-tracking branch 'origin/feat/restrict-related-transmissi…
MagnusSandgren Oct 21, 2024
3ad8242
Add using in CreateDialogCommand.cs
MagnusSandgren Oct 21, 2024
5d11a69
remove old
oskogstad Oct 21, 2024
1bc997d
fx
oskogstad Oct 21, 2024
89d0898
tests
oskogstad Oct 21, 2024
56daa91
tst
oskogstad Oct 21, 2024
4f03fc5
fx msg
oskogstad Oct 21, 2024
445d747
grmr
oskogstad Oct 21, 2024
9014fbf
--wip-- [skip ci]
oskogstad Oct 21, 2024
0422932
Merge branch 'main' into feat/restrict-related-transmission-tree
oskogstad Oct 21, 2024
9ffda1e
chck
oskogstad Oct 21, 2024
711911a
sup
oskogstad Oct 21, 2024
21ea813
Merge branch 'main' into feat/restrict-related-transmission-tree
oskogstad Oct 22, 2024
19669d8
Add HierarchyTestNodeBuilder for ValidateReferenceHierarchy tests wit…
MagnusSandgren Oct 24, 2024
396edef
fx
oskogstad Oct 24, 2024
989a1a2
add more test, complex invalid, complex valid
oskogstad Oct 24, 2024
112afd2
Merge branch 'main' into feat/restrict-related-transmission-tree
oskogstad Oct 24, 2024
59c6fa2
wat why
oskogstad Oct 24, 2024
3dc3d8a
ff
oskogstad Oct 24, 2024
a0ada4f
nop
oskogstad Oct 24, 2024
6ca4aa5
nop
oskogstad Oct 24, 2024
a359062
prrr
oskogstad Oct 24, 2024
c0f43bd
improv mesage
oskogstad Oct 24, 2024
17d26d8
cm
oskogstad Oct 24, 2024
680f3b0
ws
oskogstad Oct 24, 2024
3d10f63
Some minor changes 🙏
MagnusSandgren Oct 25, 2024
d51eb21
Merge branch 'main' into feat/restrict-related-transmission-tree
oskogstad Oct 25, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using Digdir.Domain.Dialogporten.Domain.Common;

namespace Digdir.Domain.Dialogporten.Application.Common.Extensions;

internal static class ReadOnlyCollectionExtensions
{
private const int Cycle = int.MaxValue;

/// <summary>
/// Validates the reference hierarchy in a collection of entities, checking for depth, cyclic references, and width violations.
/// </summary>
/// <typeparam name="TEntity">The type of the entities in the collection.</typeparam>
/// <typeparam name="TKey">The type of the key used to identify entities.</typeparam>
/// <param name="entities">The collection of entities to validate.</param>
/// <param name="keySelector">A function to select the key for each entity.</param>
/// <param name="parentKeySelector">A function to select the parent key for each entity.</param>
/// <param name="propertyName">The name of the property being validated.</param>
/// <param name="maxDepth">The maximum allowed depth of the hierarchy. Default is 100.</param>
/// <param name="maxWidth">The maximum allowed width of the hierarchy. Default is 1.</param>
/// <returns>A list of <see cref="DomainFailure"/> objects representing any validation errors found.</returns>
/// <exception cref="InvalidOperationException">Thrown if an entity's parent key is not found in the collection.</exception>
/// <exception cref="InvalidOperationException">Thrown if an entity's <paramref name="keySelector"/> returns default <typeparamref name="TKey"/>.</exception>
public static List<DomainFailure> ValidateReferenceHierarchy<TEntity, TKey>(
this IReadOnlyCollection<TEntity> entities,
Func<TEntity, TKey> keySelector,
Func<TEntity, TKey?> parentKeySelector,
string propertyName,
int maxDepth = 100,
int maxWidth = 1)
where TKey : struct
{
entities.Select(keySelector).EnsureNonDefaultTKey();

var maxDepthViolation = maxDepth + 1;
var type = typeof(TEntity);
var errors = new List<DomainFailure>();

var invalidReferences = GetInvalidReferences(entities, keySelector, parentKeySelector);
if (invalidReferences.Count > 0)
{
var ids = $"[{string.Join(",", invalidReferences)}]";
errors.Add(new DomainFailure(propertyName,
$"Hierarchy reference violation found. " +
$"{type.Name} with the following referenced ids does not exist: {ids}."));

return errors;
}

var depthByKey = entities
.ToDictionary(keySelector)
.ToDepthByKey(keySelector, parentKeySelector);

var depthErrors = depthByKey
.Where(x => x.Value == maxDepthViolation)
.ToList();

var cycleErrors = depthByKey
.Where(x => x.Value == Cycle)
.ToList();

var widthErrors = entities
.Where(x => parentKeySelector(x) is not null)
.GroupBy(parentKeySelector)
.Where(x => x.Count() > maxWidth)
.ToList();

if (depthErrors.Count > 0)
{
var ids = $"[{string.Join(",", depthErrors.Select(x => x.Key))}]";
errors.Add(new DomainFailure(propertyName,
$"Hierarchy depth violation found. {type.Name} with the following " +
$"ids is at depth {maxDepthViolation}, exceeding the max allowed depth of {maxDepth}. " +
$"It, and all its referencing children is in violation of the depth constraint. {ids}."));
}

if (cycleErrors.Count > 0)
{
var firstTenFailedIds = cycleErrors.Take(10).Select(x => x.Key).ToList();
var cycleCutOffInfo = cycleErrors.Count > 10 ? " (showing first 10)" : string.Empty;

var joinedIds = $"[{string.Join(",", firstTenFailedIds)}]";
errors.Add(new DomainFailure(propertyName,
$"Hierarchy cyclic reference violation found. {type.Name} with the " +
$"following ids is part of a cyclic reference chain{cycleCutOffInfo}: {joinedIds}."));
}

if (widthErrors.Count > 0)
{
var ids = $"[{string.Join(",", widthErrors.Select(x => x.Key))}]";
errors.Add(new DomainFailure(propertyName,
$"Hierarchy width violation found. '{type.Name}' with the following " +
$"ids has to many referring {type.Name}, exceeding the max " +
$"allowed width of {maxWidth}: {ids}."));
}

return errors;
}

private static List<TKey> GetInvalidReferences<TEntity, TKey>(IReadOnlyCollection<TEntity> entities,
Func<TEntity, TKey> keySelector,
Func<TEntity, TKey?> parentKeySelector) where TKey : struct => entities
.Where(x => parentKeySelector(x).HasValue)
.Select(x => parentKeySelector(x)!.Value)
.Except(entities.Select(keySelector))
.ToList();

private static Dictionary<TKey, int> ToDepthByKey<TKey, TEntity>(
this Dictionary<TKey, TEntity> transmissionById,
Func<TEntity, TKey> keySelector,
Func<TEntity, TKey?> parentKeySelector)
where TKey : struct
{
var depthByKey = new Dictionary<TKey, int>();
var breadCrumbs = new HashSet<TKey>();
foreach (var (_, current) in transmissionById)
{
GetDepth(current, transmissionById, keySelector, parentKeySelector, depthByKey, breadCrumbs);
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

return depthByKey;
}

private static int GetDepth<TEntity, TKey>(TEntity current,
Dictionary<TKey, TEntity> entitiesById,
Func<TEntity, TKey> keySelector,
Func<TEntity, TKey?> parentKeySelector,
Dictionary<TKey, int> cachedDepthByVisited,
HashSet<TKey> breadCrumbs)
where TKey : struct
{
var key = keySelector(current);
if (breadCrumbs.Contains(key))
{
return Cycle;
}

if (cachedDepthByVisited.TryGetValue(key, out var cachedDepth))
{
return cachedDepth;
}

breadCrumbs.Add(key);
var parentKey = parentKeySelector(current);
var parentDepth = !parentKey.HasValue ? 0
: entitiesById.TryGetValue(parentKey.Value, out var parent)
? GetDepth(parent, entitiesById, keySelector, parentKeySelector, cachedDepthByVisited, breadCrumbs)
: throw new InvalidOperationException(
$"{nameof(entitiesById)} does not contain expected " +
$"key '{parentKey.Value}'.");

breadCrumbs.Remove(key);
return cachedDepthByVisited[key] = parentDepth == Cycle ? Cycle : ++parentDepth;
}

private static void EnsureNonDefaultTKey<TKey>(this IEnumerable<TKey> keys) where TKey : struct
{
if (keys.Any(key => EqualityComparer<TKey>.Default.Equals(key, default)))
{
throw new InvalidOperationException("All keys must be non-default.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions;
using Digdir.Domain.Dialogporten.Domain.Parties;
using Digdir.Library.Entity.Abstractions.Features.Identifiable;
using MediatR;
using OneOf;
using OneOf.Types;
Expand Down Expand Up @@ -70,6 +71,20 @@ public async Task<CreateDialogResult> Handle(CreateDialogCommand request, Cancel
}
CreateDialogEndUserContext(request, dialog);
await EnsureNoExistingUserDefinedIds(dialog, cancellationToken);

// Ensure transmissions have a UUIDv7 ID, needed for the transmission hierarchy validation.
foreach (var transmission in dialog.Transmissions)
{
transmission.Id = transmission.Id.CreateVersion7IfDefault();
}

_domainContext.AddErrors(dialog.Transmissions.ValidateReferenceHierarchy(
keySelector: x => x.Id,
parentKeySelector: x => x.RelatedTransmissionId,
propertyName: nameof(CreateDialogCommand.Transmissions),
maxDepth: 100,
maxWidth: 1));

await _db.Dialogs.AddAsync(dialog, cancellationToken);
var saveResult = await _unitOfWork.SaveChangesAsync(cancellationToken);
return saveResult.Match<CreateDialogResult>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions;
using Digdir.Domain.Dialogporten.Domain.Parties;
using Digdir.Library.Entity.Abstractions.Features.Identifiable;
using MediatR;
using Microsoft.EntityFrameworkCore;
using OneOf;
Expand Down Expand Up @@ -97,14 +98,26 @@ public async Task<UpdateDialogResult> Handle(UpdateDialogCommand request, Cancel
return new BadRequest($"Entity '{nameof(DialogEntity)}' with key '{request.Id}' is removed, and cannot be updated.");
}

// Ensure transmissions have a UUIDv7 ID, needed for the transmission hierarchy validation.
foreach (var transmission in request.Dto.Transmissions)
{
transmission.Id = transmission.Id.CreateVersion7IfDefault();
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

// Update primitive properties
_mapper.Map(request.Dto, dialog);
ValidateTimeFields(dialog);

await AppendActivity(dialog, request.Dto, cancellationToken);

await AppendTransmission(dialog, request.Dto, cancellationToken);
VerifyTransmissionRelations(dialog);

_domainContext.AddErrors(dialog.Transmissions.ValidateReferenceHierarchy(
keySelector: x => x.Id,
parentKeySelector: x => x.RelatedTransmissionId,
propertyName: nameof(UpdateDialogDto.Transmissions),
maxDepth: 100,
maxWidth: 1));
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

VerifyActivityTransmissionRelations(dialog);

Expand Down Expand Up @@ -270,32 +283,6 @@ private async Task AppendTransmission(DialogEntity dialog, UpdateDialogDto dto,
_db.DialogTransmissions.AddRange(newDialogTransmissions);
}

private void VerifyTransmissionRelations(DialogEntity dialog)
{
var relatedTransmissionIds = dialog.Transmissions
.Where(x => x.RelatedTransmissionId is not null)
.Select(x => x.RelatedTransmissionId)
.ToList();

if (relatedTransmissionIds.Count == 0)
{
return;
}

var transmissionIds = dialog.Transmissions.Select(x => x.Id).ToList();

var invalidRelatedTransmissionIds = relatedTransmissionIds
.Where(id => !transmissionIds.Contains(id!.Value))
.ToList();

if (invalidRelatedTransmissionIds.Count != 0)
{
_domainContext.AddError(
nameof(UpdateDialogDto.Transmissions),
$"Invalid '{nameof(DialogTransmission.RelatedTransmissionId)}, entity '{nameof(DialogTransmission)}' with the following key(s) does not exist: ({string.Join(", ", invalidRelatedTransmissionIds)}).");
}
}

private IEnumerable<DialogApiAction> CreateApiActions(IEnumerable<UpdateDialogDialogApiActionDto> creatables)
{
return creatables.Select(x =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Content;
using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations;
using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Create;
using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;
using Digdir.Domain.Dialogporten.Domain;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions;
Expand Down Expand Up @@ -101,4 +100,27 @@ public async Task Cannot_Create_Transmission_Embeddable_Content_With_Http_Url()
validationError.Errors.First().ErrorMessage.Should().Contain("HTTPS");

}

oskogstad marked this conversation as resolved.
Show resolved Hide resolved
[Fact]
public async Task Can_Create_Related_Transmission_With_Null_Id()
{
// Arrange
var createCommand = DialogGenerator.GenerateSimpleFakeDialog();
var transmissions = DialogGenerator.GenerateFakeDialogTransmissions(2);

transmissions[0].RelatedTransmissionId = transmissions[1].Id;

// This test assures that the Create-handler will use CreateVersion7IfDefault
// on all transmissions before validating the hierarchy.
transmissions[0].Id = null;

createCommand.Transmissions = transmissions;

// Act
var response = await Application.Send(createCommand);

// Assert
response.TryPickT0(out var success, out _).Should().BeTrue();
success.Should().NotBeNull();
}
}
Loading
Loading