Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,8 @@ protected virtual void DefinePlan()
// To 16.3.0
To<V_16_3_0.AddRichTextEditorCapabilities>("{A917FCBC-C378-4A08-A36C-220C581A6581}");
To<V_16_3_0.MigrateMediaTypeLabelProperties>("{FB7073AF-DFAF-4AC1-800D-91F9BD5B5238}");

// To 16.4.0
To<V_16_4_0.CreateMissingTabs>("{6A7D3B80-8B64-4E41-A7C0-02EC39336E97}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Logging;
using NPoco;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Extensions;

namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_4_0;

/// <summary>
/// Creates missing tabs on content types when a tab is referenced by both a composition and the content type's own groups.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Obsolete("Remove in Umbraco 18.")]
public class CreateMissingTabs : AsyncMigrationBase
{
private readonly ILogger<CreateMissingTabs> _logger;

/// <summary>
/// Initializes a new instance of the <see cref="CreateMissingTabs"/> class.
/// </summary>
public CreateMissingTabs(IMigrationContext context, ILogger<CreateMissingTabs> logger)
: base(context) => _logger = logger;

/// <inheritdoc/>
protected override async Task MigrateAsync()
{
await ExecuteMigration(Database, _logger);
Context.Complete();
}

/// <summary>
/// Performs the migration to create missing tabs.
/// </summary>
/// <remarks>
/// Extracted into an internal static method to support integration testing.
/// </remarks>
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.
Sql<ISqlContext> groupsSql = database.SqlContext.Sql()
.SelectDistinct<PropertyTypeGroupDto>("g", pt => pt.ContentTypeNodeId)
.AndSelect(GetTabAliasQuery(database.DatabaseType, "g.alias") + " AS tabAlias")
.From<PropertyTypeDto>(alias: "p")
.InnerJoin<PropertyTypeGroupDto>(alias: "g").On<PropertyTypeDto, PropertyTypeGroupDto>(
(pt, ptg) => pt.PropertyTypeGroupId == ptg.Id && pt.ContentTypeId == ptg.ContentTypeNodeId,
aliasLeft: "p",
"g")
.Where<PropertyTypeGroupDto>(x => x.Type == 0, alias: "g")
.Where(CheckIfContainsTabAliasQuery(database.DatabaseType, "g.alias"));

// 2. Get all existing tabs (type 1) for all content types.
Sql<ISqlContext> tabsSql = database.SqlContext.Sql()
.Select<PropertyTypeGroupDto>("g2", g => g.UniqueId, g => g.ContentTypeNodeId, g => g.Alias)
.From<PropertyTypeGroupDto>(alias: "g2")
.Where<PropertyTypeGroupDto>(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<ISqlContext> missingTabsSql = database.SqlContext.Sql()
.Select<PropertyTypeGroupDto>("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<PropertyTypeGroupDto>(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<ISqlContext> missingTabsWithDetailsSql = database.SqlContext.Sql()
.Select<PropertyTypeGroupDto>("missingTabs", ptg => ptg.ContentTypeNodeId)
.AndSelect<PropertyTypeGroupDto>("tg", ptg => ptg.Alias)
.AndSelect("MIN(text) AS text", "MIN(sortorder) AS sortOrder")
.From()
.AppendSubQuery(missingTabsSql, "missingTabs")
.InnerJoin<ContentType2ContentTypeDto>(alias: "ct2ct")
.On<PropertyTypeGroupDto, ContentType2ContentTypeDto>(
(ptg, ct2Ct) => ptg.ContentTypeNodeId == ct2Ct.ChildId,
"missingTabs",
"ct2ct")
.InnerJoin<PropertyTypeGroupDto>(alias: "tg")
.On<ContentType2ContentTypeDto, PropertyTypeGroupDto>(
(ct2Ct, ptg) => ct2Ct.ParentId == ptg.ContentTypeNodeId,
"ct2ct",
"tg")
.Append("AND tg.alias = missingTabs.tabAlias")
.GroupBy<PropertyTypeGroupDto>("missingTabs", ptg => ptg.ContentTypeNodeId)
.AndBy<PropertyTypeGroupDto>("tg", ptg => ptg.Alias);

List<MissingTabWithDetails> missingTabsWithDetails =
await database.FetchAsync<MissingTabWithDetails>(missingTabsWithDetailsSql);

// 5. Create and insert new tab records for each missing tab, using the details from the parent/composition.
IEnumerable<PropertyTypeGroupDto> 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);

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) =>
databaseType == DatabaseType.SQLite
? $"substr({columnName}, 1, INSTR({columnName},'/') - 1)"
: $"SUBSTRING({columnName}, 1, CHARINDEX('/', {columnName}) - 1)";

private static string CheckIfContainsTabAliasQuery(DatabaseType databaseType, 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; }
}
}
46 changes: 45 additions & 1 deletion src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Collections;

