From a1cba4dfb6a8b9b02b4a89b5e23b1969a2302a30 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 7 May 2026 13:54:17 -0500 Subject: [PATCH 1/3] The fix --- .../Services/CategoryMover.cs | 4 +- .../Services/CategoryMover.cs | 172 +++++++++++++++++- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/samples/VirtoCommerce.CatalogModule2.Web/Services/CategoryMover.cs b/samples/VirtoCommerce.CatalogModule2.Web/Services/CategoryMover.cs index 4de5e26fe..174baa81d 100644 --- a/samples/VirtoCommerce.CatalogModule2.Web/Services/CategoryMover.cs +++ b/samples/VirtoCommerce.CatalogModule2.Web/Services/CategoryMover.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using VirtoCommerce.CatalogModule.Core.Model; +using VirtoCommerce.CatalogModule.Core.Search; using VirtoCommerce.CatalogModule.Core.Services; using VirtoCommerce.CatalogModule.Data.Services; @@ -8,7 +9,8 @@ namespace VirtoCommerce.CatalogModule2.Data.Services { public class CategoryMover2 : CategoryMover { - public CategoryMover2(ICategoryService categoryService) : base(categoryService) + public CategoryMover2(ICategoryService categoryService, ICategorySearchService categorySearchService, IProductSearchService productSearchService, IItemService itemService) + : base(categoryService, categorySearchService, productSearchService, itemService) { } public override Task ConfirmMoveAsync(IEnumerable entities) diff --git a/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs b/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs index ac9cd9645..f029616a0 100644 --- a/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs +++ b/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs @@ -5,6 +5,8 @@ using FluentValidation; using VirtoCommerce.CatalogModule.Core.Model; using VirtoCommerce.CatalogModule.Core.Model.ListEntry; +using VirtoCommerce.CatalogModule.Core.Model.Search; +using VirtoCommerce.CatalogModule.Core.Search; using VirtoCommerce.CatalogModule.Core.Services; using VirtoCommerce.CatalogModule.Data.Validation; using VirtoCommerce.Platform.Core.Common; @@ -13,22 +15,45 @@ namespace VirtoCommerce.CatalogModule.Data.Services { public class CategoryMover : ListEntryMover { + private const int CategoryPageSize = 200; + private const int ProductPageSize = 50; + private readonly ICategoryService _categoryService; + private readonly ICategorySearchService _categorySearchService; + private readonly IProductSearchService _productSearchService; + private readonly IItemService _itemService; /// /// Initializes a new instance of the class. /// - /// - /// The category service. - /// - public CategoryMover(ICategoryService categoryService) + public CategoryMover( + ICategoryService categoryService, + ICategorySearchService categorySearchService, + IProductSearchService productSearchService, + IItemService itemService) { _categoryService = categoryService; + _categorySearchService = categorySearchService; + _productSearchService = productSearchService; + _itemService = itemService; } - public override Task ConfirmMoveAsync(IEnumerable entities) + public override async Task ConfirmMoveAsync(IEnumerable entities) { - return _categoryService.SaveChangesAsync(entities.ToArray()); + var categories = entities as IList ?? entities.ToList(); + if (categories.Count == 0) + { + return; + } + + await _categoryService.SaveChangesAsync(categories.ToArray()); + + // Cascade CatalogId to products that live under any of the moved categories. + // Every entity here carries the target CatalogId (set in PrepareMoveAsync), and the + // per-product filter in CascadeProductsAsync makes same-catalog reparents a cheap no-op. + var targetCatalogId = categories[0].CatalogId; + var categoryIds = categories.Select(x => x.Id).ToArray(); + await CascadeProductsAsync(categoryIds, targetCatalogId); } public override async Task> PrepareMoveAsync(ListEntriesMoveRequest moveInfo) @@ -37,12 +62,24 @@ public override async Task> PrepareMoveAsync(ListEntriesMoveReque var result = new List(); + // Cross-catalog roots grouped by their original (source) CatalogId so we can load + // descendants per source catalog with a single paged search each. + var crossCatalogRoots = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var listEntryCategory in moveInfo.ListEntries.Where( listEntry => listEntry.Type.EqualsIgnoreCase(CategoryListEntry.TypeName))) { var category = await _categoryService.GetByIdAsync(listEntryCategory.Id, CategoryResponseGroup.Info.ToString()); + if (category.CatalogId != moveInfo.Catalog) { + if (!crossCatalogRoots.TryGetValue(category.CatalogId, out var roots)) + { + roots = new List(); + crossCatalogRoots.Add(category.CatalogId, roots); + } + roots.Add(category.Id); + category.CatalogId = moveInfo.Catalog; } @@ -54,6 +91,16 @@ public override async Task> PrepareMoveAsync(ListEntriesMoveReque result.Add(category); } + if (crossCatalogRoots.Count > 0) + { + var descendants = await LoadDescendantsAsync(crossCatalogRoots); + foreach (var descendant in descendants) + { + descendant.CatalogId = moveInfo.Catalog; + result.Add(descendant); + } + } + return result; } @@ -67,5 +114,118 @@ protected virtual async Task ValidateOperationArguments(ListEntriesMoveRequest m var validator = new ListEntriesMoveRequestValidator(_categoryService); await validator.ValidateAndThrowAsync(moveInfo); } + + private async Task> LoadDescendantsAsync(Dictionary> rootsBySourceCatalog) + { + var all = new List(); + + foreach (var pair in rootsBySourceCatalog) + { + var sourceCatalogId = pair.Key; + var rootIds = pair.Value; + + // Page through the entire source catalog and build a parent -> children map in memory. + // ICategorySearchService doesn't accept multiple parent ids, so a single catalog-wide + // load is more efficient than one search per node when the moved subtree is non-trivial. + var byParent = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var skip = 0; + while (true) + { + var criteria = AbstractTypeFactory.TryCreateInstance(); + criteria.CatalogId = sourceCatalogId; + criteria.ResponseGroup = CategoryResponseGroup.Info.ToString(); + criteria.Skip = skip; + criteria.Take = CategoryPageSize; + + var page = await _categorySearchService.SearchAsync(criteria); + if (page.Results.Count == 0) + { + break; + } + + foreach (var category in page.Results) + { + var parentKey = category.ParentId ?? string.Empty; + if (!byParent.TryGetValue(parentKey, out var children)) + { + children = new List(); + byParent[parentKey] = children; + } + children.Add(category); + } + + if (page.Results.Count < CategoryPageSize) + { + break; + } + skip += CategoryPageSize; + } + + // BFS from each moved root. Roots themselves are seeded as visited so we don't + // re-add them (they're already in the result list of PrepareMoveAsync). + var visited = new HashSet(rootIds, StringComparer.OrdinalIgnoreCase); + var queue = new Queue(rootIds); + while (queue.Count > 0) + { + var parentId = queue.Dequeue(); + if (byParent.TryGetValue(parentId, out var children)) + { + foreach (var child in children) + { + if (visited.Add(child.Id)) + { + all.Add(child); + queue.Enqueue(child.Id); + } + } + } + } + } + + return all; + } + + private async Task CascadeProductsAsync(IList categoryIds, string targetCatalogId) + { + foreach (var idsChunk in categoryIds.Chunk(ProductPageSize)) + { + var skip = 0; + while (true) + { + var criteria = AbstractTypeFactory.TryCreateInstance(); + criteria.CategoryIds = idsChunk; + criteria.SearchInVariations = true; + criteria.ResponseGroup = ItemResponseGroup.ItemLarge.ToString(); + criteria.Skip = skip; + criteria.Take = ProductPageSize; + + var page = await _productSearchService.SearchAsync(criteria); + if (page.Results.Count == 0) + { + break; + } + + var toSave = page.Results + .Where(x => x.CatalogId != targetCatalogId) + .ToList(); + + foreach (var product in toSave) + { + product.CatalogId = targetCatalogId; + } + + if (toSave.Count > 0) + { + await _itemService.SaveChangesAsync(toSave.ToArray()); + } + + if (page.Results.Count < ProductPageSize) + { + break; + } + skip += ProductPageSize; + } + } + } } } From 18055150d7058f169ccef4223aa0530cb3c59c49 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 7 May 2026 14:10:59 -0500 Subject: [PATCH 2/3] Refactoring (Sonar) --- .../Services/CategoryMover.cs | 122 +++++++++++------- 1 file changed, 72 insertions(+), 50 deletions(-) diff --git a/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs b/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs index f029616a0..5eb16fddb 100644 --- a/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs +++ b/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs @@ -121,68 +121,90 @@ private async Task> LoadDescendantsAsync(Dictionary children map in memory. - // ICategorySearchService doesn't accept multiple parent ids, so a single catalog-wide - // load is more efficient than one search per node when the moved subtree is non-trivial. - var byParent = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var skip = 0; - while (true) + return all; + } + + // Page through the entire source catalog and build a parent -> children map in memory. + // ICategorySearchService doesn't accept multiple parent ids, so a single catalog-wide + // load is more efficient than one search per node when the moved subtree is non-trivial. + private async Task>> BuildParentChildrenMapAsync(string sourceCatalogId) + { + var byParent = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var skip = 0; + + while (true) + { + var page = await SearchCategoryPageAsync(sourceCatalogId, skip); + if (page.Count == 0) { - var criteria = AbstractTypeFactory.TryCreateInstance(); - criteria.CatalogId = sourceCatalogId; - criteria.ResponseGroup = CategoryResponseGroup.Info.ToString(); - criteria.Skip = skip; - criteria.Take = CategoryPageSize; + break; + } - var page = await _categorySearchService.SearchAsync(criteria); - if (page.Results.Count == 0) - { - break; - } + foreach (var category in page) + { + AddToParentMap(byParent, category); + } - foreach (var category in page.Results) - { - var parentKey = category.ParentId ?? string.Empty; - if (!byParent.TryGetValue(parentKey, out var children)) - { - children = new List(); - byParent[parentKey] = children; - } - children.Add(category); - } + if (page.Count < CategoryPageSize) + { + break; + } + skip += CategoryPageSize; + } - if (page.Results.Count < CategoryPageSize) - { - break; - } - skip += CategoryPageSize; + return byParent; + } + + private async Task> SearchCategoryPageAsync(string sourceCatalogId, int skip) + { + var criteria = AbstractTypeFactory.TryCreateInstance(); + criteria.CatalogId = sourceCatalogId; + criteria.ResponseGroup = CategoryResponseGroup.Info.ToString(); + criteria.Skip = skip; + criteria.Take = CategoryPageSize; + + var result = await _categorySearchService.SearchAsync(criteria); + return result.Results; + } + + private static void AddToParentMap(Dictionary> byParent, Category category) + { + var parentKey = category.ParentId ?? string.Empty; + if (!byParent.TryGetValue(parentKey, out var children)) + { + byParent[parentKey] = children = new List(); + } + children.Add(category); + } + + // BFS from each moved root. Roots themselves are seeded as visited so we don't re-add + // them (they're already in the result list of PrepareMoveAsync). + private static void CollectDescendants(Dictionary> byParent, IList rootIds, List output) + { + var visited = new HashSet(rootIds, StringComparer.OrdinalIgnoreCase); + var queue = new Queue(rootIds); + + while (queue.Count > 0) + { + var parentId = queue.Dequeue(); + if (!byParent.TryGetValue(parentId, out var children)) + { + continue; } - // BFS from each moved root. Roots themselves are seeded as visited so we don't - // re-add them (they're already in the result list of PrepareMoveAsync). - var visited = new HashSet(rootIds, StringComparer.OrdinalIgnoreCase); - var queue = new Queue(rootIds); - while (queue.Count > 0) + foreach (var child in children) { - var parentId = queue.Dequeue(); - if (byParent.TryGetValue(parentId, out var children)) + if (visited.Add(child.Id)) { - foreach (var child in children) - { - if (visited.Add(child.Id)) - { - all.Add(child); - queue.Enqueue(child.Id); - } - } + output.Add(child); + queue.Enqueue(child.Id); } } } - - return all; } private async Task CascadeProductsAsync(IList categoryIds, string targetCatalogId) From c1a359e74eadefbe67801bff4e2296d9b9d8df1c Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 7 May 2026 14:18:52 -0500 Subject: [PATCH 3/3] Refactoring (Sonar) --- .../Services/CategoryMover.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs b/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs index 5eb16fddb..3747c8606 100644 --- a/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs +++ b/src/VirtoCommerce.CatalogModule.Data/Services/CategoryMover.cs @@ -196,13 +196,11 @@ private static void CollectDescendants(Dictionary> byPare continue; } - foreach (var child in children) + // visited.Add returns true for ids not seen yet; the side-effect runs as Where enumerates. + foreach (var child in children.Where(x => visited.Add(x.Id))) { - if (visited.Add(child.Id)) - { - output.Add(child); - queue.Enqueue(child.Id); - } + output.Add(child); + queue.Enqueue(child.Id); } } }