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>
This commit is contained in:
Andy Butland
2025-11-13 09:32:18 +01:00
committed by GitHub
parent d4d4b8a50a
commit f075223412
9 changed files with 110 additions and 11 deletions

View File

@@ -30,6 +30,14 @@ public interface IQuery<T>
/// <returns>This instance so calls to this method are chainable</returns>
IQuery<T> WhereIn(Expression<Func<T, object>> fieldSelector, IEnumerable? values);
/// <summary>
/// Adds a where-not-in clause to the query
/// </summary>
/// <param name="fieldSelector"></param>
/// <param name="values"></param>
/// <returns>This instance so calls to this method are chainable</returns>
IQuery<T> WhereNotIn(Expression<Func<T, object>> fieldSelector, IEnumerable? values) => throw new NotImplementedException(); // TODO (V18): Remove default implementation.
/// <summary>
/// Adds a set of OR-ed where clauses to the query.
/// </summary>

View File

@@ -62,6 +62,9 @@ internal sealed class ContentEditingService
_languageService = languageService;
}
/// <inheritdoc/>
protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias;
public Task<IContent?> GetAsync(Guid key)
{
IContent? content = ContentService.GetById(key);

View File

@@ -75,6 +75,11 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
protected TContentTypeService ContentTypeService { get; }
/// <summary>
/// Gets the alias used to relate the parent entity when handling content (document or media) delete operations.
/// </summary>
protected virtual string? RelateParentOnDeleteAlias => null;
protected async Task<Attempt<TContentCreateResult, ContentEditingOperationStatus>> MapCreate<TContentCreateResult>(ContentCreationModelBase contentCreationModelBase)
where TContentCreateResult : ContentCreateResultBase<TContent>, new()
{
@@ -202,10 +207,28 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(status, content);
}
if (disabledWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child))
if (disabledWhenReferenced)
{
// 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<TContent?, ContentEditingOperationStatus>(referenceFailStatus, content);
}
}
var userId = await GetUserIdAsync(userKey);
OperationResult? deleteResult = performDelete(content, userId);

View File

@@ -297,9 +297,24 @@ public interface IRelationService : IService
/// </summary>
/// <param name="id">Id of an object to check relations for</param>
/// <param name="directionFilter">Indicates whether to check for relations as parent, child or in either direction.</param>
/// <returns>Returns <c>True</c> if any relations exists with the given Id, otherwise <c>False</c></returns>
/// <returns>Returns <c>True</c> if any relations exists with the given Id, otherwise <c>False</c>.</returns>
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")]
bool IsRelated(int id, RelationDirectionFilter directionFilter);
/// <summary>
/// Checks whether any relations exists for the passed in Id and direction.
/// </summary>
/// <param name="id">Id of an object to check relations for</param>
/// <param name="directionFilter">Indicates whether to check for relations as parent, child or in either direction.</param>
/// <param name="includeRelationTypeIds">A collection of relation type Ids to include consideration in the relation checks.</param>
/// <param name="excludeRelationTypeIds">A collection of relation type Ids to exclude from consideration in the relation checks.</param>
/// <remarks>If no relation type Ids are provided in includeRelationTypeIds or excludeRelationTypeIds, all relation type Ids are considered.</remarks>
/// <returns>Returns <c>True</c> if any relations exists with the given Id, otherwise <c>False</c>.</returns>
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
/// <summary>
/// Checks whether two items are related
/// </summary>

View File

@@ -43,6 +43,9 @@ internal sealed class MediaEditingService
contentTypeFilters)
=> _logger = logger;
/// <inheritdoc/>
protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias;
public Task<IMedia?> GetAsync(Guid key)
{
IMedia? media = ContentService.GetById(key);

View File

@@ -485,11 +485,14 @@ public class RelationService : RepositoryService, IRelationService
return _relationRepository.Get(query).Any();
}
/// <inheritdoc />
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);
/// <inheritdoc />
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<IRelation> query = Query<IRelation>();
@@ -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();
}

View File

@@ -22,7 +22,7 @@ public class Query<T> : IQuery<T>
/// </summary>
public virtual IQuery<T> Where(Expression<Func<T, bool>>? predicate)
{
if (predicate == null)
if (predicate is null)
{
return this;
}
@@ -38,7 +38,7 @@ public class Query<T> : IQuery<T>
/// </summary>
public virtual IQuery<T> WhereIn(Expression<Func<T, object>>? fieldSelector, IEnumerable? values)
{
if (fieldSelector == null)
if (fieldSelector is null)
{
return this;
}
@@ -49,12 +49,28 @@ public class Query<T> : IQuery<T>
return this;
}
/// <summary>
/// Adds a where-not-in clause to the query.
/// </summary>
public virtual IQuery<T> WhereNotIn(Expression<Func<T, object>>? fieldSelector, IEnumerable? values)
{
if (fieldSelector is null)
{
return this;
}
var expressionHelper = new ModelToSqlExpressionVisitor<T>(_sqlContext.SqlSyntax, _sqlContext.Mappers);
var whereExpression = expressionHelper.Visit(fieldSelector);
_wheres.Add(new Tuple<string, object[]>(whereExpression + " NOT IN (@values)", new object[] { new { values } }));
return this;
}
/// <summary>
/// Adds a set of OR-ed where clauses to the query.
/// </summary>
public virtual IQuery<T> WhereAny(IEnumerable<Expression<Func<T, bool>>>? predicates)
{
if (predicates == null)
if (predicates is null)
{
return this;
}

View File

@@ -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()

View File

@@ -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);