From 849fdf0dc2f60683d97e826283f0cae847da6952 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:43:05 +0200 Subject: [PATCH 1/6] Add migration to create missing tabs In v13, if a tab had groups in both a composition and the content type, the tab might not exist on the content type itself. Newer versions require such tabs to also exist directly on the content type. This migration ensures those tabs are created. Also fixes an issue in LeftJoin where nested sql arguments were being discarded. --- .../Migrations/Upgrade/UmbracoPlan.cs | 3 + .../Upgrade/V_16_4_0/CreateMissingTabs.cs | 118 ++++++++++++++++++ .../Persistence/NPocoSqlExtensions.cs | 26 +++- 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 39917cabe543..76d6737c59ff 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -127,5 +127,8 @@ protected virtual void DefinePlan() // To 16.3.0 To("{A917FCBC-C378-4A08-A36C-220C581A6581}"); To("{FB7073AF-DFAF-4AC1-800D-91F9BD5B5238}"); + + // To 16.4.0 + To("{6A7D3B80-8B64-4E41-A7C0-02EC39336E97}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs new file mode 100644 index 000000000000..0a9477c78b4a --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs @@ -0,0 +1,118 @@ +using NPoco; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0; + +/// +/// Creates missing tabs on content types when a tab is referenced by both a composition and the content type's own groups. +/// +/// +/// In v13, if a tab had groups in both a composition and the content type, the tab might not exist on the content type itself. +/// Newer versions require such tabs to also exist directly on the content type. This migration ensures those tabs are created. +/// +[Obsolete("Remove in Umbraco 18.")] +public class CreateMissingTabs : UnscopedAsyncMigrationBase +{ + private readonly IScopeProvider _scopeProvider; + + public CreateMissingTabs(IMigrationContext context, IScopeProvider scopeProvider) + : base(context) => _scopeProvider = scopeProvider; + + protected override async Task MigrateAsync() + { + using IScope scope = _scopeProvider.CreateScope(); + + // 1. Find all property groups (type 0) and extract their tab alias (the part before the first '/'). + // This helps identify which groups are referencing tabs. + Sql groupsSql = Database.SqlContext.Sql() + .SelectDistinct("g", pt => pt.ContentTypeNodeId) + .AndSelect(GetTabAliasQuery("g.alias") + " AS tabAlias") + .From(alias: "p") + .InnerJoin(alias: "g").On( + (pt, ptg) => pt.PropertyTypeGroupId == ptg.Id && pt.ContentTypeId == ptg.ContentTypeNodeId, + aliasLeft: "p", + "g") + .Where(x => x.Type == 0, alias: "g") + .Where(CheckContainsTabAliasQuery("g.alias")); + + // 2. Get all existing tabs (type 1) for all content types. + Sql tabsSql = Database.SqlContext.Sql() + .Select("g2", g => g.UniqueId, g => g.ContentTypeNodeId, g => g.Alias) + .From(alias: "g2") + .Where(x => x.Type == 1, alias: "g2"); + + // 3. Identify groups that reference a tab alias which does not exist as a tab for their content type. + // These are the "missing tabs" that need to be created. + Sql missingTabsSql = Database.SqlContext.Sql() + .Select("groups", g => g.ContentTypeNodeId) + .AndSelect("groups.tabAlias") + .From() + .AppendSubQuery(groupsSql, "groups") + .LeftJoin(tabsSql, "tabs") + .On("groups.ContentTypeNodeId = tabs.ContentTypeNodeId AND tabs.alias = groups.tabAlias") + .WhereNull(ptg => ptg.UniqueId, "tabs"); + + // 4. For each missing tab, find the corresponding tab details (text, alias, sort order) + // from the parent content type (composition) that already has this tab. + Sql missingTabsWithDetailsSql = Database.SqlContext.Sql() + .SelectDistinct("missingTabs", ptg => ptg.ContentTypeNodeId) + .AndSelect("tg", ptg => ptg.Text, ptg => ptg.Alias, ptg => ptg.SortOrder) + .From() + .AppendSubQuery(missingTabsSql, "missingTabs") + .InnerJoin(alias: "ct2ct") + .On( + (ptg, ct2Ct) => ptg.ContentTypeNodeId == ct2Ct.ChildId, + "missingTabs", + "ct2ct") + .InnerJoin(alias: "tg") + .On( + (ct2Ct, ptg) => ct2Ct.ParentId == ptg.ContentTypeNodeId, + "ct2ct", + "tg") + .Append("AND tg.alias = missingTabs.tabAlias"); + + List missingTabsWithDetails = + await Database.FetchAsync(missingTabsWithDetailsSql); + + // 5. Create and insert new tab records for each missing tab, using the details from the parent/composition. + IEnumerable newTabs = missingTabsWithDetails + .Select(missingTabWithDetails => new PropertyTypeGroupDto + { + UniqueId = Guid.CreateVersion7(), + ContentTypeNodeId = missingTabWithDetails.ContentTypeNodeId, + Type = 1, + Text = missingTabWithDetails.Text, + Alias = missingTabWithDetails.Alias, + SortOrder = missingTabWithDetails.SortOrder, + }); + await Database.InsertBatchAsync(newTabs); + + // 6. Complete the migration and commit the transaction. + Context.Complete(); + scope.Complete(); + } + + private string GetTabAliasQuery(string columnName) => + DatabaseType == DatabaseType.SQLite + ? $"substr({columnName}, 0, INSTR({columnName},'/'))" + : $"SUBSTRING({columnName}, 1, CHARINDEX('/', {columnName}) - 1)"; + + private string CheckContainsTabAliasQuery(string columnName) => + DatabaseType == DatabaseType.SQLite + ? $"INSTR({columnName}, '/') > 0" + : $"CHARINDEX('/', {columnName}) > 0"; + + private class MissingTabWithDetails + { + public required int ContentTypeNodeId { get; set; } + + public required string Alias { get; set; } + + public required string Text { get; set; } + + public required int SortOrder { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs index d2c7aa87ffd5..cdc80c6c1fd0 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs @@ -548,7 +548,8 @@ public static Sql.SqlJoinClause LeftJoin(this Sql.SqlJoinClause(sql); } /// @@ -801,6 +802,29 @@ public static Sql SelectDistinct(this Sql sql, p return sql; } + /// + /// Creates a SELECT DISTINCT Sql statement. + /// + /// The type of the DTO to select. + /// The origin sql. + /// A table alias. + /// Expressions indicating the columns to select. + /// The Sql statement. + /// + /// If is empty, all columns are selected. + /// + public static Sql SelectDistinct(this Sql sql, string tableAlias, params Expression>[] fields) + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + var columns = sql.GetColumns(tableAlias: tableAlias, columnExpressions: fields); + sql.Append("SELECT DISTINCT " + string.Join(", ", columns)); + return sql; + } + public static Sql SelectDistinct(this Sql sql, params object[] columns) { sql.Append("SELECT DISTINCT " + string.Join(", ", columns)); From 57b475ce5b466067fba62dadf9536570adee5387 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:39:48 +0200 Subject: [PATCH 2/6] Small fixes --- .../Upgrade/V_16_4_0/CreateMissingTabs.cs | 15 ++++++++------ .../Persistence/NPocoSqlExtensions.cs | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs index 0a9477c78b4a..111ff4aa3891 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs @@ -36,7 +36,7 @@ protected override async Task MigrateAsync() aliasLeft: "p", "g") .Where(x => x.Type == 0, alias: "g") - .Where(CheckContainsTabAliasQuery("g.alias")); + .Where(CheckIfContainsTabAliasQuery("g.alias")); // 2. Get all existing tabs (type 1) for all content types. Sql tabsSql = Database.SqlContext.Sql() @@ -58,8 +58,9 @@ protected override async Task MigrateAsync() // 4. For each missing tab, find the corresponding tab details (text, alias, sort order) // from the parent content type (composition) that already has this tab. Sql missingTabsWithDetailsSql = Database.SqlContext.Sql() - .SelectDistinct("missingTabs", ptg => ptg.ContentTypeNodeId) - .AndSelect("tg", ptg => ptg.Text, ptg => ptg.Alias, ptg => ptg.SortOrder) + .Select("missingTabs", ptg => ptg.ContentTypeNodeId) + .AndSelect("tg", ptg => ptg.Alias) + .AndSelect("MIN(text) AS text", "MIN(sortorder) AS sortOrder") .From() .AppendSubQuery(missingTabsSql, "missingTabs") .InnerJoin(alias: "ct2ct") @@ -72,7 +73,9 @@ protected override async Task MigrateAsync() (ct2Ct, ptg) => ct2Ct.ParentId == ptg.ContentTypeNodeId, "ct2ct", "tg") - .Append("AND tg.alias = missingTabs.tabAlias"); + .Append("AND tg.alias = missingTabs.tabAlias") + .GroupBy("missingTabs", ptg => ptg.ContentTypeNodeId) + .AndBy("tg", ptg => ptg.Alias); List missingTabsWithDetails = await Database.FetchAsync(missingTabsWithDetailsSql); @@ -97,10 +100,10 @@ protected override async Task MigrateAsync() private string GetTabAliasQuery(string columnName) => DatabaseType == DatabaseType.SQLite - ? $"substr({columnName}, 0, INSTR({columnName},'/'))" + ? $"substr({columnName}, 1, INSTR({columnName},'/') - 1)" : $"SUBSTRING({columnName}, 1, CHARINDEX('/', {columnName}) - 1)"; - private string CheckContainsTabAliasQuery(string columnName) => + private string CheckIfContainsTabAliasQuery(string columnName) => DatabaseType == DatabaseType.SQLite ? $"INSTR({columnName}, '/') > 0" : $"CHARINDEX('/', {columnName}) > 0"; diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs index cdc80c6c1fd0..bf2e1eb72377 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs @@ -393,6 +393,26 @@ public static Sql GroupBy(this Sql sql, params E return sql.GroupBy(columns); } + /// + /// Appends a GROUP BY clause to the Sql statement. + /// + /// The type of the Dto. + /// The Sql statement. + /// A table alias. + /// Expression specifying the fields. + /// The Sql statement. + public static Sql GroupBy( + this Sql sql, + string tableAlias, + params Expression>[] fields) + { + ISqlSyntaxProvider sqlSyntax = sql.SqlContext.SqlSyntax; + var columns = fields.Length == 0 + ? sql.GetColumns(withAlias: false) + : fields.Select(x => sqlSyntax.GetFieldName(x, tableAlias)).ToArray(); + return sql.GroupBy(columns); + } + /// /// Appends more ORDER BY or GROUP BY fields to the Sql statement. /// From cac4481ceb6a0767e35a4eb5669d1975ad7df0fe Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 30 Sep 2025 16:22:34 +0200 Subject: [PATCH 3/6] WIP: Integration test. --- .../Upgrade/V_16_4_0/CreateMissingTabs.cs | 50 +++---- .../Builders/PropertyGroupBuilder.cs | 11 +- .../Upgrade/V_16_4_0/CreateMissingTabsTest.cs | 124 ++++++++++++++++++ 3 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs index 111ff4aa3891..492ea496f16d 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs @@ -14,39 +14,49 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0; /// Newer versions require such tabs to also exist directly on the content type. This migration ensures those tabs are created. /// [Obsolete("Remove in Umbraco 18.")] -public class CreateMissingTabs : UnscopedAsyncMigrationBase +public class CreateMissingTabs : AsyncMigrationBase { - private readonly IScopeProvider _scopeProvider; - - public CreateMissingTabs(IMigrationContext context, IScopeProvider scopeProvider) - : base(context) => _scopeProvider = scopeProvider; + public CreateMissingTabs(IMigrationContext context) + : base(context) + { + } protected override async Task MigrateAsync() { - using IScope scope = _scopeProvider.CreateScope(); + await ExecuteMigration(Database); + Context.Complete(); + } + /// + /// Performs the migration to create missing tabs. + /// + /// + /// Extracted into an internal static method to support integration testing. + /// + internal static async Task ExecuteMigration(IUmbracoDatabase database) + { // 1. Find all property groups (type 0) and extract their tab alias (the part before the first '/'). // This helps identify which groups are referencing tabs. - Sql groupsSql = Database.SqlContext.Sql() + Sql groupsSql = database.SqlContext.Sql() .SelectDistinct("g", pt => pt.ContentTypeNodeId) - .AndSelect(GetTabAliasQuery("g.alias") + " AS tabAlias") + .AndSelect(GetTabAliasQuery(database.DatabaseType, "g.alias") + " AS tabAlias") .From(alias: "p") .InnerJoin(alias: "g").On( (pt, ptg) => pt.PropertyTypeGroupId == ptg.Id && pt.ContentTypeId == ptg.ContentTypeNodeId, aliasLeft: "p", "g") .Where(x => x.Type == 0, alias: "g") - .Where(CheckIfContainsTabAliasQuery("g.alias")); + .Where(CheckIfContainsTabAliasQuery(database.DatabaseType, "g.alias")); // 2. Get all existing tabs (type 1) for all content types. - Sql tabsSql = Database.SqlContext.Sql() + Sql tabsSql = database.SqlContext.Sql() .Select("g2", g => g.UniqueId, g => g.ContentTypeNodeId, g => g.Alias) .From(alias: "g2") .Where(x => x.Type == 1, alias: "g2"); // 3. Identify groups that reference a tab alias which does not exist as a tab for their content type. // These are the "missing tabs" that need to be created. - Sql missingTabsSql = Database.SqlContext.Sql() + Sql missingTabsSql = database.SqlContext.Sql() .Select("groups", g => g.ContentTypeNodeId) .AndSelect("groups.tabAlias") .From() @@ -57,7 +67,7 @@ protected override async Task MigrateAsync() // 4. For each missing tab, find the corresponding tab details (text, alias, sort order) // from the parent content type (composition) that already has this tab. - Sql missingTabsWithDetailsSql = Database.SqlContext.Sql() + Sql missingTabsWithDetailsSql = database.SqlContext.Sql() .Select("missingTabs", ptg => ptg.ContentTypeNodeId) .AndSelect("tg", ptg => ptg.Alias) .AndSelect("MIN(text) AS text", "MIN(sortorder) AS sortOrder") @@ -78,7 +88,7 @@ protected override async Task MigrateAsync() .AndBy("tg", ptg => ptg.Alias); List missingTabsWithDetails = - await Database.FetchAsync(missingTabsWithDetailsSql); + await database.FetchAsync(missingTabsWithDetailsSql); // 5. Create and insert new tab records for each missing tab, using the details from the parent/composition. IEnumerable newTabs = missingTabsWithDetails @@ -91,20 +101,16 @@ protected override async Task MigrateAsync() Alias = missingTabWithDetails.Alias, SortOrder = missingTabWithDetails.SortOrder, }); - await Database.InsertBatchAsync(newTabs); - - // 6. Complete the migration and commit the transaction. - Context.Complete(); - scope.Complete(); + await database.InsertBatchAsync(newTabs); } - private string GetTabAliasQuery(string columnName) => - DatabaseType == DatabaseType.SQLite + private static string GetTabAliasQuery(DatabaseType databaseType, string columnName) => + databaseType == DatabaseType.SQLite ? $"substr({columnName}, 1, INSTR({columnName},'/') - 1)" : $"SUBSTRING({columnName}, 1, CHARINDEX('/', {columnName}) - 1)"; - private string CheckIfContainsTabAliasQuery(string columnName) => - DatabaseType == DatabaseType.SQLite + private static string CheckIfContainsTabAliasQuery(DatabaseType databaseType, string columnName) => + databaseType == DatabaseType.SQLite ? $"INSTR({columnName}, '/') > 0" : $"CHARINDEX('/', {columnName}) > 0"; diff --git a/tests/Umbraco.Tests.Common/Builders/PropertyGroupBuilder.cs b/tests/Umbraco.Tests.Common/Builders/PropertyGroupBuilder.cs index 5a958f1389a9..39732f4fe8bf 100644 --- a/tests/Umbraco.Tests.Common/Builders/PropertyGroupBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/PropertyGroupBuilder.cs @@ -45,6 +45,7 @@ public class PropertyGroupBuilder private int? _sortOrder; private bool? _supportsPublishing; private DateTime? _updateDate; + private PropertyGroupType? _type; public PropertyGroupBuilder(TParent parentBuilder) : base(parentBuilder) @@ -99,6 +100,12 @@ string IWithNameBuilder.Name set => _updateDate = value; } + public PropertyGroupBuilder WithType(PropertyGroupType type) + { + _type = type; + return this; + } + public PropertyGroupBuilder WithPropertyTypeCollection(PropertyTypeCollection propertyTypeCollection) { _propertyTypeCollection = propertyTypeCollection; @@ -122,6 +129,7 @@ public override PropertyGroup Build() var name = _name ?? Guid.NewGuid().ToString(); var sortOrder = _sortOrder ?? 0; var supportsPublishing = _supportsPublishing ?? false; + var type = _type ?? PropertyGroupType.Group; PropertyTypeCollection propertyTypeCollection; if (_propertyTypeCollection != null) @@ -145,7 +153,8 @@ public override PropertyGroup Build() Name = name, SortOrder = sortOrder, CreateDate = createDate, - UpdateDate = updateDate + UpdateDate = updateDate, + Type = type, }; } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs new file mode 100644 index 000000000000..d800d8fa6104 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs @@ -0,0 +1,124 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NPoco; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Migrations.Upgrade.V16_4_0; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class CreateMissingTabsTest : UmbracoIntegrationTest +{ + private IScopeProvider ScopeProvider => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + /// + /// A verification integration test for the solution to https://github.com/umbraco/Umbraco-CMS/issues/20058 + /// provided in https://github.com/umbraco/Umbraco-CMS/pull/20303. + [Test] + public async Task Can_Create_Missing_Tabs() + { + // Prepare document types as per reproduction steps described here: https://github.com/umbraco/Umbraco-CMS/issues/20058#issuecomment-3332742559 + // - Create a new composition with a tab "Content" and inside add a group "Header" with a "Text 1" property inside. + // - Save the composition. + // - Create a new document type and inherit the composition created in step 2. + // - Save the document type. + // - Add a new property "Text 2" to the Content > Header group. + // - Create a new group "Home Content", inside the "Content" tab, and add a property "Text 3". + // - Save the document type. + + // Create base content type. + var baseContentType = new ContentTypeBuilder() + .WithAlias("baseType") + .WithName("Base Type") + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithType(PropertyGroupType.Tab) + .Done() + .AddPropertyGroup() + .WithAlias("content/header") + .WithName("Header") + .WithType(PropertyGroupType.Group) + .AddPropertyType() + .WithAlias("text1") + .WithName("Text 1") + .Done() + .Done() + .Build(); + await ContentTypeService.CreateAsync(baseContentType, Constants.Security.SuperUserKey); + baseContentType = await ContentTypeService.GetAsync(baseContentType.Key); + + // Get ID of "Header" group. + var headerGroupId = baseContentType.PropertyGroups.First(x => x.Alias == "content/header").Id; + + // Create composed content type. + var composedContentType = new ContentTypeBuilder() + .WithAlias("composedType") + .WithName("Composed Type") + .Build(); + composedContentType.ContentTypeComposition = [baseContentType]; + await ContentTypeService.CreateAsync(composedContentType, Constants.Security.SuperUserKey); + composedContentType = await ContentTypeService.GetAsync(composedContentType.Key); + + // Add further groups and properties to composed content type. + var propertyType1 = new PropertyTypeBuilder() + .WithAlias("text2") + .WithName("Text 2") + .WithPropertyGroupId(headerGroupId) + .Build(); + composedContentType.AddPropertyType(propertyType1); + + var propertyType2 = new PropertyTypeBuilder() + .WithAlias("text3") + .WithName("Text 3") + .WithPropertyGroupId(headerGroupId) + .Build(); + composedContentType.AddPropertyType(propertyType2, "content/homeContent", "Home Content"); + + await ContentTypeService.UpdateAsync(composedContentType, Constants.Security.SuperUserKey); + + // TODO: Assert the groups and types created in the database. + + using IScope scope = ScopeProvider.CreateScope(); + Sql groupsSql = scope.Database.SqlContext.Sql() + .Select() + .From() + .WhereIn(x => x.ContentTypeNodeId, new[] { baseContentType.Id, composedContentType.Id }); + var groups = await scope.Database.FetchAsync(groupsSql); // <-- this doesn't seem correct, we have 3 groups, but from the issue description would expect 5 + + Sql typesSql = scope.Database.SqlContext.Sql() + .Select() + .From() + .WhereIn(x => x.ContentTypeId, new[] { baseContentType.Id, composedContentType.Id }); + var types = await scope.Database.FetchAsync(typesSql); + scope.Complete(); + + // TODO: Delete one of the group records so we get to the 13 state. + + await ExecuteMigration(); + + // TODO: Re-retrieve the content types and assert that the groups and types are as expected. + // TODO: Verify in the database that the migration has re-added the record we removed in the setup. + } + + private async Task ExecuteMigration() + { + using IScope scope = ScopeProvider.CreateScope(); + await CreateMissingTabs.ExecuteMigration(scope.Database); + scope.Complete(); + } +} From 912f973920d2fece18af2d80c7618f95e3f81ab4 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 30 Sep 2025 16:32:20 +0200 Subject: [PATCH 4/6] Added asserts to show the current issue with the integration test. --- .../Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs index d800d8fa6104..d7686b128219 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs @@ -85,7 +85,6 @@ public async Task Can_Create_Missing_Tabs() var propertyType2 = new PropertyTypeBuilder() .WithAlias("text3") .WithName("Text 3") - .WithPropertyGroupId(headerGroupId) .Build(); composedContentType.AddPropertyType(propertyType2, "content/homeContent", "Home Content"); @@ -99,12 +98,14 @@ public async Task Can_Create_Missing_Tabs() .From() .WhereIn(x => x.ContentTypeNodeId, new[] { baseContentType.Id, composedContentType.Id }); var groups = await scope.Database.FetchAsync(groupsSql); // <-- this doesn't seem correct, we have 3 groups, but from the issue description would expect 5 + Assert.AreEqual(5, groups.Count); Sql typesSql = scope.Database.SqlContext.Sql() .Select() .From() .WhereIn(x => x.ContentTypeId, new[] { baseContentType.Id, composedContentType.Id }); var types = await scope.Database.FetchAsync(typesSql); + Assert.AreEqual(3, types.Count); scope.Complete(); // TODO: Delete one of the group records so we get to the 13 state. From 1cb7506c6e3516def67dba9e982855e3a296e854 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:34:21 +0200 Subject: [PATCH 5/6] Adjusted the integration test --- .../Upgrade/V_16_4_0/CreateMissingTabsTest.cs | 140 +++++++++++++----- 1 file changed, 104 insertions(+), 36 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs index d7686b128219..20ae0fe95261 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs @@ -3,7 +3,9 @@ using NPoco; using NUnit.Framework; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0; @@ -12,22 +14,23 @@ using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; -using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.TestServerTest; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Migrations.Upgrade.V16_4_0; [TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] -internal sealed class CreateMissingTabsTest : UmbracoIntegrationTest +internal sealed class CreateMissingTabsTest : UmbracoTestServerTestBase { private IScopeProvider ScopeProvider => GetRequiredService(); private IContentTypeService ContentTypeService => GetRequiredService(); + private IUmbracoMapper UmbracoMapper => GetRequiredService(); + /// /// A verification integration test for the solution to https://github.com/umbraco/Umbraco-CMS/issues/20058 /// provided in https://github.com/umbraco/Umbraco-CMS/pull/20303. + /// [Test] public async Task Can_Create_Missing_Tabs() { @@ -35,7 +38,6 @@ public async Task Can_Create_Missing_Tabs() // - Create a new composition with a tab "Content" and inside add a group "Header" with a "Text 1" property inside. // - Save the composition. // - Create a new document type and inherit the composition created in step 2. - // - Save the document type. // - Add a new property "Text 2" to the Content > Header group. // - Create a new group "Home Content", inside the "Content" tab, and add a property "Text 3". // - Save the document type. @@ -62,58 +64,124 @@ public async Task Can_Create_Missing_Tabs() await ContentTypeService.CreateAsync(baseContentType, Constants.Security.SuperUserKey); baseContentType = await ContentTypeService.GetAsync(baseContentType.Key); - // Get ID of "Header" group. - var headerGroupId = baseContentType.PropertyGroups.First(x => x.Alias == "content/header").Id; - // Create composed content type. var composedContentType = new ContentTypeBuilder() .WithAlias("composedType") .WithName("Composed Type") + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithType(PropertyGroupType.Tab) + .Done() + .AddPropertyGroup() + .WithAlias("content/header") + .WithName("Header") + .WithType(PropertyGroupType.Group) + .AddPropertyType() + .WithAlias("text2") + .WithName("Text 2") + .Done() + .Done() + .AddPropertyGroup() + .WithAlias("content/homeContent") + .WithName("Home Content") + .WithType(PropertyGroupType.Group) + .AddPropertyType() + .WithAlias("text3") + .WithName("Text 3") + .Done() + .Done() .Build(); composedContentType.ContentTypeComposition = [baseContentType]; await ContentTypeService.CreateAsync(composedContentType, Constants.Security.SuperUserKey); composedContentType = await ContentTypeService.GetAsync(composedContentType.Key); - // Add further groups and properties to composed content type. - var propertyType1 = new PropertyTypeBuilder() - .WithAlias("text2") - .WithName("Text 2") - .WithPropertyGroupId(headerGroupId) - .Build(); - composedContentType.AddPropertyType(propertyType1); + // Assert the groups and properties are created in the database and that the content type model is as expected. + await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id); + await AssertValidContentTypeModel(composedContentType.Key); - var propertyType2 = new PropertyTypeBuilder() - .WithAlias("text3") - .WithName("Text 3") - .Build(); - composedContentType.AddPropertyType(propertyType2, "content/homeContent", "Home Content"); + // Delete one of the tab records so we get to the 13 state. + using IScope scope = ScopeProvider.CreateScope(); + Sql deleteTabSql = scope.Database.SqlContext.Sql() + .Delete() + .Where(x => x.Type == (int)PropertyGroupType.Tab && x.ContentTypeNodeId == composedContentType.Id); + var deletedCount = await scope.Database.ExecuteAsync(deleteTabSql); + scope.Complete(); + Assert.AreEqual(1, deletedCount); - await ContentTypeService.UpdateAsync(composedContentType, Constants.Security.SuperUserKey); + // Assert that the content type groups are now without a parent tab. + await AssertInvalidContentTypeModel(composedContentType.Key); - // TODO: Assert the groups and types created in the database. + // Run the migration to add the missing tab back. + await ExecuteMigration(); - using IScope scope = ScopeProvider.CreateScope(); + // Re-retrieve the content types and assert that the groups and types are as expected. + await AssertValidContentTypeModel(composedContentType.Key); + + // Verify in the database that the migration has re-added only the record we removed in the setup. + await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id); + } + + private async Task AssertValidContentTypeModel(Guid contentTypeKey) + { + var contentType = await ContentTypeService.GetAsync(contentTypeKey); + DocumentTypeResponseModel model = UmbracoMapper.Map(contentType)!; + Assert.AreEqual(3, model.Containers.Count()); + + var contentTab = model.Containers.FirstOrDefault(c => c.Name == "Content" && c.Type == nameof(PropertyGroupType.Tab)); + Assert.IsNotNull(contentTab); + + var headerGroup = model.Containers.FirstOrDefault(c => c.Name == "Header" && c.Type == nameof(PropertyGroupType.Group)); + Assert.IsNotNull(headerGroup); + Assert.IsNotNull(headerGroup.Parent); + Assert.AreEqual(contentTab.Id, headerGroup.Parent.Id); + + var homeContentGroup = model.Containers.FirstOrDefault(c => c.Name == "Home Content" && c.Type == nameof(PropertyGroupType.Group)); + Assert.IsNotNull(homeContentGroup); + Assert.IsNotNull(homeContentGroup.Parent); + Assert.AreEqual(contentTab.Id, homeContentGroup.Parent.Id); + } + + private async Task AssertInvalidContentTypeModel(Guid contentTypeKey) + { + var contentType = await ContentTypeService.GetAsync(contentTypeKey); + DocumentTypeResponseModel model = UmbracoMapper.Map(contentType)!; + Assert.AreEqual(2, model.Containers.Count()); + + var contentTab = model.Containers.FirstOrDefault(c => c.Name == "Content" && c.Type == nameof(PropertyGroupType.Tab)); + Assert.IsNull(contentTab); + + var headerGroup = model.Containers.FirstOrDefault(c => c.Name == "Header" && c.Type == nameof(PropertyGroupType.Group)); + Assert.IsNotNull(headerGroup); + Assert.IsNull(headerGroup.Parent); + + var homeContentGroup = model.Containers.FirstOrDefault(c => c.Name == "Home Content" && c.Type == nameof(PropertyGroupType.Group)); + Assert.IsNotNull(homeContentGroup); + Assert.IsNull(homeContentGroup.Parent); + } + + private async Task AssertValidDbGroupsAndProperties(int baseContentTypeId, int composedContentTypeId) + { + using IScope scope = ScopeProvider.CreateScope(autoComplete: true); Sql groupsSql = scope.Database.SqlContext.Sql() .Select() .From() - .WhereIn(x => x.ContentTypeNodeId, new[] { baseContentType.Id, composedContentType.Id }); - var groups = await scope.Database.FetchAsync(groupsSql); // <-- this doesn't seem correct, we have 3 groups, but from the issue description would expect 5 + .WhereIn(x => x.ContentTypeNodeId, new[] { baseContentTypeId, composedContentTypeId }); + var groups = await scope.Database.FetchAsync(groupsSql); Assert.AreEqual(5, groups.Count); - Sql typesSql = scope.Database.SqlContext.Sql() + Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Tab)); + Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Group)); + + Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Tab)); + Assert.AreEqual(2, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Group)); + + Sql propertiesSql = scope.Database.SqlContext.Sql() .Select() .From() - .WhereIn(x => x.ContentTypeId, new[] { baseContentType.Id, composedContentType.Id }); - var types = await scope.Database.FetchAsync(typesSql); + .WhereIn(x => x.ContentTypeId, new[] { baseContentTypeId, composedContentTypeId }); + var types = await scope.Database.FetchAsync(propertiesSql); Assert.AreEqual(3, types.Count); - scope.Complete(); - - // TODO: Delete one of the group records so we get to the 13 state. - - await ExecuteMigration(); - - // TODO: Re-retrieve the content types and assert that the groups and types are as expected. - // TODO: Verify in the database that the migration has re-added the record we removed in the setup. } private async Task ExecuteMigration() From 9eab650faa48f654174b77045001263693cb7be7 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 1 Oct 2025 07:53:14 +0200 Subject: [PATCH 6/6] Added logging of result. Minor re-order and extraction refactoring in integration test. --- .../Upgrade/V_16_4_0/CreateMissingTabs.cs | 22 ++-- .../Upgrade/V_16_4_0/CreateMissingTabsTest.cs | 105 ++++++++++-------- 2 files changed, 75 insertions(+), 52 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs index 492ea496f16d..a6ebafd07564 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabs.cs @@ -1,5 +1,5 @@ +using Microsoft.Extensions.Logging; using NPoco; -using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Extensions; @@ -16,14 +16,18 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0; [Obsolete("Remove in Umbraco 18.")] public class CreateMissingTabs : AsyncMigrationBase { - public CreateMissingTabs(IMigrationContext context) - : base(context) - { - } + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public CreateMissingTabs(IMigrationContext context, ILogger logger) + : base(context) => _logger = logger; + /// protected override async Task MigrateAsync() { - await ExecuteMigration(Database); + await ExecuteMigration(Database, _logger); Context.Complete(); } @@ -33,7 +37,7 @@ protected override async Task MigrateAsync() /// /// Extracted into an internal static method to support integration testing. /// - internal static async Task ExecuteMigration(IUmbracoDatabase database) + internal static async Task ExecuteMigration(IUmbracoDatabase database, ILogger logger) { // 1. Find all property groups (type 0) and extract their tab alias (the part before the first '/'). // This helps identify which groups are referencing tabs. @@ -102,6 +106,10 @@ internal static async Task ExecuteMigration(IUmbracoDatabase database) SortOrder = missingTabWithDetails.SortOrder, }); await database.InsertBatchAsync(newTabs); + + logger.LogInformation( + "Created {MissingTabCount} tab records to migrate property group information for content types derived from compositions.", + missingTabsWithDetails.Count); } private static string GetTabAliasQuery(DatabaseType databaseType, string columnName) => diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs index 20ae0fe95261..7e733eb95500 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Migrations/Upgrade/V_16_4_0/CreateMissingTabsTest.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.Logging.Abstractions; using NPoco; using NUnit.Framework; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; @@ -33,6 +34,31 @@ internal sealed class CreateMissingTabsTest : UmbracoTestServerTestBase /// [Test] public async Task Can_Create_Missing_Tabs() + { + // Prepare a base and composed content type. + (IContentType baseContentType, IContentType composedContentType) = await PrepareTestData(); + + // Assert the groups and properties are created in the database and that the content type model is as expected. + await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id); + await AssertValidContentTypeModel(composedContentType.Key); + + // Prepare the database state as it would have been in Umbraco 13. + await PreparePropertyGroupPersistedStateForUmbraco13(composedContentType); + + // Assert that the content type groups are now without a parent tab. + await AssertInvalidContentTypeModel(composedContentType.Key); + + // Run the migration to add the missing tab back. + await ExecuteMigration(); + + // Re-retrieve the content types and assert that the groups and types are as expected. + await AssertValidContentTypeModel(composedContentType.Key); + + // Verify in the database that the migration has re-added only the record we removed in the setup. + await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id); + } + + private async Task<(IContentType BaseContentType, IContentType ComposedContentType)> PrepareTestData() { // Prepare document types as per reproduction steps described here: https://github.com/umbraco/Umbraco-CMS/issues/20058#issuecomment-3332742559 // - Create a new composition with a tab "Content" and inside add a group "Header" with a "Text 1" property inside. @@ -95,31 +121,32 @@ public async Task Can_Create_Missing_Tabs() composedContentType.ContentTypeComposition = [baseContentType]; await ContentTypeService.CreateAsync(composedContentType, Constants.Security.SuperUserKey); composedContentType = await ContentTypeService.GetAsync(composedContentType.Key); + return (baseContentType, composedContentType); + } - // Assert the groups and properties are created in the database and that the content type model is as expected. - await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id); - await AssertValidContentTypeModel(composedContentType.Key); - - // Delete one of the tab records so we get to the 13 state. + private async Task AssertValidDbGroupsAndProperties(int baseContentTypeId, int composedContentTypeId) + { using IScope scope = ScopeProvider.CreateScope(); - Sql deleteTabSql = scope.Database.SqlContext.Sql() - .Delete() - .Where(x => x.Type == (int)PropertyGroupType.Tab && x.ContentTypeNodeId == composedContentType.Id); - var deletedCount = await scope.Database.ExecuteAsync(deleteTabSql); - scope.Complete(); - Assert.AreEqual(1, deletedCount); - - // Assert that the content type groups are now without a parent tab. - await AssertInvalidContentTypeModel(composedContentType.Key); + Sql groupsSql = scope.Database.SqlContext.Sql() + .Select() + .From() + .WhereIn(x => x.ContentTypeNodeId, new[] { baseContentTypeId, composedContentTypeId }); + var groups = await scope.Database.FetchAsync(groupsSql); + Assert.AreEqual(5, groups.Count); - // Run the migration to add the missing tab back. - await ExecuteMigration(); + Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Tab)); + Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Group)); - // Re-retrieve the content types and assert that the groups and types are as expected. - await AssertValidContentTypeModel(composedContentType.Key); + Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Tab)); + Assert.AreEqual(2, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Group)); - // Verify in the database that the migration has re-added only the record we removed in the setup. - await AssertValidDbGroupsAndProperties(baseContentType.Id, composedContentType.Id); + Sql propertiesSql = scope.Database.SqlContext.Sql() + .Select() + .From() + .WhereIn(x => x.ContentTypeId, new[] { baseContentTypeId, composedContentTypeId }); + var types = await scope.Database.FetchAsync(propertiesSql); + Assert.AreEqual(3, types.Count); + scope.Complete(); } private async Task AssertValidContentTypeModel(Guid contentTypeKey) @@ -142,6 +169,18 @@ private async Task AssertValidContentTypeModel(Guid contentTypeKey) Assert.AreEqual(contentTab.Id, homeContentGroup.Parent.Id); } + private async Task PreparePropertyGroupPersistedStateForUmbraco13(IContentType composedContentType) + { + // Delete one of the tab records so we get to the 13 state. + using IScope scope = ScopeProvider.CreateScope(); + Sql deleteTabSql = scope.Database.SqlContext.Sql() + .Delete() + .Where(x => x.Type == (int)PropertyGroupType.Tab && x.ContentTypeNodeId == composedContentType.Id); + var deletedCount = await scope.Database.ExecuteAsync(deleteTabSql); + scope.Complete(); + Assert.AreEqual(1, deletedCount); + } + private async Task AssertInvalidContentTypeModel(Guid contentTypeKey) { var contentType = await ContentTypeService.GetAsync(contentTypeKey); @@ -160,34 +199,10 @@ private async Task AssertInvalidContentTypeModel(Guid contentTypeKey) Assert.IsNull(homeContentGroup.Parent); } - private async Task AssertValidDbGroupsAndProperties(int baseContentTypeId, int composedContentTypeId) - { - using IScope scope = ScopeProvider.CreateScope(autoComplete: true); - Sql groupsSql = scope.Database.SqlContext.Sql() - .Select() - .From() - .WhereIn(x => x.ContentTypeNodeId, new[] { baseContentTypeId, composedContentTypeId }); - var groups = await scope.Database.FetchAsync(groupsSql); - Assert.AreEqual(5, groups.Count); - - Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Tab)); - Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == baseContentTypeId && x.Type == (int)PropertyGroupType.Group)); - - Assert.AreEqual(1, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Tab)); - Assert.AreEqual(2, groups.Count(x => x.ContentTypeNodeId == composedContentTypeId && x.Type == (int)PropertyGroupType.Group)); - - Sql propertiesSql = scope.Database.SqlContext.Sql() - .Select() - .From() - .WhereIn(x => x.ContentTypeId, new[] { baseContentTypeId, composedContentTypeId }); - var types = await scope.Database.FetchAsync(propertiesSql); - Assert.AreEqual(3, types.Count); - } - private async Task ExecuteMigration() { using IScope scope = ScopeProvider.CreateScope(); - await CreateMissingTabs.ExecuteMigration(scope.Database); + await CreateMissingTabs.ExecuteMigration(scope.Database, new NullLogger()); scope.Complete(); } }