Skip to content

Commit

Permalink
Merge pull request #316 from Kentico/feat/mapped-class-category-mig
Browse files Browse the repository at this point in the history
Support category migration for mapped classes
  • Loading branch information
akfakmot authored Dec 2, 2024
2 parents f63fd49 + d93af1d commit a23cba6
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System.Collections;

using System.Diagnostics;
using CMS.ContentEngine;
using CMS.ContentEngine.Internal;
using CMS.DataEngine;
Expand All @@ -12,12 +12,14 @@

using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;

using Migration.Tool.Common;
using Migration.Tool.Common.Abstractions;
using Migration.Tool.Common.Builders;
using Migration.Tool.Common.Helpers;
using Migration.Tool.KXP.Api;
using Migration.Tool.Source.Mappers;
using Migration.Tool.Source.Model;
using Migration.Tool.Source.Providers;
using Migration.Tool.Source.Services;

using Newtonsoft.Json;
Expand All @@ -30,7 +32,9 @@ public class MigrateCategoriesCommandHandler(
IImporter importer,
ReusableSchemaService reusableSchemaService,
IUmtMapper<TagModelSource> tagModelMapper,
SpoiledGuidContext spoiledGuidContext
SpoiledGuidContext spoiledGuidContext,
KxpClassFacade kxpClassFacade,
ClassMappingProvider classMappingProvider
) : IRequestHandler<MigrateCategoriesCommand, CommandResult>
{
public async Task<CommandResult> Handle(MigrateCategoriesCommand request, CancellationToken cancellationToken)
Expand All @@ -48,26 +52,42 @@ public async Task<CommandResult> Handle(MigrateCategoriesCommand request, Cancel
if (result.Imported is TaxonomyInfo taxonomy)
{
string query = """
SELECT C.ClassName, C.ClassGuid, C.ClassID
SELECT C.ClassName, C.ClassGuid, C.ClassID, CC.CategoryID
FROM View_CMS_Tree_Joined [TJ]
JOIN dbo.CMS_DocumentCategory [CDC] on [TJ].DocumentID = [CDC].DocumentID
JOIN CMS_Class [C] ON TJ.NodeClassID = [C].ClassID
JOIN dbo.CMS_Category CC on CDC.CategoryID = CC.CategoryID AND CC.CategoryUserID IS NULL
GROUP BY C.ClassName, C.ClassGuid, C.ClassID
GROUP BY C.ClassName, C.ClassGuid, C.ClassID, CC.CategoryID
""";

var classesWithCategories = modelFacade.Select(query, (reader, version) => new { ClassName = reader.Unbox<string>("ClassName"), ClassGuid = reader.Unbox<Guid>("ClassGuid"), ClassID = reader.Unbox<int>("ClassID") });
var classesWithCategories = modelFacade.Select(query, (reader, version) => new { ClassName = reader.Unbox<string>("ClassName"), ClassGuid = reader.Unbox<Guid>("ClassGuid"), ClassID = reader.Unbox<int>("ClassID"), CategoryID = reader.Unbox<int>("CategoryID") })
.GroupBy(x => x.ClassGuid)
.Select(x => new { ClassGuid = x.Key, x.First().ClassName, x.First().ClassID, Categories = x.Select(row => row.CategoryID) });

// For each source instance class whose documents have some categories assigned, include taxonomy-storage reusable schema in the target class
#region Ensure reusable schema
var skippedClasses = new List<int>();
var schemaGuid = Guid.Empty;
string categoryFieldName = "Category_Legacy";
foreach (var classWithCategoryUsage in classesWithCategories)
{
var targetDataClass = DataClassInfoProvider.ProviderObject.Get(classWithCategoryUsage.ClassGuid);
if (targetDataClass == null)
if (targetDataClass is null)
{
skippedClasses.Add(classWithCategoryUsage.ClassID);
logger.LogWarning("Data class not found by ClassGuid {Guid}", classWithCategoryUsage.ClassGuid);
// No direct-mapped target class found. Try to identify custom-mapped target class
var classMapping = classMappingProvider.GetMapping(classWithCategoryUsage.ClassName);
if (classMapping is not null)
{
if (classWithCategoryUsage.Categories.Any(cat => classMapping.IsCategoryMapped(classWithCategoryUsage.ClassName, cat)))
{
targetDataClass = kxpClassFacade.GetClass(classMapping.TargetClassName);
}
}
}

if (targetDataClass is null)
{
logger.LogWarning($"Class(ClassGuid {{Guid}}) has documents with categories, but no directly-mapped data class nor custom-mapped class that would receive the categories (declared via {nameof(IClassMapping.IsCategoryMapped)}) was found", classWithCategoryUsage.ClassGuid);
continue;
}

Expand All @@ -82,6 +102,8 @@ FROM View_CMS_Tree_Joined [TJ]
DataClassInfoProvider.SetDataClassInfo(targetDataClass);
}
}
#endregion


var categories = modelFacade.Select<ICmsCategory>(
"CategoryEnabled = 1 AND CategoryUserID IS NULL",
Expand All @@ -95,23 +117,24 @@ FROM View_CMS_Tree_Joined [TJ]
categoryId2Guid.Add(cmsCategory.CategoryID, cmsCategory.CategoryGUID);
// CategorySiteID - not migrated, Taxonomies are global!

var mapped = tagModelMapper.Map(new TagModelSource(
var tagUMTModels = tagModelMapper.Map(new TagModelSource(
taxonomy.TaxonomyGUID,
cmsCategory,
categoryId2Guid
));

foreach (var umtModel in mapped)
foreach (var tagUMTModel in tagUMTModels)
{
if (await importer
.ImportAsync(umtModel)
.ImportAsync(tagUMTModel)
.AssertSuccess<TagInfo>(logger) is { Success: true, Info: { } tag })
{
query = """
SELECT TJ.DocumentGUID, TJ.NodeSiteID, TJ.NodeID, TJ.DocumentID, CDC.CategoryID, TJ.DocumentCheckedOutVersionHistoryID, TJ.NodeClassID
SELECT TJ.DocumentGUID, TJ.NodeSiteID, TJ.NodeID, TJ.DocumentID, CDC.CategoryID, TJ.DocumentCheckedOutVersionHistoryID, TJ.NodeClassID, C.ClassName
FROM View_CMS_Tree_Joined [TJ]
JOIN dbo.CMS_DocumentCategory [CDC] on [TJ].DocumentID = [CDC].DocumentID
JOIN dbo.CMS_Category CC on CDC.CategoryID = CC.CategoryID AND CC.CategoryUserID IS NULL
JOIN CMS_Class [C] ON TJ.NodeClassID = [C].ClassID
WHERE CDC.CategoryID = @categoryId
""";

Expand All @@ -120,6 +143,7 @@ FROM View_CMS_Tree_Joined [TJ]
CategoryID = reader.Unbox<int?>("CategoryID"),
DocumentCheckedOutVersionHistoryID = reader.Unbox<int?>("DocumentCheckedOutVersionHistoryID"),
NodeClassID = reader.Unbox<int>("NodeClassID"),
NodeClassName = reader.Unbox<string>("ClassName"),
NodeSiteID = reader.Unbox<int>("NodeSiteID"),
DocumentGUID = spoiledGuidContext.EnsureDocumentGuid(
reader.Unbox<Guid>("DocumentGUID"),
Expand All @@ -137,13 +161,23 @@ FROM View_CMS_Tree_Joined [TJ]
continue;
}

var classMapping = classMappingProvider.GetMapping(dwc.NodeClassName);
if (classMapping is not null)
{
Debug.Assert(dwc.CategoryID.HasValue, "dwc.CategoryID should have value, otherwise the row would not be included in the query due to inner join");
if (!classMapping.IsCategoryMapped(dwc.NodeClassName, dwc.CategoryID.Value))
{
continue;
}
}

var commonData = ContentItemCommonDataInfo.Provider.Get()
.WhereEquals(nameof(ContentItemCommonDataInfo.ContentItemCommonDataGUID), dwc.DocumentGUID)
.FirstOrDefault();

if (commonData is null)
{
logger.LogWarning("ContentItemCommonDataInfo not found by guid {Guid}, taxonomy cannot be migrated", dwc.DocumentGUID);
logger.LogWarning("ContentItemCommonDataInfo not found by Guid {Guid}. Taxonomy cannot be migrated", dwc.DocumentGUID);
continue;
}

Expand Down
8 changes: 7 additions & 1 deletion Migration.Tool.Common/Builders/ClassMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public interface IClassMapping

string? GetTargetFieldName(string sourceColumnName, string sourceClassName);
string GetSourceFieldName(string targetColumnName, string nodeClassClassName);

bool IsCategoryMapped(string sourceClassName, int categoryID);
void UseResusableSchema(string reusableSchemaName);
IList<string> ReusableSchemaNames { get; }
}
Expand Down Expand Up @@ -89,10 +89,16 @@ public void UseResusableSchema(string reusableSchemaName)
reusableSchemaNames.Add(reusableSchemaName);
}

private MultiClassMappingCategoryFilter categoryFilter = (_, _) => true;
public void FilterCategories(MultiClassMappingCategoryFilter filter) => categoryFilter = filter;
public bool IsCategoryMapped(string sourceClassName, int categoryID) => categoryFilter(sourceClassName, categoryID);

private readonly IList<string> reusableSchemaNames = [];
IList<string> IClassMapping.ReusableSchemaNames => reusableSchemaNames;
}

public delegate bool MultiClassMappingCategoryFilter(string sourceClassName, int categoryID);

public interface IConvertorContext;
public record ConvertorTreeNodeContext(Guid NodeGuid, int NodeSiteId, int? DocumentId, bool MigratingFromVersionHistory) : IConvertorContext;

Expand Down
45 changes: 45 additions & 0 deletions Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,51 @@ public static IServiceCollection AddClassMergeExample(this IServiceCollection se
return serviceCollection;
}

/// <summary>
/// This sample uses data from the official Kentico 11 e-shop demo
/// </summary>
public static IServiceCollection AddControlledCategoryMigrationSample(this IServiceCollection serviceCollection)
{
const string targetClassName = "Eshop.BookRemodeled";
// declare target class
var m = new MultiClassMapping(targetClassName, target =>
{
target.ClassName = targetClassName;
target.ClassTableName = "Eshop_BookRemodeled";
target.ClassDisplayName = "Book remodeled";
target.ClassType = ClassType.CONTENT_TYPE;
target.ClassContentTypeType = ClassContentTypeType.REUSABLE;
target.ClassWebPageHasUrl = false;
});

// set new primary key
m.BuildField("BookRemodeledID").AsPrimaryKey();

// change fields according to new requirements
const string sourceClassName1 = "CMSProduct.Book";

// field clone sample
m
.BuildField("BookAuthor")
.SetFrom(sourceClassName1, "BookAuthor", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Author's name"));

m
.BuildField("BookISBN")
.SetFrom(sourceClassName1, "BookISBN", true)
.WithFieldPatch(f => f.Caption = "ISBN");

// category migration refinement sample
const int EbookCategoryID = 17;
int[] excludedCategories = [EbookCategoryID,];
m.FilterCategories((className, categoryID) => !excludedCategories.Contains(categoryID));

// register class mapping
serviceCollection.AddSingleton<IClassMapping>(m);

return serviceCollection;
}

public static IServiceCollection AddReusableSchemaIntegrationSample(this IServiceCollection serviceCollection)
{
const string schemaNameDgcCommon = "DGC.Address";
Expand Down
9 changes: 8 additions & 1 deletion Migration.Tool.Extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ Example code is found in the method `AddClassMergeExample`.

The goal of this method is to take **multiple data classes** from the source instance and define their relation to a new class.

### Class mapping with category control

Example code is found in the method `AddK11EshopExample`.

The goal of this method is to show how to **control migration of categories**. You can enable/disable the migration based on category ID and/or source class name.
This is useful when merging multiple data classes into one (see _Class merge sample_)

### Example

Let's define a new class:
Expand Down Expand Up @@ -95,7 +102,7 @@ Finally, let's define relations to fields:
startDate.WithFieldPatch(f => f.Caption = "Event start date");
```

1. register class mapping to dependency injection ocntainer
1. register class mapping to dependency injection container

```csharp
serviceCollection.AddSingleton<IClassMapping>(m);
Expand Down

0 comments on commit a23cba6

Please sign in to comment.