mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13: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)
|
.Property(w => w.RowVersion)
|
||||||
.IsRowVersion();
|
.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>()
|
builder.Entity<WorkOrder>()
|
||||||
.HasIndex(w => w.SiteCode)
|
.HasIndex(w => w.SiteCode)
|
||||||
.HasFilter("[istemplate] = 0");
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
|
||||||
|
|
@ -1911,7 +1911,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("InternalWONumber")
|
b.Property<string>("InternalWONumber")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(11)
|
||||||
|
.HasColumnType("nvarchar(11)");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeleted")
|
b.Property<bool?>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
@ -1989,7 +1990,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("SiteCode")
|
b.Property<string>("SiteCode")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
b.Property<string>("Source")
|
b.Property<string>("Source")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,23 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
// nvarchar(max) cannot be an index key; shrink before indexing.
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "SiteCode",
|
name: "SiteCode",
|
||||||
table: "workOrders",
|
table: "workOrders",
|
||||||
type: "nvarchar(450)",
|
type: "nvarchar(32)",
|
||||||
maxLength: 450,
|
maxLength: 32,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(max)",
|
oldType: "nvarchar(max)");
|
||||||
oldNullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "InternalWONumber",
|
name: "InternalWONumber",
|
||||||
table: "workOrders",
|
table: "workOrders",
|
||||||
type: "nvarchar(450)",
|
type: "nvarchar(11)",
|
||||||
maxLength: 450,
|
maxLength: 11,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(max)",
|
oldType: "nvarchar(max)");
|
||||||
oldNullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_workOrders_SiteCode",
|
name: "IX_workOrders_SiteCode",
|
||||||
|
|
@ -71,9 +68,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(450)",
|
oldType: "nvarchar(32)",
|
||||||
oldMaxLength: 450,
|
oldMaxLength: 32);
|
||||||
oldNullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "InternalWONumber",
|
name: "InternalWONumber",
|
||||||
|
|
@ -81,9 +77,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(450)",
|
oldType: "nvarchar(11)",
|
||||||
oldMaxLength: 450,
|
oldMaxLength: 11);
|
||||||
oldNullable: true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1908,7 +1908,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("InternalWONumber")
|
b.Property<string>("InternalWONumber")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(11)
|
||||||
|
.HasColumnType("nvarchar(11)");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeleted")
|
b.Property<bool?>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
@ -1986,7 +1987,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("SiteCode")
|
b.Property<string>("SiteCode")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
b.Property<string>("Source")
|
b.Property<string>("Source")
|
||||||
.HasColumnType("nvarchar(max)");
|
.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