fix(work-orders): bound SiteCode/InternalWONumber so Phase4 indexes apply on SQL Server

This commit is contained in:
Arthur Bassi 2026-07-10 13:42:16 -03:00
parent 31675e49dd
commit bdd566de83
5 changed files with 205 additions and 19 deletions

View file

@ -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<WorkOrder>()
.Property(w => w.SiteCode)
.HasMaxLength(32);
builder.Entity<WorkOrder>()
.Property(w => w.InternalWONumber)
.HasMaxLength(11);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");

View file

@ -1911,7 +1911,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)");
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1989,7 +1990,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)");
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");

View file

@ -10,26 +10,23 @@ namespace Data.SeaHavenIndustries.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// nvarchar(max) cannot be an index key; shrink before indexing.
migrationBuilder.AlterColumn<string>(
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<string>(
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<string>(
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);
}
}
}

View file

@ -1908,7 +1908,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)");
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1986,7 +1987,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)");
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");

View file

@ -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<ApplicationDbContext>()
.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<ApplicationDbContext>()
.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<string, (string Type, short MaxLength)>(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<string>();
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<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(builder.Operations, model: null);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task<bool> 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.
}
}
}