Retrieves item counts before and after the target for sibling endpoints and returns in API response (#19844)

* Added user start node restrictions to sibling endpoints.

* Further integration tests.

* Tidy up.

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert previous update.

* Retrieves item counts before and after the target for sibling endpoints and returns in API response.

* Applied previous update correctly.

* Removed blank line.

* Fix build and test asserts following merge.

* Update OpenApi.json.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Andy Butland
2025-08-05 11:14:59 +02:00
committed by GitHub
parent 20254f0bbc
commit fcba10aecf
21 changed files with 526 additions and 115 deletions

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Umbraco.Cms.Api.Common.ViewModels.Pagination;
public class SubsetViewModel<T>
{
[Required]
public long TotalBefore { get; set; }
[Required]
public long TotalAfter { get; set; }
[Required]
public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
public static SubsetViewModel<T> Empty() => new();
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
@@ -13,7 +14,7 @@ public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DataTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
[ProducesResponseType(typeof(SubsetViewModel<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<DataTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
}

View File

@@ -1,6 +1,7 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
@@ -34,8 +35,8 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
[HttpGet("siblings")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<DocumentTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
[ProducesResponseType(typeof(SubsetViewModel<DocumentTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<DocumentTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
{
IgnoreUserStartNodesForDataType(dataTypeId);
return GetSiblings(target, before, after);

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
@@ -14,8 +15,8 @@ public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeCont
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<DocumentBlueprintTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentBlueprintTreeItemResponseModel>>> Siblings(
[ProducesResponseType(typeof(SubsetViewModel<DocumentBlueprintTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<DocumentBlueprintTreeItemResponseModel>>> Siblings(
CancellationToken cancellationToken,
Guid target,
int before,

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
@@ -13,8 +14,8 @@ public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<DocumentTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentTypeTreeItemResponseModel>>> Siblings(
[ProducesResponseType(typeof(SubsetViewModel<DocumentTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<DocumentTypeTreeItemResponseModel>>> Siblings(
CancellationToken cancellationToken,
Guid target,
int before,

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
@@ -23,8 +24,8 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<MediaTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<MediaTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
[ProducesResponseType(typeof(SubsetViewModel<MediaTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<MediaTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
{
IgnoreUserStartNodesForDataType(dataTypeId);
return GetSiblings(target, before, after);

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
@@ -13,7 +14,7 @@ public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<MediaTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<MediaTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
[ProducesResponseType(typeof(SubsetViewModel<MediaTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<MediaTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
@@ -13,8 +14,8 @@ public class SiblingsTemplateTreeController : TemplateTreeControllerBase
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<NamedEntityTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<NamedEntityTreeItemResponseModel>>> Siblings(
[ProducesResponseType(typeof(SubsetViewModel<NamedEntityTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<SubsetViewModel<NamedEntityTreeItemResponseModel>>> Siblings(
CancellationToken cancellationToken,
Guid target,
int before,

View File

@@ -44,12 +44,12 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
return Task.FromResult<ActionResult<PagedViewModel<TItem>>>(Ok(result));
}
protected Task<ActionResult<IEnumerable<TItem>>> GetSiblings(Guid target, int before, int after)
protected Task<ActionResult<SubsetViewModel<TItem>>> GetSiblings(Guid target, int before, int after)
{
IEntitySlim[] siblings = GetSiblingEntities(target, before, after);
IEntitySlim[] siblings = GetSiblingEntities(target, before, after, out var totalBefore, out var totalAfter);
if (siblings.Length == 0)
{
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(NotFound());
return Task.FromResult<ActionResult<SubsetViewModel<TItem>>>(NotFound());
}
IEntitySlim? entity = siblings.FirstOrDefault();
@@ -57,8 +57,11 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
? EntityService.GetKey(entity.ParentId, ItemObjectType).Result
: Constants.System.RootKey;
TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings);
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(Ok(treeItemsViewModels));
TItem[] treeItemViewModels = MapTreeItemViewModels(parentKey, siblings);
SubsetViewModel<TItem> result = SubsetViewModel(treeItemViewModels, totalBefore, totalAfter);
return Task.FromResult<ActionResult<SubsetViewModel<TItem>>>(Ok(result));
}
protected virtual async Task<ActionResult<IEnumerable<TItem>>> GetAncestors(Guid descendantKey, bool includeSelf = true)
@@ -120,13 +123,15 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
ordering: ItemOrdering)
.ToArray();
protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) =>
protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) =>
EntityService
.GetSiblings(
target,
ItemObjectType,
before,
after,
out totalBefore,
out totalAfter,
ordering: ItemOrdering)
.ToArray();
@@ -152,4 +157,7 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
protected PagedViewModel<TItem> PagedViewModel(IEnumerable<TItem> treeItemViewModels, long totalItems)
=> new() { Total = totalItems, Items = treeItemViewModels };
protected SubsetViewModel<TItem> SubsetViewModel(IEnumerable<TItem> treeItemViewModels, long totalBefore, long totalAfter)
=> new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = treeItemViewModels };
}

View File

@@ -59,11 +59,11 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
return CalculateAccessMap(() => userAccessEntities, out _);
}
protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after)
protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter)
{
if (UserHasRootAccess() || IgnoreUserStartNodes())
{
return base.GetSiblingEntities(target, before, after);
return base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter);
}
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities(
@@ -72,7 +72,9 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
target,
before,
after,
ItemOrdering);
ItemOrdering,
out totalBefore,
out totalAfter);
return CalculateAccessMap(() => userAccessEntities, out _);
}

View File

@@ -1839,14 +1839,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DataTypeTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetDataTypeTreeItemResponseModel"
}
]
}
}
}
@@ -4453,14 +4450,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetDocumentBlueprintTreeItemResponseModel"
}
]
}
}
}
@@ -6770,14 +6764,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetDocumentTypeTreeItemResponseModel"
}
]
}
}
}
@@ -11208,6 +11199,14 @@
"type": "integer",
"format": "int32"
}
},
{
"name": "dataTypeId",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
@@ -11216,14 +11215,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetDocumentTreeItemResponseModel"
}
]
}
}
}
@@ -16028,14 +16024,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/MediaTypeTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetMediaTypeTreeItemResponseModel"
}
]
}
}
}
@@ -18550,6 +18543,14 @@
"type": "integer",
"format": "int32"
}
},
{
"name": "dataTypeId",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
@@ -18558,14 +18559,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/MediaTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetMediaTreeItemResponseModel"
}
]
}
}
}
@@ -28769,14 +28767,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/NamedEntityTreeItemResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/SubsetNamedEntityTreeItemResponseModel"
}
]
}
}
}
@@ -36735,6 +36730,9 @@
{
"$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/DocumentTypePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/UnknownTypePermissionPresentationModel"
}
@@ -37021,6 +37019,9 @@
{
"$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/DocumentTypePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/UnknownTypePermissionPresentationModel"
}
@@ -38357,6 +38358,36 @@
},
"additionalProperties": false
},
"DocumentTypePermissionPresentationModel": {
"required": [
"$type",
"documentTypeAlias",
"verbs"
],
"type": "object",
"properties": {
"$type": {
"type": "string"
},
"verbs": {
"uniqueItems": true,
"type": "array",
"items": {
"type": "string"
}
},
"documentTypeAlias": {
"type": "string"
}
},
"additionalProperties": false,
"discriminator": {
"propertyName": "$type",
"mapping": {
"DocumentTypePermissionPresentationModel": "#/components/schemas/DocumentTypePermissionPresentationModel"
}
}
},
"DocumentTypePropertyTypeContainerResponseModel": {
"required": [
"id",
@@ -44776,6 +44807,209 @@
},
"additionalProperties": false
},
"SubsetDataTypeTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DataTypeTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"SubsetDocumentBlueprintTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"SubsetDocumentTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"SubsetDocumentTypeTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"SubsetMediaTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/MediaTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"SubsetMediaTypeTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/MediaTypeTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"SubsetNamedEntityTreeItemResponseModel": {
"required": [
"items",
"totalAfter",
"totalBefore"
],
"type": "object",
"properties": {
"totalBefore": {
"type": "integer",
"format": "int64"
},
"totalAfter": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/NamedEntityTreeItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"TagResponseModel": {
"required": [
"id",
@@ -46514,6 +46748,9 @@
{
"$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/DocumentTypePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/UnknownTypePermissionPresentationModel"
}
@@ -46940,6 +47177,9 @@
{
"$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/DocumentTypePermissionPresentationModel"
},
{
"$ref": "#/components/schemas/UnknownTypePermissionPresentationModel"
}

View File

@@ -73,6 +73,8 @@ public interface IUserStartNodeEntitiesService
/// <param name="before">The number of applicable siblings to retrieve before the target.</param>
/// <param name="after">The number of applicable siblings to retrieve after the target.</param>
/// <param name="ordering">The ordering to apply when fetching and paginating the children.</param>
/// <param name="totalBefore">Outputs the total number of siblings before the target entity.</param>
/// <param name="totalAfter">Outputs the total number of siblings after the target entity.</param>
/// <returns>A list of sibling entities applicable for the user.</returns>
/// <remarks>
/// The returned entities may include entities that outside of the user start node scope, but are needed to
@@ -84,7 +86,14 @@ public interface IUserStartNodeEntitiesService
Guid targetKey,
int before,
int after,
Ordering ordering) => [];
Ordering ordering,
out long totalBefore,
out long totalAfter)
{
totalBefore = 0;
totalAfter = 0;
return [];
}
/// <summary>
/// Calculates the access level of a collection of entities for users without root access.

View File

@@ -64,7 +64,14 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
}
/// <inheritdoc/>
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems)
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(
UmbracoObjectTypes umbracoObjectType,
string[] userStartNodePaths,
Guid parentKey,
int skip,
int take,
Ordering ordering,
out long totalItems)
{
Attempt<int> parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType);
if (parentIdAttempt.Success is false)
@@ -142,11 +149,22 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
}).WhereNotNull().ToArray();
/// <inheritdoc />
public IEnumerable<UserAccessEntity> SiblingUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid targetKey, int before, int after, Ordering ordering)
public IEnumerable<UserAccessEntity> SiblingUserAccessEntities(
UmbracoObjectTypes umbracoObjectType,
string[] userStartNodePaths,
Guid targetKey,
int before,
int after,
Ordering ordering,
out long totalBefore,
out long totalAfter
)
{
Attempt<int> targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType);
if (targetIdAttempt.Success is false)
{
totalBefore = 0;
totalAfter = 0;
return [];
}
@@ -154,6 +172,8 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
IEntitySlim? target = _entityService.Get(targetId);
if (target is null)
{
totalBefore = 0;
totalAfter = 0;
return [];
}
@@ -162,13 +182,15 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
IEntitySlim? targetParent = _entityService.Get(target.ParentId);
if (targetParent is null) // Even if the parent is the root, we still expect to get a value here.
{
totalBefore = 0;
totalAfter = 0;
return [];
}
if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},")))
{
// The requested parent of the target is one of the user start nodes (or a descendant of one), all siblings are by definition allowed.
siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, ordering: ordering).ToArray();
siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, out totalBefore, out totalAfter, ordering: ordering).ToArray();
return ChildUserAccessEntities(siblings, userStartNodePaths);
}
@@ -177,12 +199,14 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
if (allowedSiblingIds.Length == 0)
{
// The requested target is outside the scope of any user start nodes.
totalBefore = 0;
totalAfter = 0;
return [];
}
// Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children.
IQuery<IUmbracoEntity> query = _scopeProvider.CreateQuery<IUmbracoEntity>().Where(x => allowedSiblingIds.Contains(x.Id));
siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, query, ordering).ToArray();
siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, out totalBefore, out totalAfter, query, ordering).ToArray();
return ChildUserAccessEntities(siblings, userStartNodePaths);
}