Check warning on line 1 in src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Code Duplication

introduced similar code in: InnerJoin,LeftJoin. Avoid duplicated, aka copy-pasted, code inside the module. More duplication lowers the code health.
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
Expand Down Expand Up @@ -393,6 +393,26 @@
return sql.GroupBy(columns);
}

/// <summary>
/// Appends a GROUP BY clause to the Sql statement.
/// </summary>
/// <typeparam name="TDto">The type of the Dto.</typeparam>
/// <param name="sql">The Sql statement.</param>
/// <param name="tableAlias">A table alias.</param>
/// <param name="fields">Expression specifying the fields.</param>
/// <returns>The Sql statement.</returns>
public static Sql<ISqlContext> GroupBy<TDto>(
this Sql<ISqlContext> sql,
string tableAlias,
params Expression<Func<TDto, object?>>[] fields)
{
ISqlSyntaxProvider sqlSyntax = sql.SqlContext.SqlSyntax;
var columns = fields.Length == 0
? sql.GetColumns<TDto>(withAlias: false)
: fields.Select(x => sqlSyntax.GetFieldName(x, tableAlias)).ToArray();
return sql.GroupBy(columns);
}

/// <summary>
/// Appends more ORDER BY or GROUP BY fields to the Sql statement.
/// </summary>
Expand Down Expand Up @@ -548,7 +568,8 @@
join += " " + sql.SqlContext.SqlSyntax.GetQuotedTableName(alias);
}

return sql.LeftJoin(join);
sql.Append("LEFT JOIN " + join, nestedSelect.Arguments);
return new Sql<ISqlContext>.SqlJoinClause<ISqlContext>(sql);
}

/// <summary>
Expand Down Expand Up @@ -801,6 +822,29 @@
return sql;
}

/// <summary>
/// Creates a SELECT DISTINCT Sql statement.
/// </summary>
/// <typeparam name="TDto">The type of the DTO to select.</typeparam>
/// <param name="sql">The origin sql.</param>
/// <param name="tableAlias">A table alias.</param>
/// <param name="fields">Expressions indicating the columns to select.</param>
/// <returns>The Sql statement.</returns>
/// <remarks>
/// <para>If <paramref name="fields"/> is empty, all columns are selected.</para>
/// </remarks>
public static Sql<ISqlContext> SelectDistinct<TDto>(this Sql<ISqlContext> sql, string tableAlias, params Expression<Func<TDto, object?>>[] 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<ISqlContext> SelectDistinct(this Sql<ISqlContext> sql, params object[] columns)
{
sql.Append("SELECT DISTINCT " + string.Join(", ", columns));
Expand Down
11 changes: 10 additions & 1 deletion tests/Umbraco.Tests.Common/Builders/PropertyGroupBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
private int? _sortOrder;
private bool? _supportsPublishing;
private DateTime? _updateDate;
private PropertyGroupType? _type;

public PropertyGroupBuilder(TParent parentBuilder)
: base(parentBuilder)
Expand Down Expand Up @@ -99,6 +100,12 @@
set => _updateDate = value;
}

public PropertyGroupBuilder<TParent> WithType(PropertyGroupType type)
{
_type = type;
return this;
}

public PropertyGroupBuilder<TParent> WithPropertyTypeCollection(PropertyTypeCollection propertyTypeCollection)
{
_propertyTypeCollection = propertyTypeCollection;
Expand All @@ -122,30 +129,32 @@
var name = _name ?? Guid.NewGuid().ToString();
var sortOrder = _sortOrder ?? 0;
var supportsPublishing = _supportsPublishing ?? false;
var type = _type ?? PropertyGroupType.Group;

PropertyTypeCollection propertyTypeCollection;
if (_propertyTypeCollection != null)
{
propertyTypeCollection = _propertyTypeCollection;
}
else
{
propertyTypeCollection = new PropertyTypeCollection(supportsPublishing);
foreach (var propertyType in _propertyTypeBuilders.Select(x => x.Build()))
{
propertyTypeCollection.Add(propertyType);
}
}

return new PropertyGroup(propertyTypeCollection)
{
Id = id,
Key = key,
Alias = alias,
Name = name,
SortOrder = sortOrder,
CreateDate = createDate,
UpdateDate = updateDate
UpdateDate = updateDate,
Type = type,

Check warning on line 157 in tests/Umbraco.Tests.Common/Builders/PropertyGroupBuilder.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

Build increases in cyclomatic complexity from 11 to 12, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
};
}
}
Loading