From f0752234124ce84d76cd22edcd0b85426d6fe919 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 13 Nov 2025 09:32:18 +0100 Subject: [PATCH] Relations: Exclude the relate parent on delete relation type from checks for related documents and media on delete, when disable delete with references is enabled (closes #20803) (#20811) * Exclude the relate parent on delete relation type from checks for related documents and media on delete, when disable delete with references is enabled. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Applied suggestions from code review. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Persistence/Querying/IQuery.cs | 8 ++++++ .../Services/ContentEditingService.cs | 3 +++ .../Services/ContentEditingServiceBase.cs | 27 +++++++++++++++++-- src/Umbraco.Core/Services/IRelationService.cs | 17 +++++++++++- .../Services/MediaEditingService.cs | 3 +++ src/Umbraco.Core/Services/RelationService.cs | 19 ++++++++++--- .../Persistence/Querying/Query.cs | 22 ++++++++++++--- .../ContentEditingServiceTests.Delete.cs | 18 +++++++++++++ .../Services/ContentEditingServiceTests.cs | 4 +-- 9 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Core/Persistence/Querying/IQuery.cs b/src/Umbraco.Core/Persistence/Querying/IQuery.cs index 8803d69fc0..e87ec77b86 100644 --- a/src/Umbraco.Core/Persistence/Querying/IQuery.cs +++ b/src/Umbraco.Core/Persistence/Querying/IQuery.cs @@ -30,6 +30,14 @@ public interface IQuery /// This instance so calls to this method are chainable IQuery WhereIn(Expression> fieldSelector, IEnumerable? values); + /// + /// Adds a where-not-in clause to the query + /// + /// + /// + /// This instance so calls to this method are chainable + IQuery WhereNotIn(Expression> fieldSelector, IEnumerable? values) => throw new NotImplementedException(); // TODO (V18): Remove default implementation. + /// /// Adds a set of OR-ed where clauses to the query. /// diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 9b4f92615b..783421a2c2 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -62,6 +62,9 @@ internal sealed class ContentEditingService _languageService = languageService; } + /// + protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias; + public Task GetAsync(Guid key) { IContent? content = ContentService.GetById(key); diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 15a4b9670e..41e1adf7b3 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -75,6 +75,11 @@ internal abstract class ContentEditingServiceBase + /// Gets the alias used to relate the parent entity when handling content (document or media) delete operations. + /// + protected virtual string? RelateParentOnDeleteAlias => null; + protected async Task> MapCreate(ContentCreationModelBase contentCreationModelBase) where TContentCreateResult : ContentCreateResultBase, new() { @@ -202,9 +207,27 @@ internal abstract class ContentEditingServiceBase(status, content); } - if (disabledWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) + if (disabledWhenReferenced) { - return Attempt.FailWithStatus(referenceFailStatus, content); + // When checking if an item is related, we may need to exclude the "relate parent on delete" relation type, as this prevents + // deleting from the recycle bin. + int[]? excludeRelationTypeIds = null; + if (string.IsNullOrWhiteSpace(RelateParentOnDeleteAlias) is false) + { + IRelationType? relateParentOnDeleteRelationType = _relationService.GetRelationTypeByAlias(RelateParentOnDeleteAlias); + if (relateParentOnDeleteRelationType is not null) + { + excludeRelationTypeIds = [relateParentOnDeleteRelationType.Id]; + } + } + + if (_relationService.IsRelated( + content.Id, + RelationDirectionFilter.Child, + excludeRelationTypeIds: excludeRelationTypeIds)) + { + return Attempt.FailWithStatus(referenceFailStatus, content); + } } var userId = await GetUserIdAsync(userKey); diff --git a/src/Umbraco.Core/Services/IRelationService.cs b/src/Umbraco.Core/Services/IRelationService.cs index 828abc1fd1..7ddae1394d 100644 --- a/src/Umbraco.Core/Services/IRelationService.cs +++ b/src/Umbraco.Core/Services/IRelationService.cs @@ -297,9 +297,24 @@ public interface IRelationService : IService /// /// Id of an object to check relations for /// Indicates whether to check for relations as parent, child or in either direction. - /// Returns True if any relations exists with the given Id, otherwise False + /// Returns True if any relations exists with the given Id, otherwise False. + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")] bool IsRelated(int id, RelationDirectionFilter directionFilter); + /// + /// Checks whether any relations exists for the passed in Id and direction. + /// + /// Id of an object to check relations for + /// Indicates whether to check for relations as parent, child or in either direction. + /// A collection of relation type Ids to include consideration in the relation checks. + /// A collection of relation type Ids to exclude from consideration in the relation checks. + /// If no relation type Ids are provided in includeRelationTypeIds or excludeRelationTypeIds, all relation type Ids are considered. + /// Returns True if any relations exists with the given Id, otherwise False. + bool IsRelated(int id, RelationDirectionFilter directionFilter, int[]? includeRelationTypeIds = null, int[]? excludeRelationTypeIds = null) +#pragma warning disable CS0618 // Type or member is obsolete + => IsRelated(id, directionFilter); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Checks whether two items are related /// diff --git a/src/Umbraco.Core/Services/MediaEditingService.cs b/src/Umbraco.Core/Services/MediaEditingService.cs index 6947e1dc5d..ec9177c150 100644 --- a/src/Umbraco.Core/Services/MediaEditingService.cs +++ b/src/Umbraco.Core/Services/MediaEditingService.cs @@ -43,6 +43,9 @@ internal sealed class MediaEditingService contentTypeFilters) => _logger = logger; + /// + protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias; + public Task GetAsync(Guid key) { IMedia? media = ContentService.GetById(key); diff --git a/src/Umbraco.Core/Services/RelationService.cs b/src/Umbraco.Core/Services/RelationService.cs index 35adb2e217..ed1d03df70 100644 --- a/src/Umbraco.Core/Services/RelationService.cs +++ b/src/Umbraco.Core/Services/RelationService.cs @@ -485,11 +485,14 @@ public class RelationService : RepositoryService, IRelationService return _relationRepository.Get(query).Any(); } - /// - public bool IsRelated(int id) => IsRelated(id, RelationDirectionFilter.Any); + [Obsolete("No longer used in Umbraco, please the overload taking all parameters. Scheduled for removal in Umbraco 19.")] + public bool IsRelated(int id) => IsRelated(id, RelationDirectionFilter.Any, null, null); + + [Obsolete("Please the overload taking all parameters. Scheduled for removal in Umbraco 18.")] + public bool IsRelated(int id, RelationDirectionFilter directionFilter) => IsRelated(id, directionFilter, null, null); /// - public bool IsRelated(int id, RelationDirectionFilter directionFilter) + public bool IsRelated(int id, RelationDirectionFilter directionFilter, int[]? includeRelationTypeIds = null, int[]? excludeRelationTypeIds = null) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); IQuery query = Query(); @@ -502,6 +505,16 @@ public class RelationService : RepositoryService, IRelationService _ => throw new ArgumentOutOfRangeException(nameof(directionFilter)), }; + if (includeRelationTypeIds is not null && includeRelationTypeIds.Length > 0) + { + query = query.WhereIn(x => x.RelationTypeId, includeRelationTypeIds); + } + + if (excludeRelationTypeIds is not null && excludeRelationTypeIds.Length > 0) + { + query = query.WhereNotIn(x => x.RelationTypeId, excludeRelationTypeIds); + } + return _relationRepository.Get(query).Any(); } diff --git a/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs b/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs index 88d1326f44..c66e121cf7 100644 --- a/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs +++ b/src/Umbraco.Infrastructure/Persistence/Querying/Query.cs @@ -22,7 +22,7 @@ public class Query : IQuery /// public virtual IQuery Where(Expression>? predicate) { - if (predicate == null) + if (predicate is null) { return this; } @@ -38,7 +38,7 @@ public class Query : IQuery /// public virtual IQuery WhereIn(Expression>? fieldSelector, IEnumerable? values) { - if (fieldSelector == null) + if (fieldSelector is null) { return this; } @@ -49,12 +49,28 @@ public class Query : IQuery return this; } + /// + /// Adds a where-not-in clause to the query. + /// + public virtual IQuery WhereNotIn(Expression>? fieldSelector, IEnumerable? values) + { + if (fieldSelector is null) + { + return this; + } + + var expressionHelper = new ModelToSqlExpressionVisitor(_sqlContext.SqlSyntax, _sqlContext.Mappers); + var whereExpression = expressionHelper.Visit(fieldSelector); + _wheres.Add(new Tuple(whereExpression + " NOT IN (@values)", new object[] { new { values } })); + return this; + } + /// /// Adds a set of OR-ed where clauses to the query. /// public virtual IQuery WhereAny(IEnumerable>>? predicates) { - if (predicates == null) + if (predicates is null) { return this; } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs index 95a7807fad..8c0544421f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs @@ -34,6 +34,24 @@ public partial class ContentEditingServiceTests Assert.IsNotNull(subpage); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] + public async Task Can_Delete_When_Content_Is_Related_To_Parent_For_Restore_And_Configured_To_Disable_When_Related() + { + var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveAttempt.Success); + + // Setup a relation where the page being deleted is related to it's parent (created as the location to restore to). + Relate(Subpage2, Subpage, Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias); + var result = await ContentEditingService.DeleteFromRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify is deleted + var subpage = await ContentEditingService.GetAsync(Subpage.Key); + Assert.IsNull(subpage); + } + [Test] [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] public async Task Can_Delete_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index 4fca28f2ff..4c8508cfa6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -16,9 +16,9 @@ public partial class ContentEditingServiceTests : ContentEditingServiceTestsBase [SetUp] public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; - public void Relate(IContent parent, IContent child) + public void Relate(IContent parent, IContent child, string relationTypeAlias = Constants.Conventions.RelationTypes.RelatedDocumentAlias) { - var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); + var relatedContentRelType = RelationService.GetRelationTypeByAlias(relationTypeAlias); var relation = RelationService.Relate(parent.Id, child.Id, relatedContentRelType); RelationService.Save(relation);