View File

@@ -28,8 +28,23 @@ public interface IEntityRepository : IRepository
/// <param name="after">The number of siblings to retrieve after the target entity.</param>
/// <param name="filter">An optional filter to apply to the result set.</param>
/// <param name="ordering">The ordering to apply to the siblings.</param>
/// <param name="totalBefore">Outputs the total number of siblings before the target entity.</param>
/// <param name="totalAfter">Outputs the total number of siblings after the target entity.</param>
/// <returns>Enumerable of sibling entities.</returns>
IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, IQuery<IUmbracoEntity>? filter, Ordering ordering) => [];
IEnumerable<IEntitySlim> GetSiblings(
Guid objectType,
Guid targetKey,
int before,
int after,
IQuery<IUmbracoEntity>? filter,
Ordering ordering,
out long totalBefore,
out long totalAfter)
{
totalBefore = 0;
totalAfter = 0;
return [];
}
/// <summary>
/// Gets entities for a query

View File

@@ -324,6 +324,8 @@ public class EntityService : RepositoryService, IEntityService
UmbracoObjectTypes objectType,
int before,
int after,
out long totalBefore,
out long totalAfter,
IQuery<IUmbracoEntity>? filter = null,
Ordering? ordering = null)
{
@@ -347,7 +349,9 @@ public class EntityService : RepositoryService, IEntityService
before,
after,
filter,
ordering);
ordering,
out totalBefore,
out totalAfter);
scope.Complete();
return siblings;

