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:
parent
b23a10c2a4
commit
4b15b0166d
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}'.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue