feat(core): add ContentQueryOperationService implementation
Implements IContentQueryOperationService with Count, GetByLevel, and GetPagedOfType operations. Follows Phase 1 patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
169
src/Umbraco.Core/Services/ContentQueryOperationService.cs
Normal file
169
src/Umbraco.Core/Services/ContentQueryOperationService.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements content query operations (counting, filtering by type/level).
|
||||
/// </summary>
|
||||
public class ContentQueryOperationService : ContentServiceBase, IContentQueryOperationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Default ordering for paged queries.
|
||||
/// </summary>
|
||||
private static readonly Ordering DefaultSortOrdering = Ordering.By("sortOrder");
|
||||
|
||||
/// <summary>
|
||||
/// Logger for this service (for debugging, performance monitoring, or error tracking).
|
||||
/// </summary>
|
||||
private readonly ILogger<ContentQueryOperationService> _logger;
|
||||
|
||||
public ContentQueryOperationService(
|
||||
ICoreScopeProvider provider,
|
||||
ILoggerFactory loggerFactory,
|
||||
IEventMessagesFactory eventMessagesFactory,
|
||||
IDocumentRepository documentRepository,
|
||||
IAuditService auditService,
|
||||
IUserIdKeyResolver userIdKeyResolver)
|
||||
: base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<ContentQueryOperationService>();
|
||||
}
|
||||
|
||||
#region Count Operations
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count(string? contentTypeAlias = null)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return DocumentRepository.Count(contentTypeAlias);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountPublished(string? contentTypeAlias = null)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return DocumentRepository.CountPublished(contentTypeAlias);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountChildren(int parentId, string? contentTypeAlias = null)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return DocumentRepository.CountChildren(parentId, contentTypeAlias);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountDescendants(int parentId, string? contentTypeAlias = null)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return DocumentRepository.CountDescendants(parentId, contentTypeAlias);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchy Queries
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The returned enumerable may be lazily evaluated. Callers should materialize
|
||||
/// results (e.g., call ToList()) if they need to access them after the scope is disposed.
|
||||
/// This is consistent with the existing ContentService.GetByLevel implementation.
|
||||
/// </remarks>
|
||||
public IEnumerable<IContent> GetByLevel(int level)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
IQuery<IContent>? query = Query<IContent>().Where(x => x.Level == level && x.Trashed == false);
|
||||
return DocumentRepository.Get(query);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Paged Type Queries
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IContent> GetPagedOfType(
|
||||
int contentTypeId,
|
||||
long pageIndex,
|
||||
int pageSize,
|
||||
out long totalRecords,
|
||||
IQuery<IContent>? filter = null,
|
||||
Ordering? ordering = null)
|
||||
{
|
||||
if (pageIndex < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageIndex));
|
||||
}
|
||||
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
}
|
||||
|
||||
ordering ??= DefaultSortOrdering;
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
|
||||
// Note: filter=null is valid and means no additional filtering beyond the content type
|
||||
return DocumentRepository.GetPage(
|
||||
Query<IContent>()?.Where(x => x.ContentTypeId == contentTypeId),
|
||||
pageIndex,
|
||||
pageSize,
|
||||
out totalRecords,
|
||||
filter,
|
||||
ordering);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IContent> GetPagedOfTypes(
|
||||
int[] contentTypeIds,
|
||||
long pageIndex,
|
||||
int pageSize,
|
||||
out long totalRecords,
|
||||
IQuery<IContent>? filter = null,
|
||||
Ordering? ordering = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contentTypeIds);
|
||||
|
||||
if (pageIndex < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageIndex));
|
||||
}
|
||||
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
}
|
||||
|
||||
ordering ??= DefaultSortOrdering;
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
|
||||
// Expression trees require a List for Contains() - array not supported.
|
||||
// This O(n) copy is unavoidable but contentTypeIds is typically small.
|
||||
List<int> contentTypeIdsAsList = [.. contentTypeIds];
|
||||
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
|
||||
// Note: filter=null is valid and means no additional filtering beyond the content types
|
||||
return DocumentRepository.GetPage(
|
||||
Query<IContent>()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)),
|
||||
pageIndex,
|
||||
pageSize,
|
||||
out totalRecords,
|
||||
filter,
|
||||
ordering);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for ContentQueryOperationService.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[UmbracoTest(
|
||||
Database = UmbracoTestOptions.Database.NewSchemaPerTest,
|
||||
WithApplication = true)]
|
||||
public class ContentQueryOperationServiceTests : UmbracoIntegrationTestWithContent
|
||||
{
|
||||
private IContentQueryOperationService QueryService => GetRequiredService<IContentQueryOperationService>();
|
||||
|
||||
[Test]
|
||||
public void Count_WithNoFilter_ReturnsAllContentCount()
|
||||
{
|
||||
// Arrange - base class creates Textpage, Subpage, Subpage2, Subpage3, Trashed
|
||||
|
||||
// Act
|
||||
var count = QueryService.Count();
|
||||
|
||||
// Assert - should return 5 (all items including Trashed)
|
||||
Assert.That(count, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Count_WithNonExistentContentTypeAlias_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentAlias = "nonexistent-content-type-alias";
|
||||
|
||||
// Act
|
||||
var count = QueryService.Count(nonExistentAlias);
|
||||
|
||||
// Assert
|
||||
Assert.That(count, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Count_WithContentTypeAlias_ReturnsFilteredCount()
|
||||
{
|
||||
// Arrange
|
||||
var alias = ContentType.Alias;
|
||||
|
||||
// Act
|
||||
var count = QueryService.Count(alias);
|
||||
|
||||
// Assert - all 5 content items use the same content type
|
||||
Assert.That(count, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountChildren_ReturnsChildCount()
|
||||
{
|
||||
// Arrange - Textpage has children: Subpage, Subpage2, Subpage3
|
||||
var parentId = Textpage.Id;
|
||||
|
||||
// Act
|
||||
var count = QueryService.CountChildren(parentId);
|
||||
|
||||
// Assert
|
||||
Assert.That(count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetByLevel_ReturnsContentAtLevel()
|
||||
{
|
||||
// Arrange - level 1 is root content
|
||||
|
||||
// Act
|
||||
var items = QueryService.GetByLevel(1);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Not.Null);
|
||||
Assert.That(items.All(x => x.Level == 1), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPagedOfType_ReturnsPaginatedResults()
|
||||
{
|
||||
// Arrange
|
||||
var contentTypeId = ContentType.Id;
|
||||
|
||||
// Act
|
||||
var items = QueryService.GetPagedOfType(contentTypeId, 0, 10, out var total);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Not.Null);
|
||||
Assert.That(total, Is.EqualTo(5)); // All 5 content items are of this type
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPagedOfTypes_WithEmptyArray_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var items = QueryService.GetPagedOfTypes(Array.Empty<int>(), 0, 10, out var total);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Empty);
|
||||
Assert.That(total, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPagedOfTypes_WithNonExistentContentTypeIds_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentIds = new[] { 999999, 999998 };
|
||||
|
||||
// Act
|
||||
var items = QueryService.GetPagedOfTypes(nonExistentIds, 0, 10, out var total);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Empty);
|
||||
Assert.That(total, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountChildren_WithNonExistentParentId_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentParentId = 999999;
|
||||
|
||||
// Act
|
||||
var count = QueryService.CountChildren(nonExistentParentId);
|
||||
|
||||
// Assert
|
||||
Assert.That(count, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetByLevel_WithLevelZero_ReturnsEmpty()
|
||||
{
|
||||
// Arrange - level 0 doesn't exist (content starts at level 1)
|
||||
|
||||
// Act
|
||||
var items = QueryService.GetByLevel(0);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetByLevel_WithNegativeLevel_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
var items = QueryService.GetByLevel(-1);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPagedOfType_WithNonExistentContentTypeId_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentId = 999999;
|
||||
|
||||
// Act
|
||||
var items = QueryService.GetPagedOfType(nonExistentId, 0, 10, out var total);
|
||||
|
||||
// Assert
|
||||
Assert.That(items, Is.Empty);
|
||||
Assert.That(total, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountDescendants_ReturnsDescendantCount()
|
||||
{
|
||||
// Arrange - Textpage has descendants: Subpage, Subpage2, Subpage3
|
||||
var ancestorId = Textpage.Id;
|
||||
|
||||
// Act
|
||||
var count = QueryService.CountDescendants(ancestorId);
|
||||
|
||||
// Assert
|
||||
Assert.That(count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountDescendants_WithNonExistentAncestorId_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentId = 999999;
|
||||
|
||||
// Act
|
||||
var count = QueryService.CountDescendants(nonExistentId);
|
||||
|
||||
// Assert
|
||||
Assert.That(count, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountPublished_WithNoPublishedContent_ReturnsZero()
|
||||
{
|
||||
// Arrange - base class creates content but doesn't publish
|
||||
|
||||
// Act
|
||||
var count = QueryService.CountPublished();
|
||||
|
||||
// Assert
|
||||
Assert.That(count, Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user