Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,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;

/// <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 : 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<ISqlContext> groupsSql = Database.SqlContext.Sql()
.SelectDistinct<PropertyTypeGroupDto>("g", pt => pt.ContentTypeNodeId)
.AndSelect(GetTabAliasQuery("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(CheckContainsTabAliasQuery("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()
.SelectDistinct<PropertyTypeGroupDto>("missingTabs", ptg => ptg.ContentTypeNodeId)
.AndSelect<PropertyTypeGroupDto>("tg", ptg => ptg.Text, ptg => ptg.Alias, ptg => ptg.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");

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);

// 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; }
}
}
26 changes: 25 additions & 1 deletion src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,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);

Check warning on line 552 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.
}

/// <summary>
Expand Down Expand Up @@ -801,6 +802,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
Loading