diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs index aeb99e2442a3..66dc1294386b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentEditing.Validation; +using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.PropertyEditors.Validation; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; @@ -106,6 +108,122 @@ protected IActionResult GetReferencesOperationStatusResult(GetReferencesOperatio .Build()), }); + protected IActionResult ContentPublishingOperationStatusResult( + ContentPublishingOperationStatus status, + IEnumerable? invalidPropertyAliases = null, + IEnumerable? failedBranchItems = null) + => OperationStatusResult( + status, + problemDetailsBuilder => status switch + { + ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + .WithTitle("The requested document could not be found") + .Build()), + ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder + .WithTitle("Publish cancelled by event") + .WithDetail("The publish operation was cancelled by an event.") + .Build()), + ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder + .WithTitle("Invalid document") + .WithDetail("The specified document had an invalid configuration.") + .WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty()) + .Build()), + ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder + .WithTitle("Nothing to publish") + .WithDetail("None of the specified cultures needed publishing.") + .Build()), + ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder + .WithTitle("Mandatory culture missing") + .WithDetail("Must include all mandatory cultures when publishing.") + .Build()), + ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder + .WithTitle("Document expired") + .WithDetail("Could not publish the document because it was expired.") + .Build()), + ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder + .WithTitle("Document culture expired") + .WithDetail("Could not publish the document because some of the specified cultures were expired.") + .Build()), + ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder + .WithTitle("Document awaiting release") + .WithDetail("Could not publish the document because it was awaiting release.") + .Build()), + ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder + .WithTitle("Document culture awaiting release") + .WithDetail( + "Could not publish the document because some of the specified cultures were awaiting release.") + .Build()), + ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder + .WithTitle("Document in the recycle bin") + .WithDetail("Could not publish the document because it was in the recycle bin.") + .Build()), + ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder + .WithTitle("Parent not published") + .WithDetail("Could not publish the document because its parent was not published.") + .Build()), + ContentPublishingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder + .WithTitle("Invalid cultures specified") + .WithDetail("A specified culture is not valid for the operation.") + .Build()), + ContentPublishingOperationStatus.CultureMissing => BadRequest(problemDetailsBuilder + .WithTitle("Culture missing") + .WithDetail("A culture needs to be specified to execute the operation.") + .Build()), + ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder + .WithTitle("Cannot publish invariant when variant") + .WithDetail("Cannot publish invariant culture when the document varies by culture.") + .Build()), + ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder + .WithTitle("Cannot publish variant when not variant.") + .WithDetail("Cannot publish a given culture when the document is invariant.") + .Build()), + ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder + .WithTitle("Concurrency violation detected") + .WithDetail("An attempt was made to publish a version older than the latest version.") + .Build()), + ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder + .WithTitle("Unsaved changes") + .WithDetail( + "Could not publish the document because it had unsaved changes. Make sure to save all changes before attempting a publish.") + .Build()), + ContentPublishingOperationStatus.UnpublishTimeNeedsToBeAfterPublishTime => BadRequest(problemDetailsBuilder + .WithTitle("Unpublish time needs to be after the publish time") + .WithDetail( + "Cannot handle an unpublish time that is not after the specified publish time.") + .Build()), + ContentPublishingOperationStatus.PublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder + .WithTitle("Publish time needs to be higher than the current time") + .WithDetail( + "Cannot handle a publish time that is not after the current server time.") + .Build()), + ContentPublishingOperationStatus.UpublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder + .WithTitle("Unpublish time needs to be higher than the current time") + .WithDetail( + "Cannot handle an unpublish time that is not after the current server time.") + .Build()), + ContentPublishingOperationStatus.CannotUnpublishWhenReferenced => BadRequest(problemDetailsBuilder + .WithTitle("Cannot unpublish document when it's referenced somewhere else.") + .WithDetail( + "Cannot unpublish a referenced document, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.") + .Build()), + ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder + .WithTitle("Failed branch operation") + .WithDetail("One or more items in the branch could not complete the operation.") + .WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult { Id = item.Key, OperationStatus = item.OperationStatus }) ?? []) + .Build()), + ContentPublishingOperationStatus.Failed => BadRequest( + problemDetailsBuilder + .WithTitle("Publish or unpublish failed") + .WithDetail( + "An unspecified error occurred while (un)publishing. Please check the logs for additional information.") + .Build()), + ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder + .WithTitle("The result of the submitted task could not be found") + .Build()), + + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), + }); + protected IActionResult ContentEditingOperationStatusResult( ContentEditingOperationStatus status, TContentModelBase requestModel, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index 6cad2b13bfcd..b4eba064a4ec 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -30,123 +30,11 @@ protected IActionResult DocumentEditingOperationStatusResult( where TContentModelBase : ContentModelBase => ContentEditingOperationStatusResult(status, requestModel, validationResult); - // TODO ELEMENTS: move this to ContentControllerBase protected IActionResult DocumentPublishingOperationStatusResult( ContentPublishingOperationStatus status, IEnumerable? invalidPropertyAliases = null, IEnumerable? failedBranchItems = null) - => OperationStatusResult(status, problemDetailsBuilder => status switch - { - ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder - .WithTitle("The requested document could not be found") - .Build()), - ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder - .WithTitle("Publish cancelled by event") - .WithDetail("The publish operation was cancelled by an event.") - .Build()), - ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder - .WithTitle("Invalid document") - .WithDetail("The specified document had an invalid configuration.") - .WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty()) - .Build()), - ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder - .WithTitle("Nothing to publish") - .WithDetail("None of the specified cultures needed publishing.") - .Build()), - ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder - .WithTitle("Mandatory culture missing") - .WithDetail("Must include all mandatory cultures when publishing.") - .Build()), - ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder - .WithTitle("Document expired") - .WithDetail("Could not publish the document because it was expired.") - .Build()), - ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder - .WithTitle("Document culture expired") - .WithDetail("Could not publish the document because some of the specified cultures were expired.") - .Build()), - ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder - .WithTitle("Document awaiting release") - .WithDetail("Could not publish the document because it was awaiting release.") - .Build()), - ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder - .WithTitle("Document culture awaiting release") - .WithDetail( - "Could not publish the document because some of the specified cultures were awaiting release.") - .Build()), - ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder - .WithTitle("Document in the recycle bin") - .WithDetail("Could not publish the document because it was in the recycle bin.") - .Build()), - ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder - .WithTitle("Parent not published") - .WithDetail("Could not publish the document because its parent was not published.") - .Build()), - ContentPublishingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder - .WithTitle("Invalid cultures specified") - .WithDetail("A specified culture is not valid for the operation.") - .Build()), - ContentPublishingOperationStatus.CultureMissing => BadRequest(problemDetailsBuilder - .WithTitle("Culture missing") - .WithDetail("A culture needs to be specified to execute the operation.") - .Build()), - ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder - .WithTitle("Cannot publish invariant when variant") - .WithDetail("Cannot publish invariant culture when the document varies by culture.") - .Build()), - ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder - .WithTitle("Cannot publish variant when not variant.") - .WithDetail("Cannot publish a given culture when the document is invariant.") - .Build()), - ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder - .WithTitle("Concurrency violation detected") - .WithDetail("An attempt was made to publish a version older than the latest version.") - .Build()), - ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder - .WithTitle("Unsaved changes") - .WithDetail( - "Could not publish the document because it had unsaved changes. Make sure to save all changes before attempting a publish.") - .Build()), - ContentPublishingOperationStatus.UnpublishTimeNeedsToBeAfterPublishTime => BadRequest(problemDetailsBuilder - .WithTitle("Unpublish time needs to be after the publish time") - .WithDetail( - "Cannot handle an unpublish time that is not after the specified publish time.") - .Build()), - ContentPublishingOperationStatus.PublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder - .WithTitle("Publish time needs to be higher than the current time") - .WithDetail( - "Cannot handle a publish time that is not after the current server time.") - .Build()), - ContentPublishingOperationStatus.UpublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder - .WithTitle("Unpublish time needs to be higher than the current time") - .WithDetail( - "Cannot handle an unpublish time that is not after the current server time.") - .Build()), - ContentPublishingOperationStatus.CannotUnpublishWhenReferenced => BadRequest(problemDetailsBuilder - .WithTitle("Cannot unpublish document when it's referenced somewhere else.") - .WithDetail( - "Cannot unpublish a referenced document, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.") - .Build()), - ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder - .WithTitle("Failed branch operation") - .WithDetail("One or more items in the branch could not complete the operation.") - .WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult - { - Id = item.Key, - OperationStatus = item.OperationStatus - }) ?? Enumerable.Empty()) - .Build()), - ContentPublishingOperationStatus.Failed => BadRequest(problemDetailsBuilder - .WithTitle("Publish or unpublish failed") - .WithDetail( - "An unspecified error occurred while (un)publishing. Please check the logs for additional information.") - .Build()), - ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder - .WithTitle("The result of the submitted task could not be found") - .Build()), - - _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), - }); + => ContentPublishingOperationStatusResult(status, invalidPropertyAliases, failedBranchItems); protected IActionResult PublicAccessOperationStatusResult(PublicAccessOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs index 99e43340ae3b..a43e7e12584a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Management.ViewModels.Element; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Web.Common.Authorization; @@ -21,4 +22,10 @@ protected IActionResult ElementEditingOperationStatusResult( ContentValidationResult validationResult) where TContentModelBase : ContentModelBase => ContentEditingOperationStatusResult(status, requestModel, validationResult); + + protected IActionResult ElementPublishingOperationStatusResult( + ContentPublishingOperationStatus status, + IEnumerable? invalidPropertyAliases = null, + IEnumerable? failedBranchItems = null) + => ContentPublishingOperationStatusResult(status, invalidPropertyAliases, failedBranchItems); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs index ead5823bdd4b..c1bbe1b6eb30 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs @@ -65,8 +65,7 @@ public async Task Publish(CancellationToken cancellationToken, Gu if (modelResult.Success is false) { - // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready - return BadRequest(); + return ElementPublishingOperationStatusResult(modelResult.Status); } Attempt attempt = await _elementPublishingService.PublishAsync( @@ -75,7 +74,6 @@ public async Task Publish(CancellationToken cancellationToken, Gu CurrentUserKey(_backOfficeSecurityAccessor)); return attempt.Success ? Ok() - // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready - : BadRequest(); + : ElementPublishingOperationStatusResult(attempt.Status, attempt.Result.InvalidPropertyAliases); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs index 9681f2438197..d3967c7c41b0 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs @@ -57,7 +57,6 @@ public async Task Unpublish(CancellationToken cancellationToken, CurrentUserKey(_backOfficeSecurityAccessor)); return attempt.Success ? Ok() - // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready - : BadRequest(); + : ElementPublishingOperationStatusResult(attempt.Result); } } diff --git a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs index d6cdc2506f56..3d4c0a0d3067 100644 --- a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs +++ b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs @@ -204,4 +204,11 @@ public enum UmbracoObjectTypes [FriendlyName("Element Container")] [UmbracoUdiType(Constants.UdiEntityType.ElementContainer)] ElementContainer, + + /// + /// Element Recycle Bin + /// + [UmbracoObjectType(Constants.ObjectTypes.Strings.ElementRecycleBin)] + [FriendlyName("Element Recycle Bin")] + ElementRecycleBin, } diff --git a/src/Umbraco.Core/Persistence/SqlExpressionExtensions.cs b/src/Umbraco.Core/Persistence/SqlExpressionExtensions.cs index e5633deb59ec..5c1cc5021684 100644 --- a/src/Umbraco.Core/Persistence/SqlExpressionExtensions.cs +++ b/src/Umbraco.Core/Persistence/SqlExpressionExtensions.cs @@ -93,4 +93,24 @@ public static bool SqlStartsWith(this string? str, string txt, TextColumnType co public static bool SqlEndsWith(this string str, string txt, TextColumnType columnType) => str.InvariantEndsWith(txt); #pragma warning restore IDE0060 // Remove unused parameter + + /// + /// Determines whether a string is less than another string using ordinal comparison. + /// + /// The string to compare. + /// The string to compare to. + /// true if is less than ; otherwise, false. + /// Do not use outside of Sql expressions. + public static bool SqlLessThan(this string str, string other) => + string.Compare(str, other, StringComparison.Ordinal) < 0; + + /// + /// Determines whether a string is greater than another string using ordinal comparison. + /// + /// The string to compare. + /// The string to compare to. + /// true if is greater than ; otherwise, false. + /// Do not use outside of Sql expressions. + public static bool SqlGreaterThan(this string str, string other) => + string.Compare(str, other, StringComparison.Ordinal) > 0; } diff --git a/src/Umbraco.Core/Services/ElementContainerService.cs b/src/Umbraco.Core/Services/ElementContainerService.cs index 801ca170702e..fe60df7d5e7f 100644 --- a/src/Umbraco.Core/Services/ElementContainerService.cs +++ b/src/Umbraco.Core/Services/ElementContainerService.cs @@ -1,9 +1,13 @@ using System.Globalization; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence; +using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; @@ -18,6 +22,8 @@ internal sealed class ElementContainerService : EntityTypeContainerService _contentSettingsOptions; + private readonly IRelationService _relationService; private readonly ILogger _logger; // internal so the tests can reach it @@ -34,6 +40,8 @@ public ElementContainerService( IEntityService entityService, IElementRepository elementRepository, IElementService elementService, + IOptionsMonitor contentSettingsOptions, + IRelationService relationService, ILogger logger) : base(provider, loggerFactory, eventMessagesFactory, entityContainerRepository, auditService, entityRepository, userIdKeyResolver) { @@ -42,6 +50,8 @@ public ElementContainerService( _entityService = entityService; _elementRepository = elementRepository; _elementService = elementService; + _contentSettingsOptions = contentSettingsOptions; + _relationService = relationService; _logger = logger; } @@ -151,7 +161,12 @@ public ElementContainerService( return deleteResult; } - public async Task> EmptyRecycleBinAsync(Guid userKey) + /// + public Task> EmptyRecycleBinAsync(Guid userKey) + => EmptyRecycleBinAsync(userKey, DescendantsIteratorPageSize); + + // internal so tests can use a smaller page size + internal async Task> EmptyRecycleBinAsync(Guid userKey, int pageSize) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); scope.WriteLock(Constants.Locks.ElementTree); @@ -165,41 +180,7 @@ public async Task> EmptyRecycleBinAsync( return Attempt.Fail(EntityContainerOperationStatus.CancelledByNotification); } - long total; - do - { - IEnumerable recycleBinRootItems = _entityService.GetPagedChildren( - Constants.System.RecycleBinElementKey, - [UmbracoObjectTypes.ElementContainer], - [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], - 0, // pageIndex = 0 because we continuously delete items as we move through the descendants - DescendantsIteratorPageSize, - trashed: true, - out total); - - foreach (IEntitySlim recycleBinRootItem in recycleBinRootItems) - { - DeleteDescendantsLocked(recycleBinRootItem.Key); - - if (recycleBinRootItem.NodeObjectType == Constants.ObjectTypes.Element) - { - IElement? element = _elementRepository.Get(recycleBinRootItem.Key); - if (element is not null) - { - _elementRepository.Delete(element); - } - } - else - { - EntityContainer? container = await GetAsync(recycleBinRootItem.Key); - if (container is not null) - { - _entityContainerRepository.Delete(container); - } - } - } - } - while (total > DescendantsIteratorPageSize); + DeleteDescendantsLocked(Constants.System.RecycleBinElementKey, UmbracoObjectTypes.ElementRecycleBin, scope, eventMessages, pageSize); await AuditAsync(AuditType.Delete, userKey, Constants.System.RecycleBinElement, "Recycle bin emptied"); @@ -346,7 +327,7 @@ public async Task> EmptyRecycleBinAsync( return Attempt.FailWithStatus(EntityContainerOperationStatus.CancelledByNotification, container); } - DeleteDescendantsLocked(container.Key); + DeleteDescendantsLocked(container.Key, UmbracoObjectTypes.ElementContainer, scope, eventMessages); _entityContainerRepository.Delete(container); @@ -358,37 +339,83 @@ public async Task> EmptyRecycleBinAsync( return Attempt.SucceedWithStatus(EntityContainerOperationStatus.Success, container); } - private void DeleteDescendantsLocked(Guid key) + private void DeleteDescendantsLocked(Guid key, UmbracoObjectTypes objectType, ICoreScope scope, EventMessages eventMessages, int pageSize = DescendantsIteratorPageSize) { - long total; + // Order by path descending to ensure children are deleted before parents + var pathDescendingOrdering = Ordering.By("Path", Direction.Descending); - do + // Use path as a cursor to track progress - this avoids skip/take pagination issues + // when some items are skipped due to being referenced + string? lastProcessedPath = null; + + // Track paths of items that couldn't be deleted (referenced items) + // so we can skip deleting containers that are ancestors of these items + var protectedPaths = new List(); + + while (true) { - IEnumerable descendants = _entityService.GetPagedDescendants( + // Build filter: path less than the last processed path (if any) for cursor-based pagination + var pathCursor = lastProcessedPath; + IQuery? filter = pathCursor is null + ? null + : Query().Where(d => d.Path.SqlLessThan(pathCursor)); + + IEntitySlim[] descendants = _entityService.GetPagedDescendants( key, - UmbracoObjectTypes.ElementContainer, + objectType, [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], - 0, // pageIndex = 0 because we continuously delete items as we move through the descendants - DescendantsIteratorPageSize, - out total); + 0, + pageSize, + out _, + filter: filter, + ordering: pathDescendingOrdering).ToArray(); + + if (descendants.Length == 0) + { + break; + } foreach (IEntitySlim descendant in descendants) { - if (descendant.NodeObjectType == Constants.ObjectTypes.ElementContainer) + // Skip deleting containers that are ancestors of protected (referenced) items + if (descendant.NodeObjectType == Constants.ObjectTypes.ElementContainer + && protectedPaths.Any(p => p.StartsWith(descendant.Path + ","))) { - EntityContainer descendantContainer = _entityContainerRepository.Get(descendant.Id) - ?? throw new InvalidOperationException($"Descendant container with ID {descendant.Id} was not found."); - _entityContainerRepository.Delete(descendantContainer); + continue; } - else + + // Check if referenced before fetching the full entity + if (_contentSettingsOptions.CurrentValue.DisableDeleteWhenReferenced + && _relationService.IsRelated(descendant.Id, RelationDirectionFilter.Child, null)) { - IElement descendantElement = _elementRepository.Get(descendant.Id) - ?? throw new InvalidOperationException($"Descendant element with ID {descendant.Id} was not found."); - _elementRepository.Delete(descendantElement); + protectedPaths.Add(descendant.Path); + continue; } + + DeleteItem(scope, descendant, eventMessages); } + + // Track the smallest path we've seen (last in descending order) as cursor for next iteration + lastProcessedPath = descendants[^1].Path; + } + } + + private void DeleteItem(ICoreScope scope, IEntitySlim descendant, EventMessages eventMessages) + { + if (descendant.NodeObjectType == Constants.ObjectTypes.ElementContainer) + { + EntityContainer descendantContainer = _entityContainerRepository.Get(descendant.Id) + ?? throw new InvalidOperationException($"Descendant container with ID {descendant.Id} was not found."); + _entityContainerRepository.Delete(descendantContainer); + scope.Notifications.Publish(new EntityContainerDeletedNotification(descendantContainer, eventMessages)); + } + else + { + IElement descendantElement = _elementRepository.Get(descendant.Id) + ?? throw new InvalidOperationException($"Descendant element with ID {descendant.Id} was not found."); + _elementRepository.Delete(descendantElement); + scope.Notifications.Publish(new ElementDeletedNotification(descendantElement, eventMessages)); } - while (total > DescendantsIteratorPageSize); } protected override Guid ContainedObjectType => Constants.ObjectTypes.Element; diff --git a/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs b/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs index 47a64c96e3a3..e0e9b5d98ef4 100644 --- a/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs @@ -519,6 +519,8 @@ protected virtual string VisitMethodCall(MethodCallExpression? m) case nameof(SqlExpressionExtensions.SqlEndsWith): case nameof(SqlExpressionExtensions.SqlContains): case nameof(SqlExpressionExtensions.SqlEquals): + case nameof(SqlExpressionExtensions.SqlLessThan): + case nameof(SqlExpressionExtensions.SqlGreaterThan): case nameof(StringExtensions.InvariantStartsWith): case nameof(StringExtensions.InvariantEndsWith): case nameof(StringExtensions.InvariantContains): @@ -829,6 +831,18 @@ protected string HandleStringComparison(string col, string val, string verb, Tex ? string.Empty : SqlSyntax.GetStringColumnWildcardComparison(col, SqlParameters.Count - 1, columnType); + case nameof(SqlExpressionExtensions.SqlLessThan): + SqlParameters.Add(RemoveQuote(val)!); + return Visited + ? string.Empty + : $"({col} < @{SqlParameters.Count - 1})"; + + case nameof(SqlExpressionExtensions.SqlGreaterThan): + SqlParameters.Add(RemoveQuote(val)!); + return Visited + ? string.Empty + : $"({col} > @{SqlParameters.Count - 1})"; + default: throw new ArgumentOutOfRangeException(nameof(verb)); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs index 060c32e8d0d8..7fc1513d4ba4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs @@ -1,10 +1,18 @@ -using NUnit.Framework; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Attributes; namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; public partial class ElementPublishingServiceTests { + private IRelationService RelationService => GetRequiredService(); + [Test] public async Task Can_Unpublish_Invariant() { @@ -111,4 +119,66 @@ await ElementPublishingService.PublishAsync( var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); Assert.IsNull(publishedElement); } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Cannot_Unpublish_Referenced_Element_When_Configured_To_Disable_When_Referenced() + { + var elementType = await SetupInvariantElementTypeAsync(); + var referencingElement = await CreateInvariantContentAsync(elementType); + var referencedElement = await CreateInvariantContentAsync(elementType); + + await ElementPublishingService.PublishAsync( + referencedElement.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + // Setup a relation where referencingElement references referencedElement. + RelationService.Relate(referencingElement.Id, referencedElement.Id, Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + referencedElement.Key, + null, + Constants.Security.SuperUserKey); + + Assert.IsFalse(unpublishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced, unpublishAttempt.Result); + + // Verify the referencedElement is still published + var publishedElement = await ElementCacheService.GetByKeyAsync(referencedElement.Key, false); + Assert.IsNotNull(publishedElement); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Can_Unpublish_Referencing_Element_When_Configured_To_Disable_When_Referenced() + { + var elementType = await SetupInvariantElementTypeAsync(); + var referencingElement = await CreateInvariantContentAsync(elementType); + var referencedElement = await CreateInvariantContentAsync(elementType); + + await ElementPublishingService.PublishAsync( + referencingElement.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + // Setup a relation where referencingElement references referencedElement. + RelationService.Relate(referencingElement.Id, referencedElement.Id, Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + referencingElement.Key, + null, + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, unpublishAttempt.Result); + + // Verify the element is unpublished + var publishedElement = await ElementCacheService.GetByKeyAsync(referencingElement.Key, false); + Assert.IsNull(publishedElement); + } + + public static void ConfigureDisableUnpublishWhenReferencedTrue(IUmbracoBuilder builder) + => builder.Services.Configure(config => + config.DisableUnpublishWhenReferenced = true); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs index 77af33ad6e7d..33c324b1c32e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs @@ -160,4 +160,60 @@ public async Task Container_Delete_From_Recycle_Bin_Event_Can_Be_Cancelled() Assert.IsNotNull(entityContainer); Assert.IsTrue(entityContainer.Trashed); } + + [Test] + public async Task Deleted_Notifications_Are_Fired_For_Descendant_Items() + { + var deletedElementKeys = new List(); + var deletedContainerKeys = new List(); + + var elementType = await CreateElementType(); + + // Create a container structure: rootContainer -> childContainer -> elements + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + var childContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey); + + // Create elements in the child container + var element1 = await CreateElement(elementType.Key, childContainerKey); + var element2 = await CreateElement(elementType.Key, childContainerKey); + + // Move to recycle bin + await ElementContainerService.MoveToRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + + try + { + ElementNotificationHandler.DeletedElement = notification => + { + deletedElementKeys.Add(notification.DeletedEntities.Single().Key); + }; + + EntityContainerNotificationHandler.DeletedContainer = notification => + { + deletedContainerKeys.Add(notification.DeletedEntities.Single().Key); + }; + + var result = await ElementContainerService.DeleteFromRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + + // Verify notifications were fired for descendant elements + Assert.AreEqual(2, deletedElementKeys.Count); + Assert.Contains(element1.Key, deletedElementKeys); + Assert.Contains(element2.Key, deletedElementKeys); + + // Verify notifications were fired for descendant containers (child + root) + Assert.AreEqual(2, deletedContainerKeys.Count); + Assert.Contains(childContainerKey, deletedContainerKeys); + Assert.Contains(rootContainerKey, deletedContainerKeys); + } + finally + { + ElementNotificationHandler.DeletedElement = null; + EntityContainerNotificationHandler.DeletedContainer = null; + } + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs index e2e6b22af7d3..d42d34e61c3b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs @@ -1,12 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Tests.Common.Attributes; +using Umbraco.Cms.Tests.Integration.Attributes; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ElementContainerServiceTests { + private IRelationService RelationService => GetRequiredService(); + [Test] public async Task Can_Purge_Empty_Containers_From_Recycle_Bin() { @@ -82,4 +88,91 @@ public async Task Emptying_The_Recycle_Bin_Does_Not_Affect_Items_Outside_The_Rec Assert.AreEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] + public async Task Emptying_Recycle_Bin_With_DisableDeleteWhenReferenced_Deletes_All_Unreferenced_Items_Across_Multiple_Pages() + { + var elementType = await CreateElementType(); + + // Create a container outside the recycle bin to use as the "referencing" entity + var referencingContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(referencingContainerKey, "Referencing Container", null, Constants.Security.SuperUserKey); + var referencingElement = await CreateElement(elementType.Key, referencingContainerKey); + + // Create a container with elements + var containerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(containerKey, "Trashed Container", null, Constants.Security.SuperUserKey); + + var referencedElements = new List(); + var unreferencedElements = new List(); + + // Create more elements than the test page size, with some being referenced + // Using a small page size (5) means we only need ~15 items to test multiple pages + const int testPageSize = 5; + const int totalElements = testPageSize * 3; + for (var i = 0; i < totalElements; i++) + { + var element = await CreateElement(elementType.Key, containerKey); + + // Mark every 5th element as referenced (one per page) + if (i % testPageSize == 0) + { + RelateElements(referencingElement, element); + referencedElements.Add(element); + } + else + { + unreferencedElements.Add(element); + } + } + + // Move the container to recycle bin + var trashResult = + await ElementContainerService.MoveToRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + Assert.IsTrue(trashResult.Success); + + // Verify initial state + var initialRecycleBinCount = EntityService.GetDescendants(Constants.System.RecycleBinElement).Count(); + Assert.AreEqual(totalElements + 1, initialRecycleBinCount, "Should have all elements plus the container in recycle bin"); + + // Empty the recycle bin using the internal method with a small page size + var emptyResult = await ((ElementContainerService)ElementContainerService) + .EmptyRecycleBinAsync(Constants.Security.SuperUserKey, testPageSize); + Assert.IsTrue(emptyResult.Success); + + // Verify that all unreferenced elements were deleted + foreach (var element in unreferencedElements) + { + var found = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(found, $"Unreferenced element {element.Key} should have been deleted"); + } + + // Verify that all referenced elements still exist + foreach (var element in referencedElements) + { + var found = await ElementEditingService.GetAsync(element.Key); + Assert.IsNotNull(found, $"Referenced element {element.Key} should NOT have been deleted"); + } + + // The container should also still exist (because it contains referenced items) + // or alternatively all referenced items should still be in the recycle bin + var remainingInRecycleBin = EntityService.GetDescendants(Constants.System.RecycleBinElement).Count(); + Assert.AreEqual( + referencedElements.Count + 1, + remainingInRecycleBin, + $"Should have {referencedElements.Count} referenced elements plus the container remaining in recycle bin"); + } + + public static void ConfigureDisableDeleteWhenReferenced(IUmbracoBuilder builder) + => builder.Services.Configure(config => + config.DisableDeleteWhenReferenced = true); + + private void RelateElements(IElement parent, IElement child) + { + var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var relation = RelationService.Relate(parent.Id, child.Id, relatedContentRelType); + RelationService.Save(relation); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs index b3e9e7f2ece2..f364763ff0e6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs @@ -33,7 +33,8 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) => builder .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() - .AddNotificationHandler(); + .AddNotificationHandler() + .AddNotificationHandler(); private IEntitySlim[] GetAtRoot() => EntityService.GetRootEntities(UmbracoObjectTypes.ElementContainer).Union(EntityService.GetRootEntities(UmbracoObjectTypes.Element)).ToArray(); @@ -196,4 +197,11 @@ private sealed class EntityContainerNotificationHandler : public void Handle(EntityContainerDeletedNotification notification) => DeletedContainer?.Invoke(notification); } + + private sealed class ElementNotificationHandler : INotificationHandler + { + public static Action? DeletedElement { get; set; } + + public void Handle(ElementDeletedNotification notification) => DeletedElement?.Invoke(notification); + } }