fix(labels): enforce per-tenant uniqueness on NormalizedName (#8112)

* fix(labels): enforce per-tenant uniqueness on NormalizedName

Finish the unused Label.NormalizedName contract the same way Secrets
does: unique (TenantId, NormalizedName) in EF, fail-closed Memory saves,
and keep NormalizedName in sync with Name.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): clone Memory label reads so a rejected rename cannot persist

Find/List handed out live store refs. Labels.Update mutates Name (and
NormalizedName) on that instance before Save; a uniqueness rejection
then left the stored row already renamed. Clone-on-read matches Memory
identity stores from #8108.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): stamp null TenantId, fail-loud on leftover duplicates, cap names at 255

Follow the Architect steer for Greptile P1s: do not auto-delete duplicate
labels; UPDATE Labels SET TenantId = '' WHERE TenantId IS NULL on every
provider before CreateIndex; keep SQL Server/Oracle filtered unique
indexes; HasMaxLength(255) on Name and NormalizedName with no silent
truncate.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): fail-loud preflight for leftover keys and over-length names

No silent dedupe: every provider lists leftover (TenantId, NormalizedName)
keys and aborts before CreateIndex. SQL Server/Oracle keep their filtered
unique indexes. PostgreSQL/SQLite keep provider column types. Providers
that narrow Name/NormalizedName to 255 preflight over-length Ids first.
Memory uniqueness semantics are unchanged.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): match Oracle filtered-index escape in migration assertion

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-13 16:16:01 +02:00 committed by GitHub
parent b23a10c2a4
commit 4b15b0166d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1620 additions and 23 deletions

View file

@ -26,7 +26,6 @@ internal class LabelMapper : Mapper<Request, Response, Label>
e.Color = r.Color?.Trim();
e.Description = r.Description?.Trim();
e.Name = r.Name.Trim();
e.NormalizedName = r.Name.ToLowerInvariant();
return e;
}
}

View file

@ -7,6 +7,12 @@ namespace Elsa.Labels.Entities;
/// </summary>
public class Label : Entity
{
/// <summary>
/// Shared maximum for <see cref="Name"/> and <see cref="NormalizedName"/>.
/// Matches the MySQL unique-index limit that every provider's schema must honor.
/// </summary>
public const int NameMaxLength = 255;
private string _name = default!;
public string Name
@ -14,6 +20,13 @@ public class Label : Entity
get => _name;
set
{
if (value.Length > NameMaxLength)
{
throw new ArgumentException(
$"Label name cannot exceed {NameMaxLength} characters.",
nameof(Name));
}
_name = value;
NormalizedName = value.ToLowerInvariant();
}

View file

@ -38,9 +38,14 @@ public class InMemoryLabelStore : ILabelStore
/// <inheritdoc />
public Task SaveAsync(Label record, CancellationToken cancellationToken = default)
{
ApplyCurrentTenant(record);
lock (_labelStore.Sync)
{
ApplyCurrentTenant(record);
SyncNormalizedName(record);
EnsureNormalizedNameAvailable(record, [record]);
_labelStore.Save(record, x => x.Id);
}
return Task.CompletedTask;
}
@ -49,11 +54,20 @@ public class InMemoryLabelStore : ILabelStore
{
var list = records.ToList();
foreach (var record in list)
ApplyCurrentTenant(record);
lock (_labelStore.Sync)
{
foreach (var record in list)
{
ApplyCurrentTenant(record);
SyncNormalizedName(record);
}
foreach (var record in list)
EnsureNormalizedNameAvailable(record, list);
_labelStore.SaveMany(list, x => x.Id);
}
return Task.CompletedTask;
}
@ -90,7 +104,8 @@ public class InMemoryLabelStore : ILabelStore
/// <inheritdoc />
public Task<Label?> FindByIdAsync(string id, CancellationToken cancellationToken = default)
{
return Task.FromResult(FindVisibleLabel(id));
var found = FindVisibleLabel(id);
return Task.FromResult(found is null ? null : Clone(found));
}
/// <inheritdoc />
@ -98,20 +113,38 @@ public class InMemoryLabelStore : ILabelStore
{
var query = _labelStore.List().AsQueryable().WhereVisibleToTenant(CurrentTenantId).OrderBy(x => x.Name);
var page = query.ToPage(pageArgs);
return Task.FromResult(page);
return Task.FromResult(Page.Of(page.Items.Select(Clone).ToList(), page.TotalCount));
}
/// <inheritdoc />
public Task<IEnumerable<Label>> FindManyByIdAsync(IEnumerable<string> ids, CancellationToken cancellationToken)
{
var idList = ids.ToList();
var records = _labelStore.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => idList.Contains(x.Id)));
return Task.FromResult(records);
var records = _labelStore.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => idList.Contains(x.Id)))
.Select(Clone)
.ToList();
return Task.FromResult<IEnumerable<Label>>(records);
}
private Label? FindVisibleLabel(string id) =>
_labelStore.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => x.Id == id)).FirstOrDefault();
/// <summary>
/// Clone-on-read, matching Memory identity stores. Labels.Update does Find → mutate
/// Name (which writes NormalizedName) → Save. Without a copy, a rejected rename
/// would already have mutated the stored row.
/// </summary>
private static Label Clone(Label label) =>
new()
{
Id = label.Id,
TenantId = label.TenantId,
Name = label.Name,
NormalizedName = label.NormalizedName,
Description = label.Description,
Color = label.Color
};
private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
@ -123,4 +156,36 @@ public class InMemoryLabelStore : ILabelStore
entity.TenantId ??= _tenantAccessor.TenantId;
}
private static void SyncNormalizedName(Label record) =>
record.NormalizedName = record.Name.ToLowerInvariant();
/// <summary>
/// Memory counterpart of the EF unique index on <c>(TenantId, NormalizedName)</c>.
/// Same-Id upserts are allowed so a row can rename itself. Incoming batch rows
/// replace same-Id store rows, so those store rows are ignored here.
/// </summary>
private void EnsureNormalizedNameAvailable(Label record, IReadOnlyCollection<Label> batch)
{
var batchIds = batch.Select(x => x.Id).ToHashSet();
var existing = _labelStore.Find(candidate =>
candidate.TenantId == record.TenantId
&& candidate.Id != record.Id
&& !batchIds.Contains(candidate.Id)
&& candidate.NormalizedName == record.NormalizedName);
if (existing is not null)
throw DuplicateNormalizedName(record);
if (batch.Any(other =>
other.Id != record.Id
&& other.TenantId == record.TenantId
&& other.NormalizedName == record.NormalizedName))
{
throw DuplicateNormalizedName(record);
}
}
private static InvalidOperationException DuplicateNormalizedName(Label record) =>
new($"A label already exists with normalized name '{record.NormalizedName}' in tenant '{record.TenantId}'.");
}

