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>
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,9 +207,27 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user