diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e171260..510538c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ -# 5.0.4 +# 5.1.0 +- feat: Automatically produce user-friendly response messages in behaviors for Save and Delete operations that fail due to a violation of a SQL Server foreign key or unique constraint. This behavior can be controlled with the `DetailedEfConstraintExceptionMessages` setting in `.AddCoalesce(c => c.Configure(o => { ... }))`, or by overriding `StandardBehaviors.GetExceptionResult`. This is not a substitute for adding proper validation or other handling of related entities - it only exists to provide a better user experience in cases where the developer has forgotten to handle these situations. This behavior does respect Coalesce's security model and won't produce descriptions of types or values that the user is not allowed to see. + +- refactor: `CoalesceOptions.DetailedEntityFrameworkExceptionMessages` has been renamed to `CoalesceOptions.DetailedEFMigrationExceptionMessages` - fix: The "Max _N_ items retrieved" message in c-select now accounts for list calls that don't provide a count, e.g. by passing `noCount=true`. # 5.0.3 diff --git a/playground/Coalesce.Domain/AppDbContext.cs b/playground/Coalesce.Domain/AppDbContext.cs index ba5d3b2d0..ec4ccfab5 100644 --- a/playground/Coalesce.Domain/AppDbContext.cs +++ b/playground/Coalesce.Domain/AppDbContext.cs @@ -52,6 +52,12 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { + // Remove cascading deletes. + foreach (var relationship in modelBuilder.Model.GetEntityTypes().SelectMany(e => e.GetForeignKeys())) + { + relationship.DeleteBehavior = DeleteBehavior.Restrict; + } + modelBuilder.Entity().OwnsOne(p => p.Details, cb => { cb.OwnsOne(c => c.ManufacturingAddress); diff --git a/playground/Coalesce.Domain/Migrations/20241007205723_AddUniqueIndex.Designer.cs b/playground/Coalesce.Domain/Migrations/20241007205723_AddUniqueIndex.Designer.cs new file mode 100644 index 000000000..800fb33cc --- /dev/null +++ b/playground/Coalesce.Domain/Migrations/20241007205723_AddUniqueIndex.Designer.cs @@ -0,0 +1,541 @@ +// +using System; +using Coalesce.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Coalesce.Domain.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20241007205723_AddUniqueIndex")] + partial class AddUniqueIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Coalesce.Domain.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientIp") + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("KeyValue") + .HasColumnType("nvarchar(450)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Referrer") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("tinyint"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.HasIndex("Type"); + + b.HasIndex("UserId"); + + b.HasIndex("Type", "KeyValue"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case", b => + { + b.Property("CaseKey") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CaseKey")); + + b.Property("AssignedToId") + .HasColumnType("int"); + + b.Property("AttachmentHash") + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("AttachmentName") + .HasColumnType("nvarchar(max)"); + + b.Property("AttachmentSize") + .HasColumnType("bigint"); + + b.Property("AttachmentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DevTeamAssignedId") + .HasColumnType("int"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("Numbers") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReportedById") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("States") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Strings") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("CaseKey"); + + b.HasIndex("AssignedToId"); + + b.HasIndex("ReportedById"); + + b.ToTable("Case", (string)null); + }); + + modelBuilder.Entity("Coalesce.Domain.Case+CaseAttachmentContent", b => + { + b.Property("CaseKey") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.HasKey("CaseKey"); + + b.ToTable("Case", (string)null); + }); + + modelBuilder.Entity("Coalesce.Domain.CaseProduct", b => + { + b.Property("CaseProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CaseProductId")); + + b.Property("CaseId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("CaseProductId"); + + b.HasIndex("CaseId"); + + b.HasIndex("ProductId"); + + b.ToTable("CaseProduct"); + }); + + modelBuilder.Entity("Coalesce.Domain.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("CompanyId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Company"); + }); + + modelBuilder.Entity("Coalesce.Domain.Log", b => + { + b.Property("LogId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("LogId")); + + b.Property("Level") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.HasKey("LogId"); + + b.ToTable("Logs"); + }); + + modelBuilder.Entity("Coalesce.Domain.Person", b => + { + b.Property("PersonId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PersonId")); + + b.Property("ArbitraryCollectionOfStrings") + .HasColumnType("nvarchar(max)"); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasMaxLength(75) + .HasColumnType("nvarchar(75)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LastBath") + .HasColumnType("datetime2"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NextUpgrade") + .HasColumnType("datetimeoffset"); + + b.Property("ProfilePic") + .HasColumnType("varbinary(max)"); + + b.Property("Title") + .HasColumnType("int"); + + b.HasKey("PersonId"); + + b.HasIndex("CompanyId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("Coalesce.Domain.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId")); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("UniqueId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ProductUniqueId"); + + b.HasKey("ProductId"); + + b.HasIndex("UniqueId") + .IsUnique(); + + b.ToTable("Product"); + }); + + modelBuilder.Entity("Coalesce.Domain.ZipCode", b => + { + b.Property("Zip") + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Zip"); + + b.ToTable("ZipCodes"); + }); + + modelBuilder.Entity("IntelliTect.Coalesce.AuditLogging.AuditLogProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValueDescription") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValueDescription") + .HasColumnType("nvarchar(max)"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.Property("PropertyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("AuditLogProperties"); + }); + + modelBuilder.Entity("Coalesce.Domain.AuditLog", b => + { + b.HasOne("Coalesce.Domain.Person", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case", b => + { + b.HasOne("Coalesce.Domain.Person", "AssignedTo") + .WithMany("CasesAssigned") + .HasForeignKey("AssignedToId"); + + b.HasOne("Coalesce.Domain.Person", "ReportedBy") + .WithMany("CasesReported") + .HasForeignKey("ReportedById"); + + b.Navigation("AssignedTo"); + + b.Navigation("ReportedBy"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case+CaseAttachmentContent", b => + { + b.HasOne("Coalesce.Domain.Case", null) + .WithOne("AttachmentContent") + .HasForeignKey("Coalesce.Domain.Case+CaseAttachmentContent", "CaseKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Coalesce.Domain.CaseProduct", b => + { + b.HasOne("Coalesce.Domain.Case", "Case") + .WithMany("CaseProducts") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Coalesce.Domain.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Case"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Coalesce.Domain.Person", b => + { + b.HasOne("Coalesce.Domain.Company", "Company") + .WithMany("Employees") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Coalesce.Domain.Product", b => + { + b.OwnsOne("Coalesce.Domain.ProductDetails", "Details", b1 => + { + b1.Property("ProductId") + .HasColumnType("int"); + + b1.HasKey("ProductId"); + + b1.ToTable("Product"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + + b1.OwnsOne("Coalesce.Domain.StreetAddress", "CompanyHqAddress", b2 => + { + b2.Property("ProductDetailsProductId") + .HasColumnType("int"); + + b2.Property("Address") + .HasColumnType("nvarchar(max)"); + + b2.Property("City") + .HasColumnType("nvarchar(max)"); + + b2.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b2.Property("State") + .HasColumnType("nvarchar(max)"); + + b2.HasKey("ProductDetailsProductId"); + + b2.ToTable("Product"); + + b2.WithOwner() + .HasForeignKey("ProductDetailsProductId"); + }); + + b1.OwnsOne("Coalesce.Domain.StreetAddress", "ManufacturingAddress", b2 => + { + b2.Property("ProductDetailsProductId") + .HasColumnType("int"); + + b2.Property("Address") + .HasColumnType("nvarchar(max)"); + + b2.Property("City") + .HasColumnType("nvarchar(max)"); + + b2.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b2.Property("State") + .HasColumnType("nvarchar(max)"); + + b2.HasKey("ProductDetailsProductId"); + + b2.ToTable("Product"); + + b2.WithOwner() + .HasForeignKey("ProductDetailsProductId"); + }); + + b1.Navigation("CompanyHqAddress"); + + b1.Navigation("ManufacturingAddress"); + }); + + b.Navigation("Details") + .IsRequired(); + }); + + modelBuilder.Entity("IntelliTect.Coalesce.AuditLogging.AuditLogProperty", b => + { + b.HasOne("Coalesce.Domain.AuditLog", null) + .WithMany("Properties") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Coalesce.Domain.AuditLog", b => + { + b.Navigation("Properties"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case", b => + { + b.Navigation("AttachmentContent"); + + b.Navigation("CaseProducts"); + }); + + modelBuilder.Entity("Coalesce.Domain.Company", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Coalesce.Domain.Person", b => + { + b.Navigation("CasesAssigned"); + + b.Navigation("CasesReported"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/Coalesce.Domain/Migrations/20241007205723_AddUniqueIndex.cs b/playground/Coalesce.Domain/Migrations/20241007205723_AddUniqueIndex.cs new file mode 100644 index 000000000..5e2f282a8 --- /dev/null +++ b/playground/Coalesce.Domain/Migrations/20241007205723_AddUniqueIndex.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Coalesce.Domain.Migrations +{ + /// + public partial class AddUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("UPDATE Product SET ProductUniqueId = NEWID()"); + + migrationBuilder.CreateIndex( + name: "IX_Product_ProductUniqueId", + table: "Product", + column: "ProductUniqueId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Product_ProductUniqueId", + table: "Product"); + } + } +} diff --git a/playground/Coalesce.Domain/Migrations/20241007220500_RemoveCascadeDelete.Designer.cs b/playground/Coalesce.Domain/Migrations/20241007220500_RemoveCascadeDelete.Designer.cs new file mode 100644 index 000000000..2d0add075 --- /dev/null +++ b/playground/Coalesce.Domain/Migrations/20241007220500_RemoveCascadeDelete.Designer.cs @@ -0,0 +1,544 @@ +// +using System; +using Coalesce.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Coalesce.Domain.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20241007220500_RemoveCascadeDelete")] + partial class RemoveCascadeDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Coalesce.Domain.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientIp") + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("KeyValue") + .HasColumnType("nvarchar(450)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Referrer") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("tinyint"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.HasIndex("Type"); + + b.HasIndex("UserId"); + + b.HasIndex("Type", "KeyValue"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case", b => + { + b.Property("CaseKey") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CaseKey")); + + b.Property("AssignedToId") + .HasColumnType("int"); + + b.Property("AttachmentHash") + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("AttachmentName") + .HasColumnType("nvarchar(max)"); + + b.Property("AttachmentSize") + .HasColumnType("bigint"); + + b.Property("AttachmentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DevTeamAssignedId") + .HasColumnType("int"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("Numbers") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReportedById") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("States") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Strings") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("CaseKey"); + + b.HasIndex("AssignedToId"); + + b.HasIndex("ReportedById"); + + b.ToTable("Case", (string)null); + }); + + modelBuilder.Entity("Coalesce.Domain.Case+CaseAttachmentContent", b => + { + b.Property("CaseKey") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.HasKey("CaseKey"); + + b.ToTable("Case", (string)null); + }); + + modelBuilder.Entity("Coalesce.Domain.CaseProduct", b => + { + b.Property("CaseProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CaseProductId")); + + b.Property("CaseId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("CaseProductId"); + + b.HasIndex("CaseId"); + + b.HasIndex("ProductId"); + + b.ToTable("CaseProduct"); + }); + + modelBuilder.Entity("Coalesce.Domain.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("CompanyId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("WebsiteUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Company"); + }); + + modelBuilder.Entity("Coalesce.Domain.Log", b => + { + b.Property("LogId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("LogId")); + + b.Property("Level") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.HasKey("LogId"); + + b.ToTable("Logs"); + }); + + modelBuilder.Entity("Coalesce.Domain.Person", b => + { + b.Property("PersonId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PersonId")); + + b.Property("ArbitraryCollectionOfStrings") + .HasColumnType("nvarchar(max)"); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasMaxLength(75) + .HasColumnType("nvarchar(75)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LastBath") + .HasColumnType("datetime2"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NextUpgrade") + .HasColumnType("datetimeoffset"); + + b.Property("ProfilePic") + .HasColumnType("varbinary(max)"); + + b.Property("Title") + .HasColumnType("int"); + + b.HasKey("PersonId"); + + b.HasIndex("CompanyId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("Coalesce.Domain.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId")); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("UniqueId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ProductUniqueId"); + + b.HasKey("ProductId"); + + b.HasIndex("UniqueId") + .IsUnique(); + + b.ToTable("Product"); + }); + + modelBuilder.Entity("Coalesce.Domain.ZipCode", b => + { + b.Property("Zip") + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Zip"); + + b.ToTable("ZipCodes"); + }); + + modelBuilder.Entity("IntelliTect.Coalesce.AuditLogging.AuditLogProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValueDescription") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValueDescription") + .HasColumnType("nvarchar(max)"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.Property("PropertyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("AuditLogProperties"); + }); + + modelBuilder.Entity("Coalesce.Domain.AuditLog", b => + { + b.HasOne("Coalesce.Domain.Person", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case", b => + { + b.HasOne("Coalesce.Domain.Person", "AssignedTo") + .WithMany("CasesAssigned") + .HasForeignKey("AssignedToId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Coalesce.Domain.Person", "ReportedBy") + .WithMany("CasesReported") + .HasForeignKey("ReportedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedTo"); + + b.Navigation("ReportedBy"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case+CaseAttachmentContent", b => + { + b.HasOne("Coalesce.Domain.Case", null) + .WithOne("AttachmentContent") + .HasForeignKey("Coalesce.Domain.Case+CaseAttachmentContent", "CaseKey") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Coalesce.Domain.CaseProduct", b => + { + b.HasOne("Coalesce.Domain.Case", "Case") + .WithMany("CaseProducts") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Coalesce.Domain.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Case"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Coalesce.Domain.Person", b => + { + b.HasOne("Coalesce.Domain.Company", "Company") + .WithMany("Employees") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Coalesce.Domain.Product", b => + { + b.OwnsOne("Coalesce.Domain.ProductDetails", "Details", b1 => + { + b1.Property("ProductId") + .HasColumnType("int"); + + b1.HasKey("ProductId"); + + b1.ToTable("Product"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + + b1.OwnsOne("Coalesce.Domain.StreetAddress", "CompanyHqAddress", b2 => + { + b2.Property("ProductDetailsProductId") + .HasColumnType("int"); + + b2.Property("Address") + .HasColumnType("nvarchar(max)"); + + b2.Property("City") + .HasColumnType("nvarchar(max)"); + + b2.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b2.Property("State") + .HasColumnType("nvarchar(max)"); + + b2.HasKey("ProductDetailsProductId"); + + b2.ToTable("Product"); + + b2.WithOwner() + .HasForeignKey("ProductDetailsProductId"); + }); + + b1.OwnsOne("Coalesce.Domain.StreetAddress", "ManufacturingAddress", b2 => + { + b2.Property("ProductDetailsProductId") + .HasColumnType("int"); + + b2.Property("Address") + .HasColumnType("nvarchar(max)"); + + b2.Property("City") + .HasColumnType("nvarchar(max)"); + + b2.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b2.Property("State") + .HasColumnType("nvarchar(max)"); + + b2.HasKey("ProductDetailsProductId"); + + b2.ToTable("Product"); + + b2.WithOwner() + .HasForeignKey("ProductDetailsProductId"); + }); + + b1.Navigation("CompanyHqAddress"); + + b1.Navigation("ManufacturingAddress"); + }); + + b.Navigation("Details") + .IsRequired(); + }); + + modelBuilder.Entity("IntelliTect.Coalesce.AuditLogging.AuditLogProperty", b => + { + b.HasOne("Coalesce.Domain.AuditLog", null) + .WithMany("Properties") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Coalesce.Domain.AuditLog", b => + { + b.Navigation("Properties"); + }); + + modelBuilder.Entity("Coalesce.Domain.Case", b => + { + b.Navigation("AttachmentContent"); + + b.Navigation("CaseProducts"); + }); + + modelBuilder.Entity("Coalesce.Domain.Company", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Coalesce.Domain.Person", b => + { + b.Navigation("CasesAssigned"); + + b.Navigation("CasesReported"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/Coalesce.Domain/Migrations/20241007220500_RemoveCascadeDelete.cs b/playground/Coalesce.Domain/Migrations/20241007220500_RemoveCascadeDelete.cs new file mode 100644 index 000000000..9daf70efe --- /dev/null +++ b/playground/Coalesce.Domain/Migrations/20241007220500_RemoveCascadeDelete.cs @@ -0,0 +1,183 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Coalesce.Domain.Migrations +{ + /// + public partial class RemoveCascadeDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AuditLogProperties_AuditLogs_ParentId", + table: "AuditLogProperties"); + + migrationBuilder.DropForeignKey( + name: "FK_AuditLogs_Person_UserId", + table: "AuditLogs"); + + migrationBuilder.DropForeignKey( + name: "FK_Case_Person_AssignedToId", + table: "Case"); + + migrationBuilder.DropForeignKey( + name: "FK_Case_Person_ReportedById", + table: "Case"); + + migrationBuilder.DropForeignKey( + name: "FK_CaseProduct_Case_CaseId", + table: "CaseProduct"); + + migrationBuilder.DropForeignKey( + name: "FK_CaseProduct_Product_ProductId", + table: "CaseProduct"); + + migrationBuilder.DropForeignKey( + name: "FK_Person_Company_CompanyId", + table: "Person"); + + migrationBuilder.AddForeignKey( + name: "FK_AuditLogProperties_AuditLogs_ParentId", + table: "AuditLogProperties", + column: "ParentId", + principalTable: "AuditLogs", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_AuditLogs_Person_UserId", + table: "AuditLogs", + column: "UserId", + principalTable: "Person", + principalColumn: "PersonId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Case_Person_AssignedToId", + table: "Case", + column: "AssignedToId", + principalTable: "Person", + principalColumn: "PersonId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Case_Person_ReportedById", + table: "Case", + column: "ReportedById", + principalTable: "Person", + principalColumn: "PersonId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_CaseProduct_Case_CaseId", + table: "CaseProduct", + column: "CaseId", + principalTable: "Case", + principalColumn: "CaseKey", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_CaseProduct_Product_ProductId", + table: "CaseProduct", + column: "ProductId", + principalTable: "Product", + principalColumn: "ProductId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Person_Company_CompanyId", + table: "Person", + column: "CompanyId", + principalTable: "Company", + principalColumn: "CompanyId", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AuditLogProperties_AuditLogs_ParentId", + table: "AuditLogProperties"); + + migrationBuilder.DropForeignKey( + name: "FK_AuditLogs_Person_UserId", + table: "AuditLogs"); + + migrationBuilder.DropForeignKey( + name: "FK_Case_Person_AssignedToId", + table: "Case"); + + migrationBuilder.DropForeignKey( + name: "FK_Case_Person_ReportedById", + table: "Case"); + + migrationBuilder.DropForeignKey( + name: "FK_CaseProduct_Case_CaseId", + table: "CaseProduct"); + + migrationBuilder.DropForeignKey( + name: "FK_CaseProduct_Product_ProductId", + table: "CaseProduct"); + + migrationBuilder.DropForeignKey( + name: "FK_Person_Company_CompanyId", + table: "Person"); + + migrationBuilder.AddForeignKey( + name: "FK_AuditLogProperties_AuditLogs_ParentId", + table: "AuditLogProperties", + column: "ParentId", + principalTable: "AuditLogs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AuditLogs_Person_UserId", + table: "AuditLogs", + column: "UserId", + principalTable: "Person", + principalColumn: "PersonId"); + + migrationBuilder.AddForeignKey( + name: "FK_Case_Person_AssignedToId", + table: "Case", + column: "AssignedToId", + principalTable: "Person", + principalColumn: "PersonId"); + + migrationBuilder.AddForeignKey( + name: "FK_Case_Person_ReportedById", + table: "Case", + column: "ReportedById", + principalTable: "Person", + principalColumn: "PersonId"); + + migrationBuilder.AddForeignKey( + name: "FK_CaseProduct_Case_CaseId", + table: "CaseProduct", + column: "CaseId", + principalTable: "Case", + principalColumn: "CaseKey", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_CaseProduct_Product_ProductId", + table: "CaseProduct", + column: "ProductId", + principalTable: "Product", + principalColumn: "ProductId", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Person_Company_CompanyId", + table: "Person", + column: "CompanyId", + principalTable: "Company", + principalColumn: "CompanyId", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs b/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs index 94458bfd5..12624f370 100644 --- a/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs +++ b/playground/Coalesce.Domain/Migrations/AppDbContextModelSnapshot.cs @@ -309,6 +309,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ProductId"); + b.HasIndex("UniqueId") + .IsUnique(); + b.ToTable("Product"); }); @@ -365,7 +368,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasOne("Coalesce.Domain.Person", "User") .WithMany() - .HasForeignKey("UserId"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); b.Navigation("User"); }); @@ -374,11 +378,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasOne("Coalesce.Domain.Person", "AssignedTo") .WithMany("CasesAssigned") - .HasForeignKey("AssignedToId"); + .HasForeignKey("AssignedToId") + .OnDelete(DeleteBehavior.Restrict); b.HasOne("Coalesce.Domain.Person", "ReportedBy") .WithMany("CasesReported") - .HasForeignKey("ReportedById"); + .HasForeignKey("ReportedById") + .OnDelete(DeleteBehavior.Restrict); b.Navigation("AssignedTo"); @@ -390,7 +396,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Coalesce.Domain.Case", null) .WithOne("AttachmentContent") .HasForeignKey("Coalesce.Domain.Case+CaseAttachmentContent", "CaseKey") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); }); @@ -399,13 +405,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Coalesce.Domain.Case", "Case") .WithMany("CaseProducts") .HasForeignKey("CaseId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.HasOne("Coalesce.Domain.Product", "Product") .WithMany() .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Case"); @@ -418,7 +424,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Coalesce.Domain.Company", "Company") .WithMany("Employees") .HasForeignKey("CompanyId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Company"); @@ -502,7 +508,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Coalesce.Domain.AuditLog", null) .WithMany("Properties") .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); }); diff --git a/playground/Coalesce.Domain/Product.cs b/playground/Coalesce.Domain/Product.cs index 9f682407d..b931b9ff3 100644 --- a/playground/Coalesce.Domain/Product.cs +++ b/playground/Coalesce.Domain/Product.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IntelliTect.Coalesce.DataAnnotations; +using Microsoft.EntityFrameworkCore; #nullable disable @@ -12,6 +13,7 @@ namespace Coalesce.Domain [Create(Roles = "Admin")] [Edit(Roles = "Admin")] [Description("A product that can be purchased.")] + [Index(nameof(UniqueId), IsUnique = true)] public class Product { public int ProductId { get; set; } @@ -23,10 +25,10 @@ public class Product public ProductDetails Details { get; set; } [Column("ProductUniqueId")] - [Read(Roles = "User")] + [Read] [Edit(Roles = "Admin")] [DataType(DataType.Password)] - public Guid UniqueId { get; set; } + public Guid UniqueId { get; set; } = Guid.NewGuid(); [NotMapped] public object Unknown { get; set; } = "unknown value"; diff --git a/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs b/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs index c7638f5c5..7ce4e0cdf 100644 --- a/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs +++ b/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs @@ -49,7 +49,7 @@ public override void MapTo(Coalesce.Domain.Product entity, IMappingContext conte if (ShouldMapTo(nameof(ProductId))) entity.ProductId = (ProductId ?? entity.ProductId); if (ShouldMapTo(nameof(Name))) entity.Name = Name; - if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); + if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); if (ShouldMapTo(nameof(Unknown))) entity.Unknown = Unknown; } @@ -84,11 +84,11 @@ public override void MapFrom(Coalesce.Domain.Product obj, IMappingContext contex this.ProductId = obj.ProductId; this.Name = obj.Name; + this.UniqueId = obj.UniqueId; this.Unknown = obj.Unknown; this.Details = obj.Details.MapToDto(context, tree?[nameof(this.Details)]); - if ((context.IsInRoleCached("User"))) this.UniqueId = obj.UniqueId; } } } diff --git a/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs b/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs index 598bc40a1..f22be3787 100644 --- a/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs +++ b/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs @@ -49,7 +49,7 @@ public override void MapTo(Coalesce.Domain.Product entity, IMappingContext conte if (ShouldMapTo(nameof(ProductId))) entity.ProductId = (ProductId ?? entity.ProductId); if (ShouldMapTo(nameof(Name))) entity.Name = Name; - if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); + if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); if (ShouldMapTo(nameof(Unknown))) entity.Unknown = Unknown; } @@ -84,11 +84,11 @@ public override void MapFrom(Coalesce.Domain.Product obj, IMappingContext contex this.ProductId = obj.ProductId; this.Name = obj.Name; + this.UniqueId = obj.UniqueId; this.Unknown = obj.Unknown; this.Details = obj.Details.MapToDto(context, tree?[nameof(this.Details)]); - if ((context.IsInRoleCached("User"))) this.UniqueId = obj.UniqueId; } } } diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Product.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Product.cs index 210c2759f..454f6d673 100644 --- a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Product.cs +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/Product.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using System.Threading.Tasks; using IntelliTect.Coalesce.DataAnnotations; +using Microsoft.EntityFrameworkCore; namespace IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext { @@ -11,6 +13,10 @@ namespace IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext [Read(RoleNames.Admin, RoleNames.User)] [Create(Roles = RoleNames.Admin)] [Edit(Roles = RoleNames.Admin)] + // These indexes are used for ExceptionResultTests (StandardBehaviors.GetExceptionResult) + [Index(nameof(UniqueId1), IsUnique = true)] + [Index(nameof(UniqueId1), nameof(UniqueId2), IsUnique = true)] + [Index(nameof(TenantId), nameof(UniqueId1), IsUnique = true)] public class Product { public int ProductId { get; set; } @@ -18,5 +24,14 @@ public class Product [Search(SearchMethod = SearchAttribute.SearchMethods.Contains)] [DefaultOrderBy] public string Name { get; set; } + + [InternalUse] + public int TenantId { get; set; } + + [Display(Name = "ID1")] + public string UniqueId1 { get; set; } + + [Display(Name = "ID2")] + public string UniqueId2 { get; set; } } } diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Api/Behaviors/SqlServerExceptionResultTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Api/Behaviors/SqlServerExceptionResultTests.cs new file mode 100644 index 000000000..4a736c382 --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/Tests/Api/Behaviors/SqlServerExceptionResultTests.cs @@ -0,0 +1,292 @@ +using IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext; +using IntelliTect.Coalesce.Tests.Util; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace IntelliTect.Coalesce.Tests.Tests.Api.Behaviors; + +public class SqlServerExceptionResultTests +{ + public SqlServerExceptionResultTests() + { + Db = new AppDbContext(new DbContextOptionsBuilder() + .UseSqlServer() + .Options + ); + CrudContext = new CrudContext(Db, () => new System.Security.Claims.ClaimsPrincipal()) + { + ReflectionRepository = ReflectionRepositoryFactory.Reflection + }; + } + + public AppDbContext Db { get; } + public CrudContext CrudContext { get; } + + private StandardBehaviors Behaviors() + where T : class, new() + => new StandardBehaviors(CrudContext); + + [Fact] + public void InsertFkConflict() + { + Db.Add(new CaseProduct { CaseId = 1, ProductId = 42 }); + + var exception = CreateException( + "The INSERT statement conflicted with the FOREIGN KEY constraint \"FK_CaseProduct_Product_ProductId\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.Product\", column 'ProductId'."); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { "ProductId" } }); + + result.AssertError("The value of Product is not valid."); + } + + [Fact] + public void UpdateFkConflict() + { + var entry = Db.Entry(new CaseProduct { CaseId = 1, ProductId = 42 }); + entry.State = EntityState.Unchanged; + entry.Property("ProductId").IsModified = true; + + var exception = CreateException( + "The UPDATE statement conflicted with the FOREIGN KEY constraint \"FK_CaseProduct_Product_ProductId\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.Product\", column 'ProductId'."); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { "ProductId" } }); + + result.AssertError("The value of Product is not valid."); + } + + [Fact] + public void FkConstraint_WhenPropIsNotUserChanged_DoesNotProduceFriendlyError() + { + var entry = Db.Entry(new CaseProduct { CaseId = 1, ProductId = 42 }); + entry.State = EntityState.Unchanged; + entry.Property("ProductId").IsModified = true; + + var exception = CreateException( + "The UPDATE statement conflicted with the FOREIGN KEY constraint \"FK_CaseProduct_Product_ProductId\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.Product\", column 'ProductId'."); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { /* empty */ } }); + + Assert.Null(result); + } + + [Fact] + public void DeleteFkConflict() + { + var entry = Db.Entry(new Product { ProductId = 42 }); + entry.State = EntityState.Deleted; + + var exception = CreateException( + "The DELETE statement conflicted with the REFERENCE constraint \"FK_CaseProduct_Product_ProductId\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.CaseProduct\", column 'ProductId'."); + + var result = Behaviors() + .GetExceptionResult(exception, null); + + result.AssertError("The Product is still referenced by at least one Case Product."); + } + + [Fact] + public void FkConstraint_UnknownTableName() + { + Db.Add(new CaseProduct { CaseId = 1, ProductId = 42 }); + + var exception = CreateException( + "The INSERT statement conflicted with the FOREIGN KEY constraint \"FK_CaseProduct_Product_ProductId\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.FooTable\", column 'ProductId'."); + + var result = Behaviors().GetExceptionResult(exception, new TestSparseDto()); + + Assert.Null(result); + } + + [Fact] + public void FkConstraint_UnknownColumnName() + { + Db.Add(new CaseProduct { CaseId = 1, ProductId = 42 }); + + var exception = CreateException( + "The INSERT statement conflicted with the FOREIGN KEY constraint \"FK_CaseProduct_Product_ProductId\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.Product\", column 'FooColumn'."); + + var result = Behaviors().GetExceptionResult(exception, new TestSparseDto()); + + Assert.Null(result); + } + + [Fact] + public void FkConstraint_UnknownConstraintName() + { + Db.Add(new CaseProduct { CaseId = 1, ProductId = 42 }); + + var exception = CreateException( + "The INSERT statement conflicted with the FOREIGN KEY constraint \"FK_FooFk\". " + + "The conflict occurred in database \"FooDb\", table \"dbo.Product\", column 'ProductId'."); + + var result = Behaviors().GetExceptionResult(exception, new TestSparseDto()); + + Assert.Null(result); + } + + [Fact] + public void UniqueIndexConflict() + { + Db.Add(new Product { UniqueId1 = "qwerty" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_Product_UniqueId1'. " + + "The duplicate key value is (qwerty)"); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { "UniqueId1" } }); + + result.AssertError("A different item with ID1 'qwerty' already exists."); + } + + [Fact] + public void UniqueIndexConflict_WhenPropIsNotUserChanged_DoesNotProduceFriendlyError() + { + Db.Add(new Product { UniqueId1 = "qwerty" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_Product_UniqueId1'. " + + "The duplicate key value is (qwerty)"); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { /* empty */ } }); + + Assert.Null(result); + } + + [Fact] + public void UniqueIndexConflict_MultiPropIndex() + { + Db.Add(new Product { UniqueId1 = "qwe, rty", UniqueId2 = "foo,bar" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_Product_UniqueId1_UniqueId2'. " + + "The duplicate key value is (qwe, rty, foo,bar)"); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { "UniqueId1", "UniqueId2" } }); + + result.AssertError("A different item with ID1 'qwe, rty' and ID2 'foo,bar' already exists."); + } + + [Fact] + public void UniqueIndexConflict_MultiPropIndex_PartiallyChanged() + { + Db.Add(new Product { UniqueId1 = "qwe, rty", UniqueId2 = "foo,bar" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_Product_UniqueId1_UniqueId2'. " + + "The duplicate key value is (qwe, rty, foo,bar)"); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { "UniqueId2" } }); + + result.AssertError("A different item with ID1 'qwe, rty' and ID2 'foo,bar' already exists."); + } + + [Fact] + public void UniqueIndexConflict_PartiallyInternalMultiPropIndex_ReportsNonInternalParts() + { + Db.Add(new Product { TenantId = 1, UniqueId1 = "qwerty" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_Product_TenantId_UniqueId1'. " + + "The duplicate key value is (1, qwerty)"); + + var result = Behaviors() + .GetExceptionResult(exception, new TestSparseDto() { ChangedProperties = { "UniqueId1" } }); + + // Doesn't report the tenantID, which is internal. + result.AssertError("A different item with ID1 'qwerty' already exists."); + } + + [Fact] + public void UniqueIndexConflict_UnknownTableName() + { + Db.Add(new Product { TenantId = 1, UniqueId1 = "qwerty" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.FooBar' with unique index 'IX_Product_TenantId_UniqueId1'. " + + "The duplicate key value is (1, qwerty)"); + + var result = Behaviors().GetExceptionResult(exception, new TestSparseDto()); + + Assert.Null(result); + } + + [Fact] + public void UniqueIndexConflict_UnknownIndex() + { + Db.Add(new Product { TenantId = 1, UniqueId1 = "qwerty" }); + + var exception = CreateException( + "Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_FooIndex'. " + + "The duplicate key value is (1, qwerty)"); + + var result = Behaviors().GetExceptionResult(exception, new TestSparseDto()); + + Assert.Null(result); + } + + private DbUpdateException CreateException(string error) + { + return new DbUpdateException("", CreateSqlException(547, error), Db.ChangeTracker.Entries().ToList()); + } + + private static SqlException CreateSqlException(int errorCode, string errorMessage) + { + // AI-generated + // Use reflection to create a SqlError instance + var sqlError = CreateSqlError(errorCode, errorMessage); + + // Create an SqlErrorCollection and add the SqlError instance to it + var errorCollection = CreateSqlErrorCollection(sqlError); + + // Call the internal static CreateException method on SqlException + return CreateSqlException(errorCollection); + } + + private static SqlError CreateSqlError(int errorCode, string errorMessage) + { + // (int infoNumber, byte errorState, byte errorClass, string server, string message, string procedure, int lineNumber, uint win32ErrorCode, Exception exception = null) + var sqlErrorCtor = typeof(SqlError).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic)[0]; + return (SqlError)sqlErrorCtor.Invoke([errorCode, (byte)0, (byte)0, "Server", errorMessage, "Procedure", 0, (uint)0, null]); + } + + private static SqlErrorCollection CreateSqlErrorCollection(SqlError sqlError) + { + // AI-generated + // Create an empty SqlErrorCollection instance using reflection + var errorCollection = Activator.CreateInstance(typeof(SqlErrorCollection), true); + + // Use reflection to add the SqlError instance to the SqlErrorCollection + var method = typeof(SqlErrorCollection).GetMethod("Add", BindingFlags.Instance | BindingFlags.NonPublic); + method.Invoke(errorCollection, new object[] { sqlError }); + + return (SqlErrorCollection)errorCollection; + } + + private static SqlException CreateSqlException(SqlErrorCollection errorCollection) + { + // AI-generated + // Use reflection to invoke the internal static method CreateException(SqlErrorCollection, string) + var sqlExceptionType = typeof(SqlException); + var createExceptionMethod = sqlExceptionType.GetMethod("CreateException", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(SqlErrorCollection), typeof(string) }, null); + + // Call the method and return the SqlException + return (SqlException)createExceptionMethod.Invoke(null, new object[] { errorCollection, "11.0.0" }); // SQL Server version is arbitrary + } +} diff --git a/src/IntelliTect.Coalesce.Tests/Util/TestDto.cs b/src/IntelliTect.Coalesce.Tests/Util/TestDto.cs index 57d49be7f..8aeb0b7cf 100644 --- a/src/IntelliTect.Coalesce.Tests/Util/TestDto.cs +++ b/src/IntelliTect.Coalesce.Tests/Util/TestDto.cs @@ -1,4 +1,6 @@ -using System; +using IntelliTect.Coalesce.Models; +using System; +using System.Collections.Generic; #nullable enable @@ -31,6 +33,12 @@ void IResponseDto.MapFrom(T obj, IMappingContext context, IncludeTree? tree) void IParameterDto.MapTo(T obj, IMappingContext context) { MapTo?.Invoke(obj); - } + } + } + + internal class TestSparseDto : TestDto, ISparseDto + where T : class + { + public ISet ChangedProperties { get; } = new HashSet(); } } diff --git a/src/IntelliTect.Coalesce/Api/Behaviors/StandardBehaviors`1.cs b/src/IntelliTect.Coalesce/Api/Behaviors/StandardBehaviors`1.cs index 951da3381..ed0855715 100644 --- a/src/IntelliTect.Coalesce/Api/Behaviors/StandardBehaviors`1.cs +++ b/src/IntelliTect.Coalesce/Api/Behaviors/StandardBehaviors`1.cs @@ -3,11 +3,16 @@ using IntelliTect.Coalesce.Mapping; using IntelliTect.Coalesce.Models; using IntelliTect.Coalesce.TypeDefinition; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Data.Common; +using System.Linq; using System.Reflection; using System.Security.Claims; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace IntelliTect.Coalesce @@ -293,7 +298,16 @@ IDataSourceParameters parameters return new ItemResult(beforeSave); } - await ExecuteSaveAsync(kind, originalItem, item); + try + { + await ExecuteSaveAsync(kind, originalItem, item); + } + catch (Exception ex) + { + var exResult = GetExceptionResult(ex, incomingDto); + if (exResult is not null) return new ItemResult(exResult); + throw; + } // Pull the object to get any changes. ItemResult newItem = await FetchObjectAfterSaveAsync(dataSource, parameters, item); @@ -402,8 +416,16 @@ IDataSourceParameters parameters // Perform the delete operation against the database. // By default, this removes the item from its DbSet<> and calls SaveChanges(). // This might be overridden to set a deleted flag on the object instead. - await ExecuteDeleteAsync(item); - + try + { + await ExecuteDeleteAsync(item); + } + catch (Exception ex) + { + var exResult = GetExceptionResult(ex, null); + if (exResult is not null) return new ItemResult(exResult); + throw; + } // Pull the object to see if it can still be seen by the user. @@ -501,5 +523,192 @@ IDataSourceParameters parameters public virtual void AfterDelete(ref T item, ref IncludeTree? includeTree) { } #endregion + + /// + /// Attempt to transform a database exception into a user-friendly error message. + /// Requires to be enabled. + /// + /// The database exception that was thrown by EF + /// The incoming dto that the current operation is consuming, if any. Used to distinguish errors that were triggered by the user's input, as opposed to errors triggered by custom code in the behaviors implementation. + public virtual ItemResult? GetExceptionResult(Exception ex, IParameterDto? incomingDto) + { + if (!Context.Options.DetailedEfConstraintExceptionMessages) return null; + + if (ex is not DbUpdateException dbUpdateException) + { + return null; + } + + DbContext? dbContext = dbUpdateException.Entries.FirstOrDefault()?.Context; + + if (dbUpdateException.InnerException is not DbException dbException || dbContext is null) + { + return null; + } + + // The INSERT statement conflicted with the FOREIGN KEY constraint "FK_CaseProduct_Product_ProductId". The conflict occurred in database "CoalesceDb", table "dbo.Product", column 'ProductId'. + // The UPDATE statement conflicted with the FOREIGN KEY constraint "FK_CaseProduct_Product_ProductId". The conflict occurred in database "CoalesceDb", table "dbo.Product", column 'ProductId'. + // The DELETE statement conflicted with the REFERENCE constraint "FK_CaseProduct_Product_ProductId". The conflict occurred in database "CoalesceDb", table "dbo.CaseProduct", column 'ProductId'. + Match match = Regex.Match( + dbException.Message, + @"(?INSERT|UPDATE|DELETE) statement conflicted with the (?:FOREIGN KEY|REFERENCE) constraint ""(?[^""]+)""\. The conflict occurred in database ""[^""]+"", table ""(?[^""]+)"", column '(?[^']+)'"); + + if (match.Success) + { + string kind = match.Groups["kind"].Value; + string constraint = match.Groups["constraint"].Value; + string table = match.Groups["table"].Value; + string column = match.Groups["column"].Value; + + var conflictedTable = dbContext.Model + .GetEntityTypes() + .Where(t => + t.GetSchemaQualifiedTableName() == table || + (t.GetSchema() is null && table.EndsWith('.' + t.GetTableName())) + ) + .FirstOrDefault(); + + if (conflictedTable is null) return null; + + if (kind is "DELETE") + { + if ( + // This operation isn't deleting this single entity, so it might have been some other entity being deleted that triggered the violation. + dbUpdateException.Entries.Any(entry => entry.State == EntityState.Deleted && entry.Metadata.ClrType != typeof(T) && !entry.Metadata.IsOwned()) + ) + { + return null; + } + + var dependent = Context.ReflectionRepository.GetClassViewModel(conflictedTable.ClrType); + var referencedBy = dependent?.Type.IsInternalUse != false + ? "other item" // Hide the type's name if it's internal or unknown + : dependent.DisplayName; + + return $"The {this.ClassViewModel.DisplayName} is still referenced by at least one {referencedBy}."; + } + + var fk = conflictedTable.GetReferencingForeignKeys().Where(f => f.GetConstraintName() == constraint).FirstOrDefault(); + var dependentEntity = fk?.DeclaringEntityType; + var referenceNav = fk?.DependentToPrincipal; + + if ( + referenceNav is not null && + dependentEntity is not null && + dependentEntity.ClrType == typeof(T) && + Context.ReflectionRepository.GetClassViewModel(dependentEntity.ClrType) is ClassViewModel dependentCvm && + dependentCvm.PropertyByName(referenceNav.Name) is PropertyViewModel referenceNavPvm + ) + { + // Find the FK prop that was changed. This will filter out an internal part of an FK like TenantId. + var changedFkProp = incomingDto is ISparseDto sparse + ? fk!.Properties.FirstOrDefault(p => sparse.ChangedProperties.Contains(p.Name)) + : fk!.Properties.FirstOrDefault(p => dependentCvm.PropertyByName(p.Name)?.SecurityInfo.Read.IsAllowed(User) == true); + + // Check that the user was actually changing this prop + // (rather than the backend manually setting it in the behaviors). + // This will also enforce that the prop is at least writable under *some* + // circumstances and isn't read-only or internal through Coalesce. + if (changedFkProp is not null) + { + var message = $"The value of {referenceNavPvm.DisplayName} is not valid."; + return new(false, message, [new ValidationIssue(changedFkProp.Name, message)]); + } + } + } + + // Cannot insert duplicate key row in object 'dbo.Product' with unique index 'IX_Product_ProductUniqueId'. The duplicate key value is (acab7c64-5cbd-472f-8f06-e442c037eda9) + // Cannot insert duplicate key row in object 'dbo.Table_1' with unique index 'IX_Unique_Foo_Bar'. The duplicate key value is (as,df, gh,jk). + match = Regex.Match( + dbException.Message, + @"Cannot insert duplicate key row in object '(?
[^""]+)' with unique index '(?[^""]+)'. The duplicate key value is \((?[^']+)\)"); + + if (match.Success) + { + string constraint = match.Groups["constraint"].Value; + string tableName = match.Groups["table"].Value; + string keyValue = match.Groups["keyValue"].Value; + + var table = dbContext.Model + .GetEntityTypes() + .FirstOrDefault(t => + t.GetSchemaQualifiedTableName() == tableName || + (t.GetSchema() is null && tableName.EndsWith('.' + t.GetTableName())) + ); + + var index = table?.GetIndexes().Where(f => f.GetDatabaseName() == constraint).FirstOrDefault(); + + if ( + index is not null && + table is not null && + table.ClrType == typeof(T) && + Context.ReflectionRepository.GetClassViewModel(table.ClrType) is ClassViewModel dependentCvm + ) + { + // The value may be contained in "keyValue" pulled from the database error, + // but SQL Server doesn't quote strings in the error message, so we don't really + // know how to find the right value since there could be commas in the middle of strings. + // So, find the affected entity ourselves by reconstructing the error message: + var entity = dbUpdateException.Entries + // Find the entity described by the error message + .FirstOrDefault(entry => + entry.Metadata.Equals(table) && + keyValue == string.Join(", ", index.Properties.Select(p => entry.CurrentValues[p])) + ); + + if (entity is null) + { + return null; + } + + // Reconstruct the violated unique values using only the values that the user is allowed to read. + // This will eliminate internal parts of the constraint like a TenantId. + + var mappingContext = new MappingContext(Context); + var propViewModels = index.Properties + .Select(p => dependentCvm.PropertyByName(p.Name)!) + .ToList(); + + // Check that the user was actually changing one of the props in the index + // (rather than the backend manually setting it in the behaviors). + // This will also enforce that the prop is at least writable under *some* + // circumstances and isn't read-only or internal through Coalesce. + if (incomingDto is ISparseDto sparse && !propViewModels.Any(p => sparse.ChangedProperties.Contains(p.Name))) + { + return null; + } + + var propsWithSecurity = propViewModels.ConvertAll(p => new + { + Prop = p, + UserCanRead = p.SecurityInfo.IsReadAllowed(mappingContext, entity.Entity), + }); + + // Only make this a user-friendly error if the user is allowed to read all parts of the index, + // or if the unreadable parts of the index are internal use (which allows a TenantId to be excluded + // while still presenting the rest of the props to the user). + if (!propsWithSecurity.All(p => p.UserCanRead || p.Prop.IsInternalUse)) return null; + + var valuesDisplay = propsWithSecurity + .Where(p => p.UserCanRead) + .Select(p => + { + var value = entity.CurrentValues[p.Prop.Name]; + if (!p.Prop.Type.IsNumber) + { + // Quote non-numbers so its clear what part of the message is the actual value + value = $"'{value}'"; + } + + return $"{p.Prop.DisplayName} {value}"; + }); + + var message = $"A different item with {string.Join(" and ", valuesDisplay)} already exists."; + return new(false, message, propViewModels.Select(p => new ValidationIssue(p.Name, message))); + } + } + + return null; + } } } diff --git a/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs b/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs index 84db6118b..66154fe58 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs @@ -81,11 +81,11 @@ public virtual void OnActionExecuted(ActionExecutedContext context) string message = context.Exception.Message; if ( - options.Value.DetailedEntityFrameworkExceptionMessages && + options.Value.DetailedEfMigrationExceptionMessages && (context.Exception as DbException ?? context.Exception?.InnerException) is DbException ) { - var dbMessage = GetDbContextExceptionMessage(context); + var dbMessage = GetDbContextMigrationExceptionMessage(context); if (!string.IsNullOrWhiteSpace(dbMessage)) { message = dbMessage + "\n\n" + message; @@ -117,7 +117,7 @@ public virtual void OnActionExecuted(ActionExecutedContext context) } } - private static string GetDbContextExceptionMessage(ActionExecutedContext context) + private static string GetDbContextMigrationExceptionMessage(ActionExecutedContext context) { List messages = []; try diff --git a/src/IntelliTect.Coalesce/Application/CoalesceOptions.cs b/src/IntelliTect.Coalesce/Application/CoalesceOptions.cs index 931d36600..905ce2462 100644 --- a/src/IntelliTect.Coalesce/Application/CoalesceOptions.cs +++ b/src/IntelliTect.Coalesce/Application/CoalesceOptions.cs @@ -22,17 +22,31 @@ public class CoalesceOptions /// public Func? ExceptionResponseFactory { get; set; } - private bool? _efErrors; + private bool? _migrationErrors; /// /// Determines whether detailed error messages about EF model/migration errors are returned in error responses. /// Requires to be enabled, and defaults to that value. /// + public bool DetailedEfMigrationExceptionMessages + { + get => DetailedExceptionMessages ? (_migrationErrors ?? DetailedExceptionMessages) : false; + set => _migrationErrors = value; + } + + [Obsolete("Renamed to DetailedEFMigrationExceptionMessages")] public bool DetailedEntityFrameworkExceptionMessages { - get => DetailedExceptionMessages ? (_efErrors ?? DetailedExceptionMessages) : false; - set => _efErrors = value; + get => DetailedEfMigrationExceptionMessages; + set => DetailedEfMigrationExceptionMessages = value; } + /// + /// If true, Coalesce will transform some database exceptions into user-friendly messages when these exceptions occur in Save and Delete operations through . + /// For SQL Server, this includes foreign key constraint violations and unique index violations. + /// These messages respect the security configuration of your models. These messages only serve as a fallback to produce a more acceptable user experience in cases where the developer neglects to add appropriate validation or other handling of related entities. + /// + public bool DetailedEfConstraintExceptionMessages { get; set; } = true; + /// /// If true, Coalesce will perform validation of incoming data using s /// present on your models during save operations (in ). diff --git a/src/IntelliTect.Coalesce/TypeDefinition/Security/PropertySecurityInfo.cs b/src/IntelliTect.Coalesce/TypeDefinition/Security/PropertySecurityInfo.cs index 0ec2e2468..73214bec7 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/Security/PropertySecurityInfo.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/Security/PropertySecurityInfo.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using System.Xml.Linq; namespace IntelliTect.Coalesce.TypeDefinition { @@ -125,6 +126,16 @@ public PropertySecurityInfo(PropertyViewModel prop) [Obsolete("This method cannot account for any custom IPropertyRestrictions.")] public bool IsReadAllowed(ClaimsPrincipal? user) => Read.IsAllowed(user); + public bool IsReadAllowed(IMappingContext mappingContext, object model) + { + if (!Read.IsAllowed(mappingContext.User)) return false; + + return Prop.SecurityInfo.Restrictions.All(r => mappingContext + .GetPropertyRestriction(r.TypeInfo) + .UserCanRead(mappingContext, Prop.Name, model) + ); + } + /// /// If true, the user can initialize the field on a new instance of the object. /// diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index f358dda2f..6c4ff62c6 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -2482,6 +2482,18 @@ export const Product = domain.types.Product = { type: "string", role: "value", }, + uniqueId1: { + name: "uniqueId1", + displayName: "ID1", + type: "string", + role: "value", + }, + uniqueId2: { + name: "uniqueId2", + displayName: "ID2", + type: "string", + role: "value", + }, }, methods: { }, diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 55fd0d71f..7153683af 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -455,6 +455,8 @@ export namespace Person { export interface Product extends Model { productId: number | null name: string | null + uniqueId1: string | null + uniqueId2: string | null } export class Product { diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index 9e9122d8f..1f5eb9033 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -772,6 +772,8 @@ export class PersonListViewModel extends ListViewModel<$models.Person, $apiClien export interface ProductViewModel extends $models.Product { productId: number | null; name: string | null; + uniqueId1: string | null; + uniqueId2: string | null; } export class ProductViewModel extends ViewModel<$models.Product, $apiClients.ProductApiClient, number> implements $models.Product { diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/TrackingBase.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/TrackingBase.cs index b4d2838f0..8f8516a72 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/TrackingBase.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/TrackingBase.cs @@ -32,14 +32,12 @@ public abstract class TrackingBase [InternalUse] public void SetTracking(string? userId) { - if (CreatedById == null) - { - CreatedById = userId; - } if (CreatedOn == default) { - // CreatedOn is handled separately so that we can avoid resetting the - // CreatedOn stamp if the entity wasn't created by a user. + // CreatedOn is checked so that we can avoid setting CreatedBy + // to some future modifying user if the entity was created with a CreatedOn + // stamp but not a CreatedBy stamp (which happens for entities created by migrations or background jobs). + CreatedById = userId; CreatedOn = DateTimeOffset.Now; }