View file

@ -0,0 +1,100 @@
// <auto-generated />
using Elsa.Persistence.EFCore.Modules.Labels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Elsa.Persistence.EFCore.MySql.Migrations.Labels
{
[DbContext(typeof(LabelsElsaDbContext))]
[Migration("20260913133332_PerTenantLabelUniqueness")]
partial class PerTenantLabelUniqueness
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Elsa.Labels.Entities.Label", b =>
{
b.Property<string>("Id")
.HasColumnType("varchar(255)");
b.Property<string>("Color")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("TenantId")
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName");
b.ToTable("Labels", "Elsa");
});
modelBuilder.Entity("Elsa.Labels.Entities.WorkflowDefinitionLabel", b =>
{
b.Property<string>("Id")
.HasColumnType("varchar(255)");
b.Property<string>("LabelId")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("TenantId")
.HasColumnType("varchar(255)");
b.Property<string>("WorkflowDefinitionId")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("WorkflowDefinitionVersionId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("LabelId")
.HasDatabaseName("WorkflowDefinitionLabel_LabelId");
b.HasIndex("TenantId")
.HasDatabaseName("WorkflowDefinitionLabel_TenantId");
b.HasIndex("WorkflowDefinitionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionId");
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionVersionId");
b.ToTable("WorkflowDefinitionLabels", "Elsa");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.MySql.Migrations.Labels
{
/// <inheritdoc />
public partial class PerTenantLabelUniqueness : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public PerTenantLabelUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Default tenant is "" (not null). Stamp leftover nulls so the unique index covers them.
migrationBuilder.Sql($"""
UPDATE `{_schema.Schema}`.`Labels`
SET `TenantId` = ''
WHERE `TenantId` IS NULL;
""");
// No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading.
// SIGNAL MESSAGE_TEXT is limited to 128 characters, so the listed keys are truncated.
migrationBuilder.Sql($"""
SET @duplicate_keys := (
SELECT GROUP_CONCAT(CONCAT('(', IFNULL(`TenantId`, '<null>'), ', ', `NormalizedName`, ')') SEPARATOR '; ')
FROM (
SELECT `TenantId`, `NormalizedName`
FROM `{_schema.Schema}`.`Labels`
GROUP BY `TenantId`, `NormalizedName`
HAVING COUNT(*) > 1
) AS Duplicates
);
SET @sql := IF(
@duplicate_keys IS NOT NULL,
CONCAT(
'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''',
LEFT(REPLACE(CONCAT(
'Duplicate keys. Resolve before upgrade: ',
@duplicate_keys
), '''', ''), 128),
''''
),
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
// Name/NormalizedName are not truncated; list over-length Ids and abort before ALTER.
migrationBuilder.Sql($"""
SET @over_length_ids := (
SELECT GROUP_CONCAT(`Id` SEPARATOR ', ')
FROM `{_schema.Schema}`.`Labels`
WHERE CHAR_LENGTH(`Name`) > 255 OR CHAR_LENGTH(`NormalizedName`) > 255
);
SET @sql := IF(
@over_length_ids IS NOT NULL,
CONCAT(
'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''',
LEFT(REPLACE(CONCAT(
'Over-length Name/NormalizedName. Shorten before upgrade. Ids: ',
@over_length_ids
), '''', ''), 128),
''''
),
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
schema: _schema.Schema,
table: "Labels",
type: "varchar(255)",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,
table: "Labels",
type: "varchar(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "NormalizedName",
schema: _schema.Schema,
table: "Labels",
type: "varchar(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels",
columns: new[] { "TenantId", "NormalizedName" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
schema: _schema.Schema,
table: "Labels",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "NormalizedName",
schema: _schema.Schema,
table: "Labels",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldMaxLength: 255)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,
table: "Labels",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldMaxLength: 255)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Labels
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -35,17 +35,23 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Labels
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("TenantId")
.HasColumnType("longtext");
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName");
b.ToTable("Labels", "Elsa");
});

View file

@ -0,0 +1,101 @@
// <auto-generated />
using Elsa.Persistence.EFCore.Modules.Labels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Oracle.EntityFrameworkCore.Metadata;
#nullable disable
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Labels
{
[DbContext(typeof(LabelsElsaDbContext))]
[Migration("20260913133340_PerTenantLabelUniqueness")]
partial class PerTenantLabelUniqueness
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Elsa.Labels.Entities.Label", b =>
{
b.Property<string>("Id")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("Color")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Description")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("NVARCHAR2(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("NVARCHAR2(255)");
b.Property<string>("TenantId")
.HasColumnType("NVARCHAR2(450)");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName")
.HasFilter("\"TenantId\" IS NOT NULL");
b.ToTable("Labels", "Elsa");
});
modelBuilder.Entity("Elsa.Labels.Entities.WorkflowDefinitionLabel", b =>
{
b.Property<string>("Id")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("LabelId")
.IsRequired()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("TenantId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("WorkflowDefinitionId")
.IsRequired()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("WorkflowDefinitionVersionId")
.IsRequired()
.HasColumnType("NVARCHAR2(450)");
b.HasKey("Id");
b.HasIndex("LabelId")
.HasDatabaseName("WorkflowDefinitionLabel_LabelId");
b.HasIndex("TenantId")
.HasDatabaseName("WorkflowDefinitionLabel_TenantId");
b.HasIndex("WorkflowDefinitionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionId");
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionVersionId");
b.ToTable("WorkflowDefinitionLabels", "Elsa");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,174 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Labels
{
/// <inheritdoc />
public partial class PerTenantLabelUniqueness : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public PerTenantLabelUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Default tenant is "" (not null). Stamp leftover nulls so the filtered unique
// index (TenantId IS NOT NULL) covers them. Duplicates are not deleted.
migrationBuilder.Sql($"""
UPDATE "{_schema.Schema}"."Labels"
SET "TenantId" = ''
WHERE "TenantId" IS NULL;
""");
// No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading.
migrationBuilder.Sql($"""
DECLARE
duplicate_keys VARCHAR2(4000);
duplicate_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO duplicate_count
FROM (
SELECT "TenantId", "NormalizedName"
FROM "{_schema.Schema}"."Labels"
GROUP BY "TenantId", "NormalizedName"
HAVING COUNT(*) > 1
);
IF duplicate_count > 0 THEN
SELECT LISTAGG('(' || NVL("TenantId", '<null>') || ', ' || "NormalizedName" || ')', '; ' ON OVERFLOW TRUNCATE)
WITHIN GROUP (ORDER BY "NormalizedName")
INTO duplicate_keys
FROM (
SELECT "TenantId", "NormalizedName"
FROM "{_schema.Schema}"."Labels"
GROUP BY "TenantId", "NormalizedName"
HAVING COUNT(*) > 1
);
RAISE_APPLICATION_ERROR(
-20001,
SUBSTR(
'Cannot create unique index IX_Label_TenantId_NormalizedName because leftover duplicate (TenantId, NormalizedName) rows exist. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: ' || duplicate_keys,
1,
2048
)
);
END IF;
END;
""");
// Name/NormalizedName are not truncated; list over-length Ids and abort before ALTER.
migrationBuilder.Sql($"""
DECLARE
over_length_ids VARCHAR2(4000);
over_length_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO over_length_count
FROM "{_schema.Schema}"."Labels"
WHERE LENGTH("Name") > 255 OR LENGTH("NormalizedName") > 255;
IF over_length_count > 0 THEN
SELECT LISTAGG("Id", ', ' ON OVERFLOW TRUNCATE) WITHIN GROUP (ORDER BY "Id")
INTO over_length_ids
FROM "{_schema.Schema}"."Labels"
WHERE LENGTH("Name") > 255 OR LENGTH("NormalizedName") > 255;
RAISE_APPLICATION_ERROR(
-20002,
SUBSTR(
'Cannot alter Labels.Name / Labels.NormalizedName to NVARCHAR2(255) because leftover rows exceed 255 characters. Operators must shorten or remove those rows before upgrade. Over-length Ids: ' || over_length_ids,
1,
2048
)
);
END IF;
END;
""");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
schema: _schema.Schema,
table: "Labels",
type: "NVARCHAR2(450)",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,
table: "Labels",
type: "NVARCHAR2(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)");
migrationBuilder.AlterColumn<string>(
name: "NormalizedName",
schema: _schema.Schema,
table: "Labels",
type: "NVARCHAR2(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)");
migrationBuilder.CreateIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels",
columns: new[] { "TenantId", "NormalizedName" },
unique: true,
filter: "\"TenantId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
schema: _schema.Schema,
table: "Labels",
type: "NVARCHAR2(2000)",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(450)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "NormalizedName",
schema: _schema.Schema,
table: "Labels",
type: "NVARCHAR2(2000)",
nullable: false,
oldClrType: typeof(string),
oldType: "NVARCHAR2(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,
table: "Labels",
type: "NVARCHAR2(2000)",
nullable: false,
oldClrType: typeof(string),
oldType: "NVARCHAR2(255)",
oldMaxLength: 255);
}
}
}

View file

@ -17,7 +17,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Labels
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@ -35,17 +35,24 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Labels
b.Property<string>("Name")
.IsRequired()
.HasColumnType("NVARCHAR2(2000)");
.HasMaxLength(255)
.HasColumnType("NVARCHAR2(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasColumnType("NVARCHAR2(2000)");
.HasMaxLength(255)
.HasColumnType("NVARCHAR2(255)");
b.Property<string>("TenantId")
.HasColumnType("NVARCHAR2(2000)");
.HasColumnType("NVARCHAR2(450)");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName")
.HasFilter("\"TenantId\" IS NOT NULL");
b.ToTable("Labels", "Elsa");
});

View file

@ -0,0 +1,100 @@
// <auto-generated />
using Elsa.Persistence.EFCore.Modules.Labels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Labels
{
[DbContext(typeof(LabelsElsaDbContext))]
[Migration("20260913133325_PerTenantLabelUniqueness")]
partial class PerTenantLabelUniqueness
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Elsa.Labels.Entities.Label", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Color")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("text");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("text");
b.Property<string>("TenantId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName");
b.ToTable("Labels", "Elsa");
});
modelBuilder.Entity("Elsa.Labels.Entities.WorkflowDefinitionLabel", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("LabelId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TenantId")
.HasColumnType("text");
b.Property<string>("WorkflowDefinitionId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("WorkflowDefinitionVersionId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("LabelId")
.HasDatabaseName("WorkflowDefinitionLabel_LabelId");
b.HasIndex("TenantId")
.HasDatabaseName("WorkflowDefinitionLabel_TenantId");
b.HasIndex("WorkflowDefinitionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionId");
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionVersionId");
b.ToTable("WorkflowDefinitionLabels", "Elsa");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Labels
{
/// <inheritdoc />
public partial class PerTenantLabelUniqueness : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public PerTenantLabelUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Default tenant is "" (not null). Stamp leftover nulls so the unique index covers them.
migrationBuilder.Sql($"""
UPDATE "{_schema.Schema}"."Labels"
SET "TenantId" = ''
WHERE "TenantId" IS NULL;
""");
// Keep provider column type (text). HasMaxLength(255) is a model annotation only.
// No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading.
migrationBuilder.Sql($"""
DO $$
DECLARE keys text;
BEGIN
SELECT string_agg(format('(%s, %s)', "TenantId", "NormalizedName"), ', ')
INTO keys
FROM (
SELECT "TenantId", "NormalizedName"
FROM "{_schema.Schema}"."Labels"
GROUP BY "TenantId", "NormalizedName"
HAVING COUNT(*) > 1
) d;
IF keys IS NOT NULL THEN
RAISE EXCEPTION 'Cannot apply IX_Label_TenantId_NormalizedName. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: %', keys;
END IF;
END $$;
""");
migrationBuilder.CreateIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels",
columns: new[] { "TenantId", "NormalizedName" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Labels
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -35,10 +35,12 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Labels
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("text");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("text");
b.Property<string>("TenantId")
@ -46,6 +48,10 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Labels
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName");
b.ToTable("Labels", "Elsa");
});

View file

@ -0,0 +1,101 @@
// <auto-generated />
using Elsa.Persistence.EFCore.Modules.Labels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Labels
{
[DbContext(typeof(LabelsElsaDbContext))]
[Migration("20260913133317_PerTenantLabelUniqueness")]
partial class PerTenantLabelUniqueness
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Elsa.Labels.Entities.Label", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("Color")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName")
.HasFilter("[TenantId] IS NOT NULL");
b.ToTable("Labels", "Elsa");
});
modelBuilder.Entity("Elsa.Labels.Entities.WorkflowDefinitionLabel", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("LabelId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<string>("WorkflowDefinitionId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("WorkflowDefinitionVersionId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("LabelId")
.HasDatabaseName("WorkflowDefinitionLabel_LabelId");
b.HasIndex("TenantId")
.HasDatabaseName("WorkflowDefinitionLabel_TenantId");
b.HasIndex("WorkflowDefinitionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionId");
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionVersionId");
b.ToTable("WorkflowDefinitionLabels", "Elsa");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,157 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Labels
{
/// <inheritdoc />
public partial class PerTenantLabelUniqueness : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public PerTenantLabelUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Default tenant is "" (not null). Stamp leftover nulls so the filtered unique
// index (TenantId IS NOT NULL) covers them. Duplicates are not deleted.
migrationBuilder.Sql($"""
UPDATE [{_schema.Schema}].[Labels]
SET [TenantId] = N''
WHERE [TenantId] IS NULL;
""");
// No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading.
migrationBuilder.Sql($"""
IF EXISTS (
SELECT 1
FROM [{_schema.Schema}].[Labels]
GROUP BY [TenantId], [NormalizedName]
HAVING COUNT(*) > 1
)
BEGIN
DECLARE @DuplicateKeys nvarchar(max);
SELECT @DuplicateKeys = STRING_AGG(
CAST(CONCAT(N'(', ISNULL([TenantId], N'<null>'), N', ', [NormalizedName], N')') AS nvarchar(max)),
N'; '
)
FROM (
SELECT [TenantId], [NormalizedName]
FROM [{_schema.Schema}].[Labels]
GROUP BY [TenantId], [NormalizedName]
HAVING COUNT(*) > 1
) AS Duplicates;
THROW 50001, CONCAT(
N'Cannot create unique index IX_Label_TenantId_NormalizedName because leftover duplicate (TenantId, NormalizedName) rows exist. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: ',
LEFT(@DuplicateKeys, 1500)
), 1;
END
""");
// Name/NormalizedName are not truncated; list over-length Ids and abort before ALTER.
migrationBuilder.Sql($"""
IF EXISTS (
SELECT 1
FROM [{_schema.Schema}].[Labels]
WHERE LEN([Name]) > 255 OR LEN([NormalizedName]) > 255
)
BEGIN
DECLARE @OverLengthIds nvarchar(max);
SELECT @OverLengthIds = STRING_AGG(CAST([Id] AS nvarchar(max)), N', ')
FROM [{_schema.Schema}].[Labels]
WHERE LEN([Name]) > 255 OR LEN([NormalizedName]) > 255;
THROW 50002, CONCAT(
N'Cannot alter Labels.Name / Labels.NormalizedName to nvarchar(255) because leftover rows exceed 255 characters. Operators must shorten or remove those rows before upgrade. Over-length Ids: ',
LEFT(@OverLengthIds, 1500)
), 1;
END
""");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
schema: _schema.Schema,
table: "Labels",
type: "nvarchar(450)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,
table: "Labels",
type: "nvarchar(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "NormalizedName",
schema: _schema.Schema,
table: "Labels",
type: "nvarchar(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.CreateIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels",
columns: new[] { "TenantId", "NormalizedName" },
unique: true,
filter: "[TenantId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels");
migrationBuilder.AlterColumn<string>(
name: "TenantId",
schema: _schema.Schema,
table: "Labels",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "NormalizedName",
schema: _schema.Schema,
table: "Labels",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,
table: "Labels",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(255)",
oldMaxLength: 255);
}
}
}

View file

@ -17,7 +17,7 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Labels
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@ -35,17 +35,24 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Labels
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName")
.HasFilter("[TenantId] IS NOT NULL");
b.ToTable("Labels", "Elsa");
});

View file

@ -0,0 +1,96 @@
// <auto-generated />
using Elsa.Persistence.EFCore.Modules.Labels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Labels
{
[DbContext(typeof(LabelsElsaDbContext))]
[Migration("20260913133258_PerTenantLabelUniqueness")]
partial class PerTenantLabelUniqueness
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "9.0.17");
modelBuilder.Entity("Elsa.Labels.Entities.Label", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("TenantId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName");
b.ToTable("Labels", "Elsa");
});
modelBuilder.Entity("Elsa.Labels.Entities.WorkflowDefinitionLabel", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("LabelId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TenantId")
.HasColumnType("TEXT");
b.Property<string>("WorkflowDefinitionId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("WorkflowDefinitionVersionId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LabelId")
.HasDatabaseName("WorkflowDefinitionLabel_LabelId");
b.HasIndex("TenantId")
.HasDatabaseName("WorkflowDefinitionLabel_TenantId");
b.HasIndex("WorkflowDefinitionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionId");
b.HasIndex("WorkflowDefinitionVersionId")
.HasDatabaseName("WorkflowDefinitionLabel_WorkflowDefinitionVersionId");
b.ToTable("WorkflowDefinitionLabels", "Elsa");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Labels
{
/// <inheritdoc />
public partial class PerTenantLabelUniqueness : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public PerTenantLabelUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Default tenant is "" (not null). Stamp leftover nulls so the unique index covers them.
migrationBuilder.Sql($"""
UPDATE "{_schema.Schema}"."Labels"
SET "TenantId" = ''
WHERE "TenantId" IS NULL;
""");
// No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading.
migrationBuilder.Sql($"""
SELECT RAISE(ABORT, 'Cannot apply IX_Label_TenantId_NormalizedName. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: ' ||
(SELECT group_concat('(' || IFNULL("TenantId", '') || ', ' || "NormalizedName" || ')', ', ')
FROM (
SELECT "TenantId", "NormalizedName"
FROM "{_schema.Schema}"."Labels"
GROUP BY "TenantId", "NormalizedName"
HAVING COUNT(*) > 1
)))
WHERE EXISTS (
SELECT 1
FROM "{_schema.Schema}"."Labels"
GROUP BY "TenantId", "NormalizedName"
HAVING COUNT(*) > 1
);
""");
migrationBuilder.CreateIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels",
columns: new[] { "TenantId", "NormalizedName" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Label_TenantId_NormalizedName",
schema: _schema.Schema,
table: "Labels");
}
}
}

View file

@ -16,7 +16,7 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Labels
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "8.0.12");
.HasAnnotation("ProductVersion", "9.0.17");
modelBuilder.Entity("Elsa.Labels.Entities.Label", b =>
{
@ -31,10 +31,12 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Labels
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("TenantId")
@ -42,6 +44,10 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Labels
b.HasKey("Id");
b.HasIndex("TenantId", "NormalizedName")
.IsUnique()
.HasDatabaseName("IX_Label_TenantId_NormalizedName");
b.ToTable("Labels", "Elsa");
});

View file

@ -8,6 +8,16 @@ public class Configurations : IEntityTypeConfiguration<Label>, IEntityTypeConfig
{
public void Configure(EntityTypeBuilder<Label> builder)
{
builder.Property(x => x.Name).HasMaxLength(Label.NameMaxLength).IsRequired();
builder.Property(x => x.NormalizedName).HasMaxLength(Label.NameMaxLength).IsRequired();
// Uniqueness is per tenant, matching Secrets (and User, Role, Application).
// A global unique index would make the name a shared resource across tenants.
// SQL Server/Oracle still emit a filtered unique index (TenantId IS NOT NULL).
// Default-tenant rows use "" — migrations stamp leftover null TenantId to "" first.
builder.HasIndex(x => new { x.TenantId, x.NormalizedName })
.HasDatabaseName($"IX_{nameof(Label)}_{nameof(Label.TenantId)}_{nameof(Label.NormalizedName)}")
.IsUnique();
}
public void Configure(EntityTypeBuilder<WorkflowDefinitionLabel> builder)

View file

@ -0,0 +1,27 @@
using Elsa.Labels.Entities;
namespace Elsa.Labels.UnitTests.Entities;
public class LabelNameMaxLengthTests
{
[Fact]
public void Name_WhenLongerThanSharedMaximum_Throws()
{
var label = new Label { Id = "label-1" };
var exception = Assert.Throws<ArgumentException>(() => label.Name = new string('a', Label.NameMaxLength + 1));
Assert.Equal("Name", exception.ParamName);
Assert.Contains(Label.NameMaxLength.ToString(), exception.Message);
}
[Fact]
public void Name_WhenAtSharedMaximum_Succeeds()
{
var name = new string('a', Label.NameMaxLength);
var label = new Label { Id = "label-1", Name = name };
Assert.Equal(name, label.Name);
Assert.Equal(name, label.NormalizedName);
}
}

View file

@ -0,0 +1,180 @@
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.Labels.Entities;
using Elsa.Labels.Services;
using Elsa.Testing.Shared.Multitenancy;
namespace Elsa.Labels.UnitTests.Services;
/// <summary>
/// Memory must enforce the same per-tenant normalized-name uniqueness that EF Core
/// enforces via <c>IX_Label_TenantId_NormalizedName</c> on <c>(TenantId, NormalizedName)</c>.
/// </summary>
public class InMemoryLabelStoreUniquenessTests
{
[Fact(DisplayName = "SaveAsync rejects a different Id that repeats a normalized name in the same tenant")]
public async Task SaveAsync_WhenNormalizedNameExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(Label("label-2", "urgent", "tenant-a")));
Assert.Contains("already exists", exception.Message);
var stored = (await store.ListAsync()).Items.ToList();
Assert.Equal("label-1", Assert.Single(stored).Id);
}
[Fact(DisplayName = "SaveAsync allows the same Id to update its own name")]
public async Task SaveAsync_WhenSameIdUpdatesName_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
await store.SaveAsync(Label("label-1", "Critical", "tenant-a"));
var stored = await store.FindByIdAsync("label-1");
Assert.Equal("Critical", stored!.Name);
Assert.Equal("critical", stored.NormalizedName);
}
[Fact(DisplayName = "SaveAsync allows the same normalized name in different tenants")]
public async Task SaveAsync_WhenNormalizedNameRepeatsInAnotherTenant_Succeeds()
{
var labels = new MemoryStore<Label>();
var associations = new MemoryStore<WorkflowDefinitionLabel>();
var tenantA = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-a"));
var tenantB = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(Label("label-a", "Urgent", "tenant-a"));
await tenantB.SaveAsync(Label("label-b", "Urgent", "tenant-b"));
Assert.Equal("urgent", (await tenantA.FindByIdAsync("label-a"))!.NormalizedName);
Assert.Equal("urgent", (await tenantB.FindByIdAsync("label-b"))!.NormalizedName);
}
[Fact(DisplayName = "SaveAsync rejects renaming onto a normalized name another Id already owns")]
public async Task SaveAsync_WhenRenamingOntoAnotherIdsNormalizedName_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
await store.SaveAsync(Label("label-2", "Later", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(Label("label-2", "Urgent", "tenant-a")));
Assert.Contains("already exists", exception.Message);
Assert.Equal("Later", (await store.FindByIdAsync("label-2"))!.Name);
}
[Fact(DisplayName = "SaveAsync leaves the stored name unchanged when a Find result is renamed onto a collision")]
public async Task SaveAsync_WhenFoundLabelRenamedOntoCollision_LeavesStoredNameUnchanged()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
await store.SaveAsync(Label("label-2", "Later", "tenant-a"));
var found = await store.FindByIdAsync("label-2");
found!.Name = "Urgent";
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => store.SaveAsync(found));
Assert.Contains("already exists", exception.Message);
var stored = await store.FindByIdAsync("label-2");
Assert.Equal("Later", stored!.Name);
Assert.Equal("later", stored.NormalizedName);
}
[Fact(DisplayName = "SaveAsync treats a stamped ambient tenant as the uniqueness tenant")]
public async Task SaveAsync_WhenTenantIdUnset_UsesStampedAmbientTenantForUniqueness()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", tenantId: null));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(Label("label-2", "Urgent", tenantId: null)));
Assert.Contains("already exists", exception.Message);
Assert.Equal("tenant-a", (await store.FindByIdAsync("label-1"))!.TenantId);
}
[Fact(DisplayName = "SaveAsync allows the same normalized name for * and a tenant-scoped label")]
public async Task SaveAsync_WhenAgnosticAndTenantScopedShareNormalizedName_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-star", "Urgent", Tenant.AgnosticTenantId));
await store.SaveAsync(Label("label-a", "Urgent", "tenant-a"));
Assert.NotNull(await store.FindByIdAsync("label-star"));
Assert.NotNull(await store.FindByIdAsync("label-a"));
}
[Fact(DisplayName = "SaveAsync keeps NormalizedName in sync with Name")]
public async Task SaveAsync_WhenNormalizedNameIsStale_ResyncsFromName()
{
var store = CreateStore("tenant-a");
var label = Label("label-1", "Urgent", "tenant-a");
label.NormalizedName = "stale";
await store.SaveAsync(label);
Assert.Equal("urgent", (await store.FindByIdAsync("label-1"))!.NormalizedName);
}
[Fact(DisplayName = "SaveManyAsync rejects a batch that repeats a normalized name already in the store")]
public async Task SaveManyAsync_WhenNormalizedNameExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveManyAsync([Label("label-2", "Urgent", "tenant-a")]));
Assert.Contains("already exists", exception.Message);
Assert.Null(await store.FindByIdAsync("label-2"));
}
[Fact(DisplayName = "SaveManyAsync rejects two different Ids that share a normalized name")]
public async Task SaveManyAsync_WhenBatchRepeatsNormalizedName_Throws()
{
var store = CreateStore("tenant-a");
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveManyAsync([
Label("label-1", "Urgent", "tenant-a"),
Label("label-2", "urgent", "tenant-a")
]));
Assert.Contains("already exists", exception.Message);
Assert.Empty((await store.ListAsync()).Items);
}
[Fact(DisplayName = "SaveManyAsync allows swapping normalized names within one batch")]
public async Task SaveManyAsync_WhenBatchSwapsNormalizedNames_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
await store.SaveAsync(Label("label-2", "Later", "tenant-a"));
await store.SaveManyAsync([
Label("label-1", "Later", "tenant-a"),
Label("label-2", "Urgent", "tenant-a")
]);
Assert.Equal("later", (await store.FindByIdAsync("label-1"))!.NormalizedName);
Assert.Equal("urgent", (await store.FindByIdAsync("label-2"))!.NormalizedName);
}
private static InMemoryLabelStore CreateStore(string tenantId) =>
new(new MemoryStore<Label>(), new MemoryStore<WorkflowDefinitionLabel>(), new TestTenantAccessor(tenantId));
private static Label Label(string id, string name, string? tenantId) =>
new()
{
Id = id,
Name = name,
TenantId = tenantId
};
}

View file

@ -0,0 +1,40 @@
using Elsa.Labels.Entities;
using Elsa.Persistence.EFCore.Modules.Labels;
using Microsoft.EntityFrameworkCore;
namespace Elsa.Persistence.EFCore.UnitTests;
/// <summary>
/// The Labels unique index must be per tenant, matching Secrets'
/// <c>IX_Secret_TenantId_NormalizedName</c>. A global unique index would make
/// the name a shared resource: the second tenant could not create "urgent".
/// </summary>
public class LabelNormalizedNameUniquenessTests
{
[Fact]
public void Label_HasUniqueIndex_OnTenantIdAndNormalizedName()
{
var builder = new ModelBuilder();
new Configurations().Configure(builder.Entity<Label>());
var index = builder.Model
.FindEntityType(typeof(Label))!
.GetIndexes()
.Single(x => x.IsUnique);
Assert.Equal(["TenantId", "NormalizedName"], index.Properties.Select(x => x.Name));
Assert.Equal("IX_Label_TenantId_NormalizedName", index.GetDatabaseName());
}
[Fact]
public void Label_NameAndNormalizedName_HaveSharedMaxLength()
{
var builder = new ModelBuilder();
new Configurations().Configure(builder.Entity<Label>());
var entity = builder.Model.FindEntityType(typeof(Label))!;
Assert.Equal(Label.NameMaxLength, entity.FindProperty(nameof(Label.Name))!.GetMaxLength());
Assert.Equal(Label.NameMaxLength, entity.FindProperty(nameof(Label.NormalizedName))!.GetMaxLength());
Assert.Equal(255, Label.NameMaxLength);
}
}