View File

@@ -179,14 +179,23 @@ public interface IEntityService
/// <param name="after">The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0.</param>
/// <param name="filter">An optional filter to apply to the result set.</param>
/// <param name="ordering">The ordering to apply to the siblings.</param>
/// <param name="totalBefore">Outputs the total number of siblings before the target entity.</param>
/// <param name="totalAfter">Outputs the total number of siblings after the target entity.</param>
/// <returns>Enumerable of sibling entities.</returns>
IEnumerable<IEntitySlim> GetSiblings(
Guid key,
UmbracoObjectTypes objectType,
int before,
int after,
out long totalBefore,
out long totalAfter,
IQuery<IUmbracoEntity>? filter = null,
Ordering? ordering = null) => [];
Ordering? ordering = null)
{
totalBefore = 0;
totalAfter = 0;
return [];
}
/// <summary>
/// Gets the children of an entity.

View File

@@ -152,7 +152,9 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
int before,
int after,
IQuery<IUmbracoEntity>? filter,
Ordering ordering)
Ordering ordering,
out long totalBefore,
out long totalAfter)
{
// 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.
@@ -204,15 +206,21 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
// Select the UniqueId of nodes which row number is within the specified range of the target node's row number.
const int BeforeAfterParameterIndex = 3;
var beforeAfterParameterIndex = BeforeAfterParameterIndex + beforeAfterParameterIndexOffset;
var beforeArgumentsArray = beforeArguments.ToArray();
var afterArgumentsArray = afterArguments.ToArray();
Sql<ISqlContext>? mainSql = Sql()
.Select("UniqueId")
.From().AppendSubQuery(rowNumberSql, "NumberedNodes")
.Where($"rn >= ({targetRowSql.SQL}) - @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", beforeArguments.ToArray())
.Where($"rn <= ({targetRowSql.SQL}) + @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", afterArguments.ToArray())
.Where($"rn >= ({targetRowSql.SQL}) - @{beforeAfterParameterIndex}", beforeArgumentsArray)
.Where($"rn <= ({targetRowSql.SQL}) + @{beforeAfterParameterIndex}", afterArgumentsArray)
.OrderBy("rn");
List<Guid>? keys = Database.Fetch<Guid>(mainSql);
totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true);
totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false);
if (keys is null || keys.Count == 0)
{
return [];
@@ -221,6 +229,20 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
return PerformGetAll(objectType, ordering, sql => sql.WhereIn<NodeDto>(x => x.UniqueId, keys));
}
private long GetNumberOfSiblingsOutsideSiblingRange(
Sql<ISqlContext> rowNumberSql,
Sql<ISqlContext> targetRowSql,
int parameterIndex,
object[] arguments,
bool getBefore)
{
Sql<ISqlContext>? sql = Sql()
.SelectCount()
.From().AppendSubQuery(rowNumberSql, "NumberedNodes")
.Where($"rn {(getBefore ? "<" : ">")} ({targetRowSql.SQL}) {(getBefore ? "-" : "+")} @{parameterIndex}", arguments);
return Database.ExecuteScalar<long>(sql);
}
public IEntitySlim? Get(Guid key, Guid objectTypeId)
{

View File

@@ -17,9 +17,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(2, totalBefore);
Assert.AreEqual(3, totalAfter);
Assert.AreEqual(5, siblings.Length);
Assert.Multiple(() =>
{
@@ -45,9 +49,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
@@ -68,9 +76,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
@@ -82,18 +94,22 @@ public partial class UserStartNodeEntitiesServiceMediaTests
[Test]
public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id);
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id, _mediaByName["1-10"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
1,
1,
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(1, totalAfter);
Assert.AreEqual(3, siblings.Length);
Assert.Multiple(() =>
{
@@ -118,9 +134,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{

View File

@@ -17,9 +17,13 @@ public partial class UserStartNodeEntitiesServiceTests
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(2, totalBefore);
Assert.AreEqual(3, totalAfter);
Assert.AreEqual(5, siblings.Length);
Assert.Multiple(() =>
{
@@ -45,9 +49,13 @@ public partial class UserStartNodeEntitiesServiceTests
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
@@ -68,9 +76,13 @@ public partial class UserStartNodeEntitiesServiceTests
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
@@ -82,19 +94,24 @@ public partial class UserStartNodeEntitiesServiceTests
[Test]
public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id);
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id, _contentByName["1-10"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
1,
1,
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(1, totalAfter);
Assert.AreEqual(3, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName[$"1-3"].Key, siblings[0].Entity.Key);
@@ -118,9 +135,13 @@ public partial class UserStartNodeEntitiesServiceTests
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
BySortOrder,
out long totalBefore,
out long totalAfter)
.ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{

View File

@@ -929,7 +929,6 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
var result2 = EntityService.GetPathKeys(grandChild, omitSelf: true);
Assert.AreEqual($"{root.Key},{child.Key}", string.Join(",", result2));
}
[Test]
@@ -939,7 +938,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
var target = children[1];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(7, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
@@ -955,7 +956,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
ContentService.MoveToRecycleBin(trash);
var target = children[2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(6, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsFalse(result.Any(x => x.Key == trash.Key));
Assert.IsTrue(result[0].Key == children[0].Key);
@@ -974,7 +977,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
IQuery<IUmbracoEntity> filter = ScopeProvider.CreateQuery<IUmbracoEntity>().Where(x => !keysToExclude.Contains(x.Key));
var target = children[2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, filter).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(6, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsFalse(result.Any(x => x.Key == keysToExclude[0]));
Assert.IsTrue(result[0].Key == children[0].Key);
@@ -993,7 +998,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
IQuery<IUmbracoEntity> filter = ScopeProvider.CreateQuery<IUmbracoEntity>().Where(x => x.Key != keyToExclude);
var target = children[2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, filter).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(6, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsFalse(result.Any(x => x.Key == keyToExclude));
Assert.IsTrue(result[0].Key == children[0].Key);
@@ -1010,7 +1017,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
children = children.OrderBy(x => x.Name).ToList();
var target = children[1];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(7, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
@@ -1023,7 +1032,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
var children = CreateSiblingsTestData();
var target = children[1];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1, out long totalBefore, out long totalAfter).ToArray();
Assert.AreEqual(0, totalBefore);
Assert.AreEqual(7, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
@@ -1036,7 +1047,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
var children = CreateSiblingsTestData();
var target = children[^2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100, out long totalBefore, out long totalAfter).ToArray();
Assert.AreEqual(7, totalBefore);
Assert.AreEqual(0, totalAfter);
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[^1].Key == children[^1].Key);
Assert.IsTrue(result[^2].Key == children[^2].Key);

View File

@@ -23,7 +23,7 @@ public class EntityServiceTests
if (shouldThrow)
{
Assert.Throws<ArgumentOutOfRangeException>(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after));
Assert.Throws<ArgumentOutOfRangeException>(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after, out _, out _));
}
}