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:
@@ -30,6 +30,14 @@ public interface IQuery<T>
|
|||||||
/// <returns>This instance so calls to this method are chainable</returns>
|
/// <returns>This instance so calls to this method are chainable</returns>
|
||||||
IQuery<T> WhereIn(Expression<Func<T, object>> fieldSelector, IEnumerable? values);
|
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>
|
/// <summary>
|
||||||
/// Adds a set of OR-ed where clauses to the query.
|
/// Adds a set of OR-ed where clauses to the query.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ internal sealed class ContentEditingService
|
|||||||
_languageService = languageService;
|
_languageService = languageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias;
|
||||||
|
|
||||||
public Task<IContent?> GetAsync(Guid key)
|
public Task<IContent?> GetAsync(Guid key)
|
||||||
{
|
{
|
||||||
IContent? content = ContentService.GetById(key);
|
IContent? content = ContentService.GetById(key);
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
|||||||
|
|
||||||
protected TContentTypeService ContentTypeService { get; }
|
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)
|
protected async Task<Attempt<TContentCreateResult, ContentEditingOperationStatus>> MapCreate<TContentCreateResult>(ContentCreationModelBase contentCreationModelBase)
|
||||||
where TContentCreateResult : ContentCreateResultBase<TContent>, new()
|
where TContentCreateResult : ContentCreateResultBase<TContent>, new()
|
||||||
{
|
{
|
||||||
@@ -202,9 +207,27 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
|||||||
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(status, content);
|
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(status, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disabledWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child))
|
if (disabledWhenReferenced)
|
||||||
{
|
{
|
||||||
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(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<TContent?, ContentEditingOperationStatus>(referenceFailStatus, content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = await GetUserIdAsync(userKey);
|
var userId = await GetUserIdAsync(userKey);
|
||||||
|
|||||||
@@ -297,9 +297,24 @@ public interface IRelationService : IService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">Id of an object to check relations for</param>
|
/// <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="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);
|
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>
|
/// <summary>
|
||||||
/// Checks whether two items are related
|
/// Checks whether two items are related
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ internal sealed class MediaEditingService
|
|||||||
contentTypeFilters)
|
contentTypeFilters)
|
||||||
=> _logger = logger;
|
=> _logger = logger;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override string? RelateParentOnDeleteAlias => Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias;
|
||||||
|
|
||||||
public Task<IMedia?> GetAsync(Guid key)
|
public Task<IMedia?> GetAsync(Guid key)
|
||||||
{
|
{
|
||||||
IMedia? media = ContentService.GetById(key);
|
IMedia? media = ContentService.GetById(key);
|
||||||
|
|||||||
@@ -485,11 +485,14 @@ public class RelationService : RepositoryService, IRelationService
|
|||||||
return _relationRepository.Get(query).Any();
|
return _relationRepository.Get(query).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
[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);
|
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 />
|
/// <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);
|
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||||
IQuery<IRelation> query = Query<IRelation>();
|
IQuery<IRelation> query = Query<IRelation>();
|
||||||
@@ -502,6 +505,16 @@ public class RelationService : RepositoryService, IRelationService
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(directionFilter)),
|
_ => 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();
|
return _relationRepository.Get(query).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class Query<T> : IQuery<T>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual IQuery<T> Where(Expression<Func<T, bool>>? predicate)
|
public virtual IQuery<T> Where(Expression<Func<T, bool>>? predicate)
|
||||||
{
|
{
|
||||||
if (predicate == null)
|
if (predicate is null)
|
||||||
{
|
{
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ public class Query<T> : IQuery<T>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual IQuery<T> WhereIn(Expression<Func<T, object>>? fieldSelector, IEnumerable? values)
|
public virtual IQuery<T> WhereIn(Expression<Func<T, object>>? fieldSelector, IEnumerable? values)
|
||||||
{
|
{
|
||||||
if (fieldSelector == null)
|
if (fieldSelector is null)
|
||||||
{
|
{
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -49,12 +49,28 @@ public class Query<T> : IQuery<T>
|
|||||||
return this;
|
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>
|
/// <summary>
|
||||||
/// Adds a set of OR-ed where clauses to the query.
|
/// Adds a set of OR-ed where clauses to the query.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual IQuery<T> WhereAny(IEnumerable<Expression<Func<T, bool>>>? predicates)
|
public virtual IQuery<T> WhereAny(IEnumerable<Expression<Func<T, bool>>>? predicates)
|
||||||
{
|
{
|
||||||
if (predicates == null)
|
if (predicates is null)
|
||||||
{
|
{
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ public partial class ContentEditingServiceTests
|
|||||||
Assert.IsNotNull(subpage);
|
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]
|
[Test]
|
||||||
[ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))]
|
[ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))]
|
||||||
public async Task Can_Delete_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related()
|
public async Task Can_Delete_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related()
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ public partial class ContentEditingServiceTests : ContentEditingServiceTestsBase
|
|||||||
[SetUp]
|
[SetUp]
|
||||||
public void Setup() => ContentRepositoryBase.ThrowOnWarning = true;
|
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);
|
var relation = RelationService.Relate(parent.Id, child.Id, relatedContentRelType);
|
||||||
RelationService.Save(relation);
|
RelationService.Save(relation);
|
||||||
|
|||||||
Reference in New Issue
Block a user