View file

@ -0,0 +1,101 @@
namespace Elsa.Persistence.EFCore.UnitTests;
/// <summary>
/// PerTenantLabelUniqueness must stamp null TenantId to "" and must not
/// auto-delete duplicate labels (associations would be at risk). A preflight
/// lists leftover keys and aborts; CreateIndex then fails loudly if any remain.
/// SQL Server/MySQL/Oracle also preflight over-length Name/NormalizedName
/// before narrowing those columns. PostgreSQL/SQLite keep provider column types.
/// </summary>
public class LabelPerTenantUniquenessMigrationTests
{
[Theory]
[InlineData("Elsa.Persistence.EFCore.Sqlite")]
[InlineData("Elsa.Persistence.EFCore.SqlServer")]
[InlineData("Elsa.Persistence.EFCore.PostgreSql")]
[InlineData("Elsa.Persistence.EFCore.MySql")]
[InlineData("Elsa.Persistence.EFCore.Oracle")]
public void PerTenantLabelUniqueness_StampsNullTenantIdAndDoesNotDeleteDuplicates(string providerProject)
{
var migration = FindMigration(providerProject);
Assert.Contains("TenantId", migration, StringComparison.Ordinal);
Assert.Contains("IS NULL", migration, StringComparison.Ordinal);
Assert.DoesNotContain("DELETE FROM", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DeleteData", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("COALESCE", migration, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("Elsa.Persistence.EFCore.Sqlite", "RAISE")]
[InlineData("Elsa.Persistence.EFCore.SqlServer", "THROW")]
[InlineData("Elsa.Persistence.EFCore.PostgreSql", "RAISE EXCEPTION")]
[InlineData("Elsa.Persistence.EFCore.MySql", "SIGNAL")]
[InlineData("Elsa.Persistence.EFCore.Oracle", "RAISE_APPLICATION_ERROR")]
public void PerTenantLabelUniqueness_PreflightsDuplicateKeys(string providerProject, string abortKeyword)
{
var migration = FindMigration(providerProject);
Assert.Contains(abortKeyword, migration, StringComparison.Ordinal);
Assert.Contains("COUNT(*)", migration, StringComparison.Ordinal);
Assert.Contains("Duplicate keys", migration, StringComparison.OrdinalIgnoreCase);
Assert.Contains("HAVING COUNT(*) > 1", migration, StringComparison.Ordinal);
}
[Theory]
[InlineData("Elsa.Persistence.EFCore.SqlServer")]
[InlineData("Elsa.Persistence.EFCore.MySql")]
[InlineData("Elsa.Persistence.EFCore.Oracle")]
public void PerTenantLabelUniqueness_PreflightsOverLengthRowsBeforeAlter(string providerProject)
{
var migration = FindMigration(providerProject);
Assert.Contains("255", migration, StringComparison.Ordinal);
Assert.Contains("Over-length", migration, StringComparison.OrdinalIgnoreCase);
Assert.Contains("AlterColumn", migration, StringComparison.Ordinal);
}
[Theory]
[InlineData("Elsa.Persistence.EFCore.Sqlite")]
[InlineData("Elsa.Persistence.EFCore.PostgreSql")]
public void PerTenantLabelUniqueness_KeepsUnboundedProviderColumnTypes(string providerProject)
{
var migration = FindMigration(providerProject);
Assert.DoesNotContain("AlterColumn", migration, StringComparison.Ordinal);
Assert.DoesNotContain("varchar(255)", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("nvarchar(255)", migration, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("Elsa.Persistence.EFCore.SqlServer", "[TenantId] IS NOT NULL")]
[InlineData("Elsa.Persistence.EFCore.Oracle", "\\\"TenantId\\\" IS NOT NULL")]
public void PerTenantLabelUniqueness_KeepsFilteredUniqueIndex(string providerProject, string filter)
{
var migration = FindMigration(providerProject);
Assert.Contains(filter, migration, StringComparison.Ordinal);
}
private static string FindMigration(string providerProject)
{
var repoRoot = FindRepoRoot();
var labelsDir = Path.Combine(repoRoot, "src", "modules", providerProject, "Migrations", "Labels");
var path = Directory.GetFiles(labelsDir, "*PerTenantLabelUniqueness.cs")
.Single(file => !file.EndsWith(".Designer.cs", StringComparison.Ordinal));
return File.ReadAllText(path);
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Elsa.sln")))
return directory.FullName;
directory = directory.Parent;
}
throw new InvalidOperationException("Could not find Elsa.sln from the test output directory.");
}
}