From bdd566de83fe6130b6db467501a1850d315c7dc7 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 13:42:16 -0300 Subject: [PATCH] fix(work-orders): bound SiteCode/InternalWONumber so Phase4 indexes apply on SQL Server --- .../Auth/ApplicationDbContext.cs | 10 + ...624200000_Phase4_SearchIndexes.Designer.cs | 6 +- .../20260624200000_Phase4_SearchIndexes.cs | 25 +-- .../ApplicationDbContextModelSnapshot.cs | 6 +- .../Phase4SearchIndexesSqlServerTests.cs | 177 ++++++++++++++++++ 5 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a055fcd..9dbef87 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -42,6 +42,16 @@ namespace Data.SeaHavenIndustries .Property(w => w.RowVersion) .IsRowVersion(); + // Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable). + // SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits. + builder.Entity() + .Property(w => w.SiteCode) + .HasMaxLength(32); + + builder.Entity() + .Property(w => w.InternalWONumber) + .HasMaxLength(11); + builder.Entity() .HasIndex(w => w.SiteCode) .HasFilter("[istemplate] = 0"); diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs index ee0a600..e860475 100644 --- a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs @@ -1911,7 +1911,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("InternalWONumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); b.Property("IsDeleted") .HasColumnType("bit"); @@ -1989,7 +1990,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("SiteCode") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); b.Property("Source") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs index 51795a3..c92d732 100644 --- a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -10,26 +10,23 @@ namespace Data.SeaHavenIndustries.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { - // nvarchar(max) cannot be an index key; shrink before indexing. migrationBuilder.AlterColumn( name: "SiteCode", table: "workOrders", - type: "nvarchar(450)", - maxLength: 450, + type: "nvarchar(32)", + maxLength: 32, nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); + oldType: "nvarchar(max)"); migrationBuilder.AlterColumn( name: "InternalWONumber", table: "workOrders", - type: "nvarchar(450)", - maxLength: 450, + type: "nvarchar(11)", + maxLength: 11, nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); + oldType: "nvarchar(max)"); migrationBuilder.CreateIndex( name: "IX_workOrders_SiteCode", @@ -71,9 +68,8 @@ namespace Data.SeaHavenIndustries.Migrations type: "nvarchar(max)", nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(450)", - oldMaxLength: 450, - oldNullable: true); + oldType: "nvarchar(32)", + oldMaxLength: 32); migrationBuilder.AlterColumn( name: "InternalWONumber", @@ -81,9 +77,8 @@ namespace Data.SeaHavenIndustries.Migrations type: "nvarchar(max)", nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(450)", - oldMaxLength: 450, - oldNullable: true); + oldType: "nvarchar(11)", + oldMaxLength: 11); } } } diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 0a169ec..ee87ae1 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1908,7 +1908,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("InternalWONumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); b.Property("IsDeleted") .HasColumnType("bit"); @@ -1986,7 +1987,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("SiteCode") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); b.Property("Source") .HasColumnType("nvarchar(max)"); diff --git a/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs new file mode 100644 index 0000000..0ed2280 --- /dev/null +++ b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs @@ -0,0 +1,177 @@ +using System.Reflection; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace SeaHavenIndustries.Tests; + +public class Phase4SearchIndexesSqlServerTests +{ + private const string LocalDbMaster = + @"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3"; + + [Fact] + public void WorkOrder_IndexedStringColumns_HaveBoundedMaxLengths() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(LocalDbMaster) + .Options; + + using var context = new ApplicationDbContext(options); + var entity = context.Model.FindEntityType(typeof(WorkOrder)); + Assert.NotNull(entity); + + Assert.Equal(32, entity.FindProperty(nameof(WorkOrder.SiteCode))!.GetMaxLength()); + Assert.Equal(11, entity.FindProperty(nameof(WorkOrder.InternalWONumber))!.GetMaxLength()); + } + + [Fact] + public async Task Phase4_SearchIndexes_ApplyOnSqlServer_WhenLocalDbAvailable() + { + if (!await IsLocalDbAvailableAsync()) + return; + + var dbName = $"Phase4SearchIndexes_{Guid.NewGuid():N}"; + var connectionString = + $@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True"; + + try + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + await using (var createTable = connection.CreateCommand()) + { + createTable.CommandText = + """ + CREATE TABLE workOrders ( + Id int NOT NULL IDENTITY PRIMARY KEY, + SiteCode nvarchar(max) NULL, + InternalWONumber nvarchar(max) NULL, + LifecycleStatus int NULL, + ScheduledDate datetime2 NULL, + istemplate bit NULL + ); + """; + await createTable.ExecuteNonQueryAsync(); + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await ApplyMigrationUpAsync(context, new Phase4_SearchIndexes()); + + await using (var assertCmd = connection.CreateCommand()) + { + assertCmd.CommandText = + """ + SELECT c.name, t.name AS type_name, c.max_length + FROM sys.columns c + INNER JOIN sys.types t ON c.user_type_id = t.user_type_id + WHERE c.object_id = OBJECT_ID(N'workOrders') + AND c.name IN (N'SiteCode', N'InternalWONumber') + ORDER BY c.name; + """; + + await using var reader = await assertCmd.ExecuteReaderAsync(); + var columns = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (await reader.ReadAsync()) + { + columns[reader.GetString(0)] = (reader.GetString(1), reader.GetInt16(2)); + } + + Assert.Equal("nvarchar", columns["InternalWONumber"].Type); + Assert.Equal(22, columns["InternalWONumber"].MaxLength); // nvarchar(11) => 22 bytes + Assert.Equal("nvarchar", columns["SiteCode"].Type); + Assert.Equal(64, columns["SiteCode"].MaxLength); // nvarchar(32) => 64 bytes + } + + await using (var indexCmd = connection.CreateCommand()) + { + indexCmd.CommandText = + """ + SELECT name + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'workOrders') + AND name IN ( + N'IX_workOrders_SiteCode', + N'IX_workOrders_InternalWONumber', + N'IX_workOrders_LifecycleStatus_ScheduledDate' + ); + """; + + await using var reader = await indexCmd.ExecuteReaderAsync(); + var indexes = new List(); + while (await reader.ReadAsync()) + indexes.Add(reader.GetString(0)); + + Assert.Contains("IX_workOrders_SiteCode", indexes); + Assert.Contains("IX_workOrders_InternalWONumber", indexes); + Assert.Contains("IX_workOrders_LifecycleStatus_ScheduledDate", indexes); + } + } + finally + { + await DropDatabaseAsync(dbName); + } + } + + private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration) + { + var builder = new MigrationBuilder(context.Database.ProviderName!); + var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Migration.Up not found."); + up.Invoke(migration, [builder]); + + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(builder.Operations, model: null); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task IsLocalDbAvailableAsync() + { + try + { + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + return true; + } + catch (SqlException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static async Task DropDatabaseAsync(string dbName) + { + try + { + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + IF DB_ID(N'{dbName}') IS NOT NULL + BEGIN + ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{dbName}]; + END + """; + await command.ExecuteNonQueryAsync(); + } + catch + { + // Best-effort cleanup for ephemeral LocalDB databases. + } + } +}