Compare commits
9 Commits
phase-1-cr
...
phase-2-qu
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bb1b24f92 | |||
| 1bc741b470 | |||
| dc44bebfcc | |||
| fb20c480e3 | |||
| ff4bdb2509 | |||
| 31dfe07aa7 | |||
| cf8394b6fd | |||
| 36d1fcc8ac | |||
| d78238b247 |
@@ -14,6 +14,7 @@
|
||||
| 1.3 | Added detailed test strategy (15 tests for coverage gaps) |
|
||||
| 1.4 | Added phase gates with test execution commands and regression protocol |
|
||||
| 1.5 | Added performance benchmarks (33 tests for baseline comparison) |
|
||||
| 1.6 | Phase 2 complete - QueryOperationService extracted |
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -386,22 +387,29 @@ public class ContentServicesComposer : IComposer
|
||||
|
||||
Each phase MUST run tests before and after to verify no regressions.
|
||||
|
||||
| Phase | Service | Tests to Run | Gate |
|
||||
|-------|---------|--------------|------|
|
||||
| 0 | Write tests | `ContentServiceRefactoringTests` | All 15 pass |
|
||||
| 1 | CRUD Service | All ContentService*Tests | All pass |
|
||||
| 2 | Query Service | All ContentService*Tests | All pass |
|
||||
| 3 | Version Service | All ContentService*Tests | All pass |
|
||||
| 4 | Move Service | All ContentService*Tests + Sort/MoveToRecycleBin tests | All pass |
|
||||
| 5 | Publish Operation Service | All ContentService*Tests + Notification ordering tests | All pass |
|
||||
| 6 | Permission Manager | All ContentService*Tests + Permission tests | All pass |
|
||||
| 7 | Blueprint Manager | All ContentService*Tests | All pass |
|
||||
| 8 | Facade | **Full test suite** | All pass |
|
||||
| Phase | Service | Tests to Run | Gate | Status |
|
||||
|-------|---------|--------------|------|--------|
|
||||
| 0 | Write tests | `ContentServiceRefactoringTests` | All 15 pass | ✅ Complete |
|
||||
| 1 | CRUD Service | All ContentService*Tests | All pass | ✅ Complete |
|
||||
| 2 | Query Service | All ContentService*Tests | All pass | ✅ Complete |
|
||||
| 3 | Version Service | All ContentService*Tests | All pass | Pending |
|
||||
| 4 | Move Service | All ContentService*Tests + Sort/MoveToRecycleBin tests | All pass | Pending |
|
||||
| 5 | Publish Operation Service | All ContentService*Tests + Notification ordering tests | All pass | Pending |
|
||||
| 6 | Permission Manager | All ContentService*Tests + Permission tests | All pass | Pending |
|
||||
| 7 | Blueprint Manager | All ContentService*Tests | All pass | Pending |
|
||||
| 8 | Facade | **Full test suite** | All pass | Pending |
|
||||
|
||||
### Phase Details
|
||||
|
||||
1. **Phase 0: Write Tests** - Create `ContentServiceRefactoringTests.cs` with all 15 tests
|
||||
2. **Phase 1: CRUD Service** - Establish patterns, base class
|
||||
1. **Phase 0: Write Tests** ✅ - Created `ContentServiceRefactoringTests.cs` with 16 tests (15 original + 1 DI test)
|
||||
2. **Phase 1: CRUD Service** ✅ - Complete! Created:
|
||||
- `ContentServiceBase.cs` - Abstract base class with shared infrastructure
|
||||
- `ContentServiceConstants.cs` - Shared constants
|
||||
- `IContentCrudService.cs` - Interface (21 methods)
|
||||
- `ContentCrudService.cs` - Implementation (~750 lines)
|
||||
- Updated `ContentService.cs` to delegate CRUD operations (reduced from 3823 to 3497 lines)
|
||||
- Benchmark regression enforcement (20% threshold, CI-configurable)
|
||||
- Git tag: `phase-1-crud-extraction`
|
||||
3. **Phase 2: Query Service** - Read-only operations, low risk
|
||||
4. **Phase 3: Version Service** - Straightforward extraction
|
||||
5. **Phase 4: Move Service** - Depends on CRUD; Sort and MoveToRecycleBin tests critical
|
||||
|
||||
@@ -299,6 +299,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
Services.AddUnique<IContentPermissionService, ContentPermissionService>();
|
||||
Services.AddUnique<IDictionaryPermissionService, DictionaryPermissionService>();
|
||||
Services.AddUnique<IContentCrudService, ContentCrudService>();
|
||||
Services.AddUnique<IContentQueryOperationService, ContentQueryOperationService>();
|
||||
Services.AddUnique<IContentService>(sp =>
|
||||
new ContentService(
|
||||
sp.GetRequiredService<ICoreScopeProvider>(),
|
||||
@@ -318,7 +319,8 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
sp.GetRequiredService<IIdKeyMap>(),
|
||||
sp.GetRequiredService<IOptionsMonitor<ContentSettings>>(),
|
||||
sp.GetRequiredService<IRelationService>(),
|
||||
sp.GetRequiredService<IContentCrudService>()));
|
||||
sp.GetRequiredService<IContentCrudService>(),
|
||||
sp.GetRequiredService<IContentQueryOperationService>()));
|
||||
Services.AddUnique<IContentBlueprintEditingService, ContentBlueprintEditingService>();
|
||||
Services.AddUnique<IContentEditingService, ContentEditingService>();
|
||||
Services.AddUnique<IContentPublishingService, ContentPublishingService>();
|
||||
|
||||
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
|
||||
}
|
||||
@@ -51,6 +51,18 @@ public class ContentService : RepositoryService, IContentService
|
||||
// Property for convenient access (deferred resolution for both paths)
|
||||
private IContentCrudService CrudService => _crudServiceLazy.Value;
|
||||
|
||||
// Query operation service fields (for Phase 2 extracted query operations)
|
||||
private readonly IContentQueryOperationService? _queryOperationService;
|
||||
private readonly Lazy<IContentQueryOperationService>? _queryOperationServiceLazy;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the query operation service.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
|
||||
private IContentQueryOperationService QueryOperationService =>
|
||||
_queryOperationService ?? _queryOperationServiceLazy?.Value
|
||||
?? throw new InvalidOperationException("QueryOperationService not initialized. Ensure the service is properly injected via constructor.");
|
||||
|
||||
#region Constructors
|
||||
|
||||
[Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor]
|
||||
@@ -72,7 +84,8 @@ public class ContentService : RepositoryService, IContentService
|
||||
IIdKeyMap idKeyMap,
|
||||
IOptionsMonitor<ContentSettings> optionsMonitor,
|
||||
IRelationService relationService,
|
||||
IContentCrudService crudService) // NEW PARAMETER - direct injection
|
||||
IContentCrudService crudService,
|
||||
IContentQueryOperationService queryOperationService) // NEW PARAMETER - Phase 2 query operations
|
||||
: base(provider, loggerFactory, eventMessagesFactory)
|
||||
{
|
||||
_documentRepository = documentRepository;
|
||||
@@ -97,6 +110,11 @@ public class ContentService : RepositoryService, IContentService
|
||||
ArgumentNullException.ThrowIfNull(crudService);
|
||||
// Wrap in Lazy for consistent access pattern (already resolved, so returns immediately)
|
||||
_crudServiceLazy = new Lazy<IContentCrudService>(() => crudService);
|
||||
|
||||
// Phase 2: Query operation service (direct injection)
|
||||
ArgumentNullException.ThrowIfNull(queryOperationService);
|
||||
_queryOperationService = queryOperationService;
|
||||
_queryOperationServiceLazy = null; // Not needed when directly injected
|
||||
}
|
||||
|
||||
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
|
||||
@@ -148,6 +166,11 @@ public class ContentService : RepositoryService, IContentService
|
||||
_crudServiceLazy = new Lazy<IContentCrudService>(() =>
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
// Phase 2: Lazy resolution of IContentQueryOperationService
|
||||
_queryOperationServiceLazy = new Lazy<IContentQueryOperationService>(() =>
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentQueryOperationService>(),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
|
||||
@@ -198,6 +221,11 @@ public class ContentService : RepositoryService, IContentService
|
||||
_crudServiceLazy = new Lazy<IContentCrudService>(() =>
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
// Phase 2: Lazy resolution of IContentQueryOperationService
|
||||
_queryOperationServiceLazy = new Lazy<IContentQueryOperationService>(() =>
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentQueryOperationService>(),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -274,40 +302,16 @@ public class ContentService : RepositoryService, IContentService
|
||||
#region Count
|
||||
|
||||
public int CountPublished(string? contentTypeAlias = null)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return _documentRepository.CountPublished(contentTypeAlias);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.CountPublished(contentTypeAlias);
|
||||
|
||||
public int Count(string? contentTypeAlias = null)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return _documentRepository.Count(contentTypeAlias);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.Count(contentTypeAlias);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.CountChildren(parentId, contentTypeAlias);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.CountDescendants(parentId, contentTypeAlias);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -545,63 +549,11 @@ public class ContentService : RepositoryService, IContentService
|
||||
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 ??= Ordering.By("sortOrder");
|
||||
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return _documentRepository.GetPage(
|
||||
Query<IContent>()?.Where(x => x.ContentTypeId == contentTypeId),
|
||||
pageIndex,
|
||||
pageSize,
|
||||
out totalRecords,
|
||||
filter,
|
||||
ordering);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.GetPagedOfType(contentTypeId, pageIndex, pageSize, out totalRecords, filter, ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IContent> GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery<IContent>? filter, Ordering? ordering = null)
|
||||
{
|
||||
if (pageIndex < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageIndex));
|
||||
}
|
||||
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
}
|
||||
|
||||
ordering ??= Ordering.By("sortOrder");
|
||||
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
// Need to use a List here because the expression tree cannot convert the array when used in Contains.
|
||||
// See ExpressionTests.Sql_In().
|
||||
List<int> contentTypeIdsAsList = [.. contentTypeIds];
|
||||
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
return _documentRepository.GetPage(
|
||||
Query<IContent>()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)),
|
||||
pageIndex,
|
||||
pageSize,
|
||||
out totalRecords,
|
||||
filter,
|
||||
ordering);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.GetPagedOfTypes(contentTypeIds, pageIndex, pageSize, out totalRecords, filter, ordering);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a collection of <see cref="IContent" /> objects by Level
|
||||
@@ -610,14 +562,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
|
||||
/// <remarks>Contrary to most methods, this method filters out trashed content items.</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);
|
||||
}
|
||||
}
|
||||
=> QueryOperationService.GetByLevel(level);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific version of an <see cref="IContent" /> item.
|
||||
|
||||
123
src/Umbraco.Core/Services/IContentQueryOperationService.cs
Normal file
123
src/Umbraco.Core/Services/IContentQueryOperationService.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
// src/Umbraco.Core/Services/IContentQueryOperationService.cs
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for content query operations (counting, filtering by type/level).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Implementation Note:</strong> Do not implement this interface directly.
|
||||
/// Instead, inherit from <see cref="ContentServiceBase"/> which provides required
|
||||
/// infrastructure (scoping, repository access, auditing). Direct implementation
|
||||
/// without this base class will result in missing functionality.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This interface is part of the ContentService refactoring initiative (Phase 2).
|
||||
/// It extracts query operations into a focused, testable service.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Versioning Policy:</strong> This interface follows additive-only changes.
|
||||
/// New methods may be added with default implementations. Existing methods will not
|
||||
/// be removed or have signatures changed without a 2 major version deprecation period.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Version History:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>v1.0 (Phase 2): Initial interface with Count, GetByLevel, GetPagedOfType operations</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <since>1.0</since>
|
||||
public interface IContentQueryOperationService : IService
|
||||
{
|
||||
#region Count Operations
|
||||
|
||||
/// <summary>
|
||||
/// Counts content items, optionally filtered by content type.
|
||||
/// </summary>
|
||||
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
|
||||
/// <returns>The count of matching content items (includes trashed items).</returns>
|
||||
int Count(string? contentTypeAlias = null);
|
||||
|
||||
/// <summary>
|
||||
/// Counts published content items, optionally filtered by content type.
|
||||
/// </summary>
|
||||
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
|
||||
/// <returns>The count of matching published content items.</returns>
|
||||
int CountPublished(string? contentTypeAlias = null);
|
||||
|
||||
/// <summary>
|
||||
/// Counts children of a parent, optionally filtered by content type.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent content id. If the parent doesn't exist, returns 0.</param>
|
||||
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
|
||||
/// <returns>The count of matching child content items.</returns>
|
||||
int CountChildren(int parentId, string? contentTypeAlias = null);
|
||||
|
||||
/// <summary>
|
||||
/// Counts descendants of an ancestor, optionally filtered by content type.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The ancestor content id. If the ancestor doesn't exist, returns 0.</param>
|
||||
/// <param name="contentTypeAlias">Optional content type alias to filter by. If the alias doesn't exist, returns 0.</param>
|
||||
/// <returns>The count of matching descendant content items.</returns>
|
||||
int CountDescendants(int parentId, string? contentTypeAlias = null);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchy Queries
|
||||
|
||||
/// <summary>
|
||||
/// Gets content items at a specific tree level.
|
||||
/// </summary>
|
||||
/// <param name="level">The tree level (1 = root children, 2 = grandchildren, etc.).</param>
|
||||
/// <returns>Content items at the specified level, excluding trashed items.</returns>
|
||||
IEnumerable<IContent> GetByLevel(int level);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Paged Type Queries
|
||||
|
||||
/// <summary>
|
||||
/// Gets paged content items of a specific content type.
|
||||
/// </summary>
|
||||
/// <param name="contentTypeId">The content type id. If the content type doesn't exist, returns empty results with totalRecords = 0.</param>
|
||||
/// <param name="pageIndex">Zero-based page index.</param>
|
||||
/// <param name="pageSize">Page size.</param>
|
||||
/// <param name="totalRecords">Output: total number of matching records.</param>
|
||||
/// <param name="filter">Optional filter query.</param>
|
||||
/// <param name="ordering">Optional ordering (defaults to sortOrder).</param>
|
||||
/// <returns>Paged content items.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when pageIndex is negative or pageSize is less than or equal to zero.</exception>
|
||||
IEnumerable<IContent> GetPagedOfType(
|
||||
int contentTypeId,
|
||||
long pageIndex,
|
||||
int pageSize,
|
||||
out long totalRecords,
|
||||
IQuery<IContent>? filter = null,
|
||||
Ordering? ordering = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets paged content items of multiple content types.
|
||||
/// </summary>
|
||||
/// <param name="contentTypeIds">The content type ids. If empty or containing non-existent IDs, returns empty results with totalRecords = 0.</param>
|
||||
/// <param name="pageIndex">Zero-based page index.</param>
|
||||
/// <param name="pageSize">Page size.</param>
|
||||
/// <param name="totalRecords">Output: total number of matching records.</param>
|
||||
/// <param name="filter">Optional filter query.</param>
|
||||
/// <param name="ordering">Optional ordering (defaults to sortOrder).</param>
|
||||
/// <returns>Paged content items.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when contentTypeIds is null.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when pageIndex is negative or pageSize is less than or equal to zero.</exception>
|
||||
IEnumerable<IContent> GetPagedOfTypes(
|
||||
int[] contentTypeIds,
|
||||
long pageIndex,
|
||||
int pageSize,
|
||||
out long totalRecords,
|
||||
IQuery<IContent>? filter = null,
|
||||
Ordering? ordering = null);
|
||||
|
||||
#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));
|
||||
}
|
||||
}
|
||||
@@ -673,6 +673,137 @@ internal sealed class ContentServiceRefactoringTests : UmbracoIntegrationTestWit
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase 2 - Count Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies Count() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Count_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
|
||||
// Act
|
||||
var facadeCount = ContentService.Count();
|
||||
var directCount = queryService.Count();
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeCount, Is.EqualTo(directCount));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies CountPublished() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void CountPublished_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
ContentService.Publish(Textpage, new[] { "*" });
|
||||
|
||||
// Act
|
||||
var facadeCount = ContentService.CountPublished();
|
||||
var directCount = queryService.CountPublished();
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeCount, Is.EqualTo(directCount));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies CountChildren() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void CountChildren_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
var parentId = Textpage.Id;
|
||||
|
||||
// Act
|
||||
var facadeCount = ContentService.CountChildren(parentId);
|
||||
var directCount = queryService.CountChildren(parentId);
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeCount, Is.EqualTo(directCount));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies CountDescendants() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void CountDescendants_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
var parentId = Textpage.Id;
|
||||
|
||||
// Act
|
||||
var facadeCount = ContentService.CountDescendants(parentId);
|
||||
var directCount = queryService.CountDescendants(parentId);
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeCount, Is.EqualTo(directCount));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies GetByLevel() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetByLevel_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
|
||||
// Act
|
||||
var facadeItems = ContentService.GetByLevel(1).ToList();
|
||||
var directItems = queryService.GetByLevel(1).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeItems.Count, Is.EqualTo(directItems.Count));
|
||||
Assert.That(facadeItems.Select(x => x.Id), Is.EquivalentTo(directItems.Select(x => x.Id)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies GetPagedOfType() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetPagedOfType_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
var contentTypeId = ContentType.Id;
|
||||
|
||||
// Act
|
||||
var facadeItems = ContentService.GetPagedOfType(contentTypeId, 0, 10, out var facadeTotal).ToList();
|
||||
var directItems = queryService.GetPagedOfType(contentTypeId, 0, 10, out var directTotal).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeTotal, Is.EqualTo(directTotal));
|
||||
Assert.That(facadeItems.Select(x => x.Id), Is.EquivalentTo(directItems.Select(x => x.Id)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 Test: Verifies GetPagedOfTypes() via facade returns same result as direct service call.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetPagedOfTypes_ViaFacade_ReturnsEquivalentResultToDirectService()
|
||||
{
|
||||
// Arrange
|
||||
var queryService = GetRequiredService<IContentQueryOperationService>();
|
||||
var contentTypeIds = new[] { ContentType.Id };
|
||||
|
||||
// Act
|
||||
var facadeItems = ContentService.GetPagedOfTypes(contentTypeIds, 0, 10, out var facadeTotal, null).ToList();
|
||||
var directItems = queryService.GetPagedOfTypes(contentTypeIds, 0, 10, out var directTotal).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.That(facadeTotal, Is.EqualTo(directTotal));
|
||||
Assert.That(facadeItems.Select(x => x.Id), Is.EquivalentTo(directItems.Select(x => x.Id)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Notification handler that tracks the order of notifications for test verification.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentQueryOperationServiceInterfaceTests.cs
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
|
||||
|
||||
[TestFixture]
|
||||
public class ContentQueryOperationServiceInterfaceTests
|
||||
{
|
||||
[Test]
|
||||
public void IContentQueryOperationService_Interface_Exists()
|
||||
{
|
||||
// Arrange & Act
|
||||
var interfaceType = typeof(IContentQueryOperationService);
|
||||
|
||||
// Assert
|
||||
Assert.That(interfaceType, Is.Not.Null);
|
||||
Assert.That(interfaceType.IsInterface, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IContentQueryOperationService_Extends_IService()
|
||||
{
|
||||
// Arrange
|
||||
var interfaceType = typeof(IContentQueryOperationService);
|
||||
|
||||
// Act & Assert
|
||||
Assert.That(typeof(IService).IsAssignableFrom(interfaceType), Is.True);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user