mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
fix(work-orders): bound SiteCode/InternalWONumber so Phase4 indexes apply on SQL Server
This commit is contained in:
parent
31675e49dd
commit
bdd566de83
5 changed files with 205 additions and 19 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
177
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
177
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue