From 9baf04026e8896b66e7cb0d350982b08d249d966 Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 7 Jul 2025 14:53:42 +0200 Subject: [PATCH] V16: Siblings endpoints (#19657) * PoC implementation * Move to controller base * Implement solution that seems worse, but works better * Don't require parent key in repository method * Fix typos * Add siblings for data type, media type and media * Add endpoint for template * Add DocumentType and DocumentBlueprint controllers * Fix naming * Fix case if siblings are under root * Take item ordering into account not all entities are ordered by sort order * Add default implementation * Fix parentkey * Add tests * Format optimizations for split view * Add test covered requirement to description * Cover positive case and make test case output more readable * reduce allocations * Clarify test --------- Co-authored-by: Migaroez --- .../Tree/SiblingsDataTypeTreeController.cs | 19 ++++ .../Tree/SiblingsDocumentTreeController.cs | 40 ++++++++ ...SiblingsDocumentBlueprintTreeController.cs | 24 +++++ .../SiblingsDocumentTypeTreeController.cs | 23 +++++ .../Media/Tree/SiblingsMediaTreeController.cs | 29 ++++++ .../Tree/SiblingsMediaTypeTreeController.cs | 19 ++++ .../Tree/SiblingsTemplateTreeController.cs | 23 +++++ .../Tree/EntityTreeControllerBase.cs | 19 +++- .../Repositories/IEntityRepository.cs | 11 +++ src/Umbraco.Core/Services/EntityService.cs | 33 +++++++ src/Umbraco.Core/Services/IEntityService.cs | 16 ++++ .../Persistence/NPocoSqlExtensions.cs | 8 ++ .../Implement/EntityRepository.cs | 82 ++++++++++++++++ .../Services/EntityServiceTests.cs | 94 ++++++++++++++++++- .../Services/EntityServiceTests.cs | 38 ++++++++ 15 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs new file mode 100644 index 0000000000..253d32e3e8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree; + +public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase +{ + public SiblingsDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService) + : base(entityService, dataTypeService) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + => GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs new file mode 100644 index 0000000000..3fec79bf36 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs @@ -0,0 +1,40 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree; + +[ApiVersion("1.0")] +public class SiblingsDocumentTreeController : DocumentTreeControllerBase +{ + public SiblingsDocumentTreeController( + IEntityService entityService, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + IPublicAccessService publicAccessService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IDocumentPresentationFactory documentPresentationFactory) + : base( + entityService, + userStartNodeEntitiesService, + dataTypeService, + publicAccessService, + appCaches, + backofficeSecurityAccessor, + documentPresentationFactory) + { + } + + [HttpGet("siblings")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + => GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs new file mode 100644 index 0000000000..ac5578155f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentBlueprint.Tree; + +public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeControllerBase +{ + public SiblingsDocumentBlueprintTreeController(IEntityService entityService, IDocumentPresentationFactory documentPresentationFactory) + : base(entityService, documentPresentationFactory) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after) => + GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs new file mode 100644 index 0000000000..7bb9c26358 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentType.Tree; + +public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase +{ + public SiblingsDocumentTypeTreeController(IEntityService entityService, IContentTypeService contentTypeService) + : base(entityService, contentTypeService) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after) => + GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs new file mode 100644 index 0000000000..f5708fa638 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree; + +public class SiblingsMediaTreeController : MediaTreeControllerBase +{ + public SiblingsMediaTreeController( + IEntityService entityService, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IMediaPresentationFactory mediaPresentationFactory) + : base(entityService, userStartNodeEntitiesService, dataTypeService, appCaches, backofficeSecurityAccessor, mediaPresentationFactory) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + => GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs new file mode 100644 index 0000000000..1482788b57 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.MediaType.Tree; + +public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase +{ + public SiblingsMediaTypeTreeController(IEntityService entityService, IMediaTypeService mediaTypeService) + : base(entityService, mediaTypeService) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + => GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs new file mode 100644 index 0000000000..ed27092ecb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Template.Tree; + +public class SiblingsTemplateTreeController : TemplateTreeControllerBase +{ + public SiblingsTemplateTreeController(IEntityService entityService) + : base(entityService) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after) => + GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs index bf86b852ed..13bbe9bc2b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -44,6 +44,23 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB return Task.FromResult>>(Ok(result)); } + protected Task>> GetSiblings(Guid target, int before, int after) + { + IEntitySlim[] siblings = EntityService.GetSiblings(target, ItemObjectType, before, after, ItemOrdering).ToArray(); + if (siblings.Length == 0) + { + return Task.FromResult>>(NotFound()); + } + + IEntitySlim? entity = siblings.FirstOrDefault(); + Guid? parentKey = entity?.ParentId > 0 + ? EntityService.GetKey(entity.ParentId, ItemObjectType).Result + : Constants.System.RootKey; + + TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings); + return Task.FromResult>>(Ok(treeItemsViewModels)); + } + protected virtual async Task>> GetAncestors(Guid descendantKey, bool includeSelf = true) { IEntitySlim[] ancestorEntities = await GetAncestorEntitiesAsync(descendantKey, includeSelf); diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index 477f3e5c50..cdf05ca5aa 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -19,6 +19,17 @@ public interface IEntityRepository : IRepository IEnumerable GetAll(Guid objectType, params Guid[] keys); + /// + /// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. + /// + /// The object type key of the entities. + /// The key of the target entity whose siblings are to be retrieved. + /// The number of siblings to retrieve before the target entity. + /// The number of siblings to retrieve after the target entity. + /// The ordering to apply to the siblings. + /// Enumerable of sibling entities. + IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) => []; + /// /// Gets entities for a query /// diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index f2fe2eefd8..0a284bdebf 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -318,6 +318,39 @@ public class EntityService : RepositoryService, IEntityService return children; } + /// + public IEnumerable GetSiblings( + Guid key, + UmbracoObjectTypes objectType, + int before, + int after, + Ordering? ordering = null) + { + if (before < 0) + { + throw new ArgumentOutOfRangeException(nameof(before), "The 'before' parameter must be greater than or equal to 0."); + } + + if (after < 0) + { + throw new ArgumentOutOfRangeException(nameof(after), "The 'after' parameter must be greater than or equal to 0."); + } + + ordering ??= new Ordering("sortOrder"); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IEnumerable siblings = _entityRepository.GetSiblings( + objectType.GetGuid(), + key, + before, + after, + ordering); + + scope.Complete(); + return siblings; + } + /// public virtual IEnumerable GetDescendants(int id) { diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 964ec9f502..6652062ac0 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -170,6 +170,22 @@ public interface IEntityService /// The object type of the parent. IEntitySlim? GetParent(int id, UmbracoObjectTypes objectType); + /// + /// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. + /// + /// The key of the target entity whose siblings are to be retrieved. + /// The object type key of the entities. + /// The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0. + /// The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0. + /// The ordering to apply to the siblings. + /// Enumerable of sibling entities. + IEnumerable GetSiblings( + Guid key, + UmbracoObjectTypes objectType, + int before, + int after, + Ordering? ordering = null) => []; + /// /// Gets the children of an entity. /// diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs index 80f2bca778..d2c7aa87ff 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs @@ -1298,6 +1298,14 @@ namespace Umbraco.Extensions #region Utilities + public static Sql AppendSubQuery(this Sql sql, Sql subQuery, string alias) + { + // Append the subquery as a derived table with an alias + sql.Append("(").Append(subQuery.SQL, subQuery.Arguments).Append($") AS {alias}"); + + return sql; + } + private static string[] GetColumns(this Sql sql, string? tableAlias = null, string? referenceName = null, Expression>[]? columnExpressions = null, bool withAlias = true, bool forInsert = false) { PocoData? pd = sql.SqlContext.PocoDataFactory.ForType(typeof (TDto)); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index 8ff1fe381b..5e107bd71d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -145,6 +145,59 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return entity; } + /// + public IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) + { + // Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough + // without us also having to do a nested query for the parent ID too. + Sql parentIdQuery = Sql() + .Select(x => x.ParentId) + .From() + .Where(x => x.UniqueId == targetKey); + var parentId = Database.ExecuteScalar(parentIdQuery); + + Sql orderingSql = Sql(); + ApplyOrdering(ref orderingSql, ordering); + + // Get all children of the parent node which is not trashed, ordered by SortOrder, and assign each a row number. + // These row numbers are important, we need them to select the "before" and "after" siblings of the target node. + Sql rowNumberSql = Sql() + .Select($"ROW_NUMBER() OVER ({orderingSql.SQL}) AS rn") + .AndSelect(n => n.UniqueId) + .From() + .Where(x => x.ParentId == parentId && x.Trashed == false); + + // Find the specific row number of the target node. + // We need this to determine the bounds of the row numbers to select. + Sql targetRowSql = Sql() + .Select("rn") + .From().AppendSubQuery(rowNumberSql, "Target") + .Where(x => x.UniqueId == targetKey, "Target"); + + // We have to reuse the target row sql arguments, however, we also need to add the "before" and "after" values to the arguments. + // If we try to do this directly in the params array it'll consider the initial argument array as a single argument. + IEnumerable beforeArguments = targetRowSql.Arguments.Concat([before]); + IEnumerable afterArguments = targetRowSql.Arguments.Concat([after]); + + // Select the UniqueId of nodes which row number is within the specified range of the target node's row number. + Sql? mainSql = Sql() + .Select("UniqueId") + .From().AppendSubQuery(rowNumberSql, "NumberedNodes") + .Where($"rn >= ({targetRowSql.SQL}) - @3", beforeArguments.ToArray()) + .Where($"rn <= ({targetRowSql.SQL}) + @3", afterArguments.ToArray()) + .OrderBy("rn"); + + List? keys = Database.Fetch(mainSql); + + if (keys is null || keys.Count == 0) + { + return []; + } + + return PerformGetAll(objectType, ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); + } + + public IEntitySlim? Get(Guid key, Guid objectTypeId) { var isContent = objectTypeId == Constants.ObjectTypes.Document || @@ -216,6 +269,20 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return GetEntities(sql, isContent, isMedia, isMember); } + private IEnumerable PerformGetAll( + Guid objectType, + Ordering ordering, + Action>? filter = null) + { + var isContent = objectType == Constants.ObjectTypes.Document || + objectType == Constants.ObjectTypes.DocumentBlueprint; + var isMedia = objectType == Constants.ObjectTypes.Media; + var isMember = objectType == Constants.ObjectTypes.Member; + + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, ordering, filter); + return GetEntities(sql, isContent, isMedia, isMember); + } + public IEnumerable GetAllPaths(Guid objectType, params int[]? ids) => ids?.Any() ?? false ? PerformGetAllPaths(objectType, sql => sql.WhereIn(x => x.NodeId, ids.Distinct())) @@ -452,6 +519,21 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return AddGroupBy(isContent, isMedia, isMember, sql, true); } + protected Sql GetFullSqlForEntityType( + bool isContent, + bool isMedia, + bool isMember, + Guid objectType, + Ordering ordering, + Action>? filter) + { + Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType }); + AddGroupBy(isContent, isMedia, isMember, sql, false); + ApplyOrdering(ref sql, ordering); + + return sql; + } + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, bool isCount = false) => GetBase(isContent, isMedia, isMember, filter, [], isCount); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index 8d33039141..d1031e3b2c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -8,7 +8,7 @@ using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.ContentTypeEditing; using Umbraco.Cms.Infrastructure.Persistence; -using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; @@ -931,6 +931,98 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest } + [Test] + public void EntityService_Siblings_ReturnsExpectedSiblings() + { + var children = CreateSiblingsTestData(); + + var taget = children[1]; + + var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[1].Key); + Assert.IsTrue(result[2].Key == children[2].Key); + } + + [Test] + public void EntityService_Siblings_SkipsTrashedEntities() + { + var children = CreateSiblingsTestData(); + + var trash = children[1]; + ContentService.MoveToRecycleBin(trash); + + var taget = children[2]; + var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsFalse(result.Any(x => x.Key == trash.Key)); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[2].Key); + Assert.IsTrue(result[2].Key == children[3].Key); + } + + [Test] + public void EntityService_Siblings_RespectsOrdering() + { + var children = CreateSiblingsTestData(); + + // Order the children by name to ensure the ordering works when differing from the default sort order, the name is a GUID. + children = children.OrderBy(x => x.Name).ToList(); + + var taget = children[1]; + var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1, Ordering.By(nameof(NodeDto.Text))).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[1].Key); + Assert.IsTrue(result[2].Key == children[2].Key); + } + + [Test] + public void EntityService_Siblings_IgnoresOutOfBoundsLower() + { + var children = CreateSiblingsTestData(); + + var taget = children[1]; + var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 100, 1).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[1].Key); + Assert.IsTrue(result[2].Key == children[2].Key); + } + + [Test] + public void EntityService_Siblings_IgnoresOutOfBoundsUpper() + { + var children = CreateSiblingsTestData(); + + var taget = children[^2]; + var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 100).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result[^1].Key == children[^1].Key); + Assert.IsTrue(result[^2].Key == children[^2].Key); + Assert.IsTrue(result[^3].Key == children[^3].Key); + } + + private List CreateSiblingsTestData() + { + var contentType = ContentTypeService.Get("umbTextpage"); + + var root = ContentBuilder.CreateSimpleContent(contentType); + ContentService.Save(root); + + var children = new List(); + + for (int i = 0; i < 10; i++) + { + var child = ContentBuilder.CreateSimpleContent(contentType, Guid.NewGuid().ToString(), root); + ContentService.Save(child); + children.Add(child); + } + + return children; + } + private static bool _isSetup; private int _folderId; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs new file mode 100644 index 0000000000..e333267274 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class EntityServiceTests +{ + [TestCase(1, 1, false, TestName = "Siblings_Index_Validation_Valid")] + [TestCase(-1, 1, true, TestName = "Siblings_Index_Validation_InvalidBefore")] + [TestCase(1, -1, true, TestName = "Siblings_Index_Validation_InvalidAfter")] + [TestCase(-1, -1, true, TestName = "Siblings_Index_Validation_InvalidBeforeAndAfter")] + public void Siblings_Index_Validation(int before, int after, bool shouldThrow) + { + var sut = CreateEntityService(); + + if (shouldThrow) + { + Assert.Throws(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after)); + } + } + + + private EntityService CreateEntityService() => + new( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); +}