refactor(data-access): update ISearchQueryBuilder to use SearchId only

- Change interface methods to accept int searchId instead of SearchModel
- Update SqlKataSearchQueryBuilder to generate SQL using extraction functions
- SQL now calls dbo.fn_GetSearchWorkOrders(@SearchId) etc instead of TVPs
- Update SearchProcessor to pass model.Id to query builder
- Update tests for new method signatures
This commit is contained in:
Joseph Doherty
2026-01-06 14:08:47 -05:00
parent 7508001be1
commit 6074424524
4 changed files with 341 additions and 344 deletions
@@ -3,28 +3,29 @@ using JdeScoping.DataAccess.Models;
namespace JdeScoping.DataAccess.Interfaces; namespace JdeScoping.DataAccess.Interfaces;
/// <summary> /// <summary>
/// Interface for building search queries using SqlKata. /// Builds SQL queries for search operations.
/// Uses SQL extraction functions to retrieve criteria from the Search table.
/// </summary> /// </summary>
public interface ISearchQueryBuilder public interface ISearchQueryBuilder
{ {
/// <summary> /// <summary>
/// Builds the main search query for flagging and retrieving work orders. /// Builds the main search query using extraction functions.
/// </summary> /// </summary>
/// <param name="model">The search model containing filter criteria.</param> /// <param name="searchId">The search ID to extract criteria from.</param>
/// <returns>The compiled query result with SQL, parameters, and setup statements.</returns> /// <returns>Query result with SQL and parameters.</returns>
SearchQueryResult BuildSearchQuery(SearchModel model); SearchQueryResult BuildSearchQuery(int searchId);
/// <summary> /// <summary>
/// Builds the MIS data extraction query when ExtractMisData is enabled. /// Builds the MIS data extraction query.
/// </summary> /// </summary>
/// <param name="model">The search model containing filter criteria.</param> /// <param name="searchId">The search ID to extract criteria from.</param>
/// <returns>The compiled query result for MIS extraction.</returns> /// <returns>Query result with SQL and parameters.</returns>
SearchQueryResult BuildMisQuery(SearchModel model); SearchQueryResult BuildMisQuery(int searchId);
/// <summary> /// <summary>
/// Builds the MIS non-match query for work orders without MIS records. /// Builds the MIS non-match extraction query.
/// </summary> /// </summary>
/// <param name="model">The search model containing filter criteria.</param> /// <param name="searchId">The search ID.</param>
/// <returns>The compiled query result for MIS non-match extraction.</returns> /// <returns>Query result with SQL and parameters.</returns>
SearchQueryResult BuildMisNonMatchQuery(SearchModel model); SearchQueryResult BuildMisNonMatchQuery(int searchId);
} }
@@ -1,4 +1,3 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Models; using JdeScoping.DataAccess.Models;
using SqlKata.Compilers; using SqlKata.Compilers;
@@ -7,83 +6,75 @@ namespace JdeScoping.DataAccess.QueryBuilders;
/// <summary> /// <summary>
/// SqlKata-based implementation of ISearchQueryBuilder. /// SqlKata-based implementation of ISearchQueryBuilder.
/// Builds SQL queries using a fluent query builder pattern. /// Generates SQL that uses extraction functions to retrieve criteria from Search.Criteria JSON.
/// </summary> /// </summary>
public sealed class SqlKataSearchQueryBuilder : ISearchQueryBuilder public sealed class SqlKataSearchQueryBuilder : ISearchQueryBuilder
{ {
private readonly SqlServerCompiler _compiler; private readonly SqlServerCompiler _compiler;
private readonly IEnumerable<IFilterHandler> _filterHandlers;
/// <summary> /// <summary>
/// Initializes a new instance of SqlKataSearchQueryBuilder. /// Initializes a new instance of SqlKataSearchQueryBuilder.
/// </summary> /// </summary>
/// <param name="compiler">The SqlKata SQL Server compiler.</param> /// <param name="compiler">The SqlKata SQL Server compiler.</param>
/// <param name="filterHandlers">Collection of filter handlers.</param> public SqlKataSearchQueryBuilder(SqlServerCompiler compiler)
public SqlKataSearchQueryBuilder(
SqlServerCompiler compiler,
IEnumerable<IFilterHandler> filterHandlers)
{ {
_compiler = compiler; _compiler = compiler;
_filterHandlers = filterHandlers.OrderBy(h => h.Priority);
} }
/// <inheritdoc /> /// <inheritdoc />
public SearchQueryResult BuildSearchQuery(SearchModel model) public SearchQueryResult BuildSearchQuery(int searchId)
{ {
var setupStatements = new List<string>(); var setupStatements = new List<string>();
var parameters = new Dictionary<string, object>(); var parameters = new Dictionary<string, object>
{
["SearchId"] = searchId
};
// 1. Create the #Temp_WO temp table // 1. Validate the search criteria
setupStatements.Add("EXEC dbo.usp_ValidateSearchCriteria @SearchId;");
// 2. Create the #Temp_WO temp table
setupStatements.Add(BuildTempWoTableSql()); setupStatements.Add(BuildTempWoTableSql());
// 2. Apply filter handlers in priority order // 3. Create filter temp tables and populate from extraction functions
foreach (var handler in _filterHandlers.Where(h => h.IsEnabled(model))) setupStatements.Add(BuildWorkOrderFilterSetup());
{ setupStatements.Add(BuildItemNumberFilterSetup());
var filterResult = handler.Apply(model, _compiler); setupStatements.Add(BuildWorkCenterFilterSetup());
setupStatements.AddRange(filterResult.SetupSql); setupStatements.Add(BuildOperatorFilterSetup());
foreach (var param in filterResult.Parameters) setupStatements.Add(BuildComponentLotFilterSetup());
{ setupStatements.Add(BuildPartOperationsFilterSetup());
parameters[param.Key] = param.Value;
}
}
// 3. Build step-based flagging query if needed // 4. Build step-based flagging query
if (model.ShouldSearchSteps()) setupStatements.Add(BuildStepFlaggingQuery());
{
setupStatements.Add(BuildStepFlaggingQuery(model));
}
// 4. Build the final result SELECT query // 5. Build the final result SELECT query
var resultSql = BuildResultQuery(); var resultSql = BuildResultQuery();
return new SearchQueryResult(resultSql, parameters, setupStatements); return new SearchQueryResult(resultSql, parameters, setupStatements);
} }
/// <inheritdoc /> /// <inheritdoc />
public SearchQueryResult BuildMisQuery(SearchModel model) public SearchQueryResult BuildMisQuery(int searchId)
{ {
// MIS query is delegated to MisQueryBuilder var parameters = new Dictionary<string, object>
// This is a placeholder - full implementation would generate MIS extraction SQL
var parameters = new Dictionary<string, object>();
if (model.MinimumDt.HasValue)
{ {
parameters["p_MinimumDT"] = model.MinimumDt.Value; ["SearchId"] = searchId
} };
if (model.MaximumDt.HasValue)
{
parameters["p_MaximumDT"] = model.MaximumDt.Value;
}
var sql = BuildMisExtractionQuery(model); var sql = BuildMisExtractionQuery();
return new SearchQueryResult(sql, parameters, []); return new SearchQueryResult(sql, parameters, []);
} }
/// <inheritdoc /> /// <inheritdoc />
public SearchQueryResult BuildMisNonMatchQuery(SearchModel model) public SearchQueryResult BuildMisNonMatchQuery(int searchId)
{ {
var parameters = new Dictionary<string, object>
{
["SearchId"] = searchId
};
var sql = BuildMisNonMatchExtractionQuery(); var sql = BuildMisNonMatchExtractionQuery();
return new SearchQueryResult(sql, new Dictionary<string, object>(), []); return new SearchQueryResult(sql, parameters, []);
} }
private static string BuildTempWoTableSql() private static string BuildTempWoTableSql()
@@ -110,147 +101,204 @@ public sealed class SqlKataSearchQueryBuilder : ISearchQueryBuilder
"""; """;
} }
private string BuildStepFlaggingQuery(SearchModel model) private static string BuildWorkOrderFilterSetup()
{ {
// Build the complex step-based flagging query dynamically return """
var queryParts = new List<string>(); --Setup work order filter from extraction function
IF OBJECT_ID('tempdb.dbo.#P_WorkOrders', 'U') IS NOT NULL
DROP TABLE #P_WorkOrders;
CREATE TABLE #P_WorkOrders (WorkOrderNumber BIGINT NOT NULL PRIMARY KEY);
INSERT INTO #P_WorkOrders (WorkOrderNumber)
SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId);
// First query part - WorkOrderStep based --Add manually specified work order numbers to flagged list
queryParts.Add(BuildStepSubquery(model, includeWorkOrderTime: true)); IF EXISTS (SELECT 1 FROM #P_WorkOrders)
BEGIN
WITH WOP_CTE AS(
SELECT DISTINCT wo.WorkOrderNumber,
wo.LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder AS wo INNER JOIN
#P_WorkOrders AS pwof ON (wo.WorkOrderNumber = pwof.WorkOrderNumber)
)
MERGE #Temp_WO AS TARGET
USING WOP_CTE AS SOURCE
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber)
WHEN MATCHED THEN
UPDATE SET TARGET.ManuallySpecified = 1
WHEN NOT MATCHED BY TARGET THEN
INSERT(WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, ManuallySpecified)
VALUES(SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
// Second query part - WorkOrderTime only (if not filtering by MIS) --Add any work orders split from flagged work orders
if (!model.ItemOperationMisFilterEnabled) WITH SP_WO AS
{ (
queryParts.Add(BuildTimeOnlySubquery(model)); SELECT DISTINCT wo.WorkOrderNumber,
} wo.LotNumber,
wo.BranchCode,
var unionQuery = string.Join("\r\n UNION \r\n", queryParts); wo.ShortItemNumber
FROM dbo.WorkOrder AS wo INNER JOIN
return $""" #Temp_WO AS tw_o ON (wo.ParentWorkOrderNumber = CAST(tw_o.WorkOrderNumber AS VARCHAR(8)) AND wo.BranchCode = tw_o.BranchCode)
--Query data )
WITH LU_WO AS( MERGE #Temp_WO AS TARGET
SELECT DISTINCT USING SP_WO AS SOURCE
step.WorkOrderNumber, ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber AND TARGET.BranchCode = SOURCE.BranchCode)
step.LotNumber, WHEN MATCHED THEN
step.BranchCode, UPDATE SET TARGET.SplitOrder = 1
step.ShortItemNumber WHEN NOT MATCHED BY TARGET THEN
FROM ( INSERT (WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, SplitOrder)
{unionQuery} VALUES (SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
) step END
)
MERGE INTO #Temp_WO AS TARGET
USING LU_WO AS SOURCE
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber AND TARGET.BranchCode = SOURCE.BranchCode)
WHEN MATCHED THEN
UPDATE SET TARGET.Flagged = 1
WHEN NOT MATCHED BY TARGET THEN
INSERT(WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, Flagged)
VALUES(SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
"""; """;
} }
private string BuildStepSubquery(SearchModel model, bool includeWorkOrderTime) private static string BuildItemNumberFilterSetup()
{ {
var joins = new List<string>(); return """
var whereClause = ""; --Setup item number filter from extraction function
IF OBJECT_ID('tempdb.dbo.#P_ItemNumbers', 'U') IS NOT NULL
// Item number filter join DROP TABLE #P_ItemNumbers;
if (model.ItemNumberFilterEnabled) CREATE TABLE #P_ItemNumbers (ItemNumber VARCHAR(128) NOT NULL PRIMARY KEY);
{ INSERT INTO #P_ItemNumbers (ItemNumber)
joins.Add(" #P_ItemNumbers p_in ON (wo.ItemNumber = p_in.ItemNumber) INNER JOIN"); SELECT ItemNumber FROM dbo.fn_GetSearchItemNumbers(@SearchId);
}
// Base WorkOrderStep join
joins.Add(" dbo.WorkOrderStep wos ON (wo.WorkOrderNumber = wos.WorkOrderNumber AND wo.BranchCode = wos.BranchCode) LEFT OUTER JOIN");
joins.Add(" dbo.WorkOrderTime wot ON (wos.WorkOrderNumber = wot.WorkOrderNumber AND wos.StepNumber = wot.StepNumber AND wos.BranchCode = wot.BranchCode)");
// Work center filter join
if (model.ProfitCenterFilterEnabled || model.WorkCenterFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_WorkCenters p_wc ON (wos.WorkCenterCode = p_wc.Code)");
}
// Operator filter join
if (model.OperatorFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_OperatorIDs p_oi ON (wot.AddressNumber = p_oi.AddressNumber)");
}
// MIS filter join (uses CROSS APPLY)
if (model.ItemOperationMisFilterEnabled)
{
joins.Add("""
CROSS APPLY
dbo.MatchMIS(wo.WorkOrderNumber, wo.ItemNumber, wo.BranchCode, wo.RoutingType, wo.IssueDate, wos.WorkCenterCode,
wos.StepNumber, wos.EndDT, wos.FunctionCode, wos.FunctionOperationDescription) AS mm INNER JOIN
#P_PartOperations p_po on (mm.ItemNumber = p_po.ItemNumber AND mm.MisSequenceNumber = p_po.OperationNumber AND mm.MisNumber = p_po.MisNumber AND mm.RevID = p_po.MisRevision)
""");
}
// Timespan WHERE clause
if (model.TimespanFilterEnabled)
{
whereClause = """
WHERE (wos.EndDT <= @p_MaximumDT AND wos.EndDT >= @p_MinimumDT) OR
(wot.GlDate <= @p_MaximumDT AND wot.GlDate >= @p_MinimumDT)
""";
}
return $"""
SELECT DISTINCT
wo.WorkOrderNumber,
COALESCE(wo.LotNumber, CAST(wo.WorkOrderNumber AS VARCHAR(8))) AS LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder wo INNER JOIN
{string.Join("\r\n", joins)}
{whereClause}
"""; """;
} }
private string BuildTimeOnlySubquery(SearchModel model) private static string BuildWorkCenterFilterSetup()
{ {
var joins = new List<string>(); return """
var whereClause = ""; --Setup work center filter from extraction functions (combines profit centers and work centers)
IF OBJECT_ID('tempdb.dbo.#P_WorkCenters', 'U') IS NOT NULL
DROP TABLE #P_WorkCenters;
CREATE TABLE #P_WorkCenters (Code VARCHAR(12) NOT NULL PRIMARY KEY);
// Item number filter join --Insert from profit centers (joined with WorkCenter table)
if (model.ItemNumberFilterEnabled) INSERT INTO #P_WorkCenters (Code)
{ SELECT DISTINCT wc.WorkCenterCode
joins.Add(" #P_ItemNumbers p_in ON (wo.ItemNumber = p_in.ItemNumber) INNER JOIN"); FROM dbo.fn_GetSearchProfitCenters(@SearchId) pc
} INNER JOIN dbo.WorkCenter wc ON pc.Code = wc.ProfitCenterCode
WHERE NOT EXISTS (SELECT 1 FROM #P_WorkCenters WHERE Code = wc.WorkCenterCode);
// Base WorkOrderTime join --Insert from work centers directly
joins.Add(" dbo.WorkOrderTime wot ON (wo.WorkOrderNumber = wot.WorkOrderNumber AND wo.BranchCode = wot.BranchCode)"); INSERT INTO #P_WorkCenters (Code)
SELECT Code
FROM dbo.fn_GetSearchWorkCenters(@SearchId)
WHERE NOT EXISTS (SELECT 1 FROM #P_WorkCenters WHERE Code = Code);
""";
}
// Work center filter join private static string BuildOperatorFilterSetup()
if (model.ProfitCenterFilterEnabled || model.WorkCenterFilterEnabled) {
{ return """
joins.Add(" INNER JOIN \r\n #P_WorkCenters p_wc ON (wot.WorkCenterCode = p_wc.Code)"); --Setup operator filter from extraction function
} IF OBJECT_ID('tempdb.dbo.#P_OperatorIDs', 'U') IS NOT NULL
DROP TABLE #P_OperatorIDs;
CREATE TABLE #P_OperatorIDs (AddressNumber BIGINT NOT NULL PRIMARY KEY);
INSERT INTO #P_OperatorIDs (AddressNumber)
SELECT DISTINCT u.AddressNumber
FROM dbo.fn_GetSearchOperatorIDs(@SearchId) op
INNER JOIN dbo.JdeUser u ON op.OperatorID = u.UserId;
""";
}
// Operator filter join private static string BuildComponentLotFilterSetup()
if (model.OperatorFilterEnabled) {
{ return """
joins.Add(" INNER JOIN \r\n #P_OperatorIDs p_oi ON (wot.AddressNumber = p_oi.AddressNumber)"); --Setup component lot filter from extraction function
} IF OBJECT_ID('tempdb.dbo.#P_ComponentLots', 'U') IS NOT NULL
DROP TABLE #P_ComponentLots;
CREATE TABLE #P_ComponentLots (
LotNumber VARCHAR(30) NOT NULL,
ItemNumber VARCHAR(128) NOT NULL,
PRIMARY KEY (LotNumber, ItemNumber)
);
INSERT INTO #P_ComponentLots (LotNumber, ItemNumber)
SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId);
""";
}
// Timespan WHERE clause (only if both min and max are present) private static string BuildPartOperationsFilterSetup()
if (model.MinimumDt.HasValue && model.MaximumDt.HasValue) {
{ return """
whereClause = """ --Setup part operations filter from extraction function
WHERE (wot.GlDate <= @p_MaximumDT AND wot.GlDate >= @p_MinimumDT) IF OBJECT_ID('tempdb.dbo.#P_PartOperations', 'U') IS NOT NULL
"""; DROP TABLE #P_PartOperations;
} CREATE TABLE #P_PartOperations (
ItemNumber VARCHAR(128) NOT NULL,
OperationNumber VARCHAR(10) NOT NULL,
MisNumber VARCHAR(10) NULL,
MisRevision VARCHAR(10) NULL,
PRIMARY KEY (ItemNumber, OperationNumber)
);
INSERT INTO #P_PartOperations (ItemNumber, OperationNumber, MisNumber, MisRevision)
SELECT ItemNumber, OperationNumber, MisNumber, MisRevision
FROM dbo.fn_GetSearchPartOperations(@SearchId);
""";
}
return $""" private static string BuildStepFlaggingQuery()
SELECT DISTINCT {
wo.WorkOrderNumber, // Build the complex step-based flagging query dynamically using extraction functions
COALESCE(wo.LotNumber, CAST(wo.WorkOrderNumber AS VARCHAR(8))) AS LotNumber, return """
wo.BranchCode, --Query data using extraction functions for date filtering
wo.ShortItemNumber DECLARE @MinDt DATETIME2(7) = dbo.fn_GetSearchMinimumDt(@SearchId);
FROM dbo.WorkOrder wo INNER JOIN DECLARE @MaxDt DATETIME2(7) = dbo.fn_GetSearchMaximumDt(@SearchId);
{string.Join("\r\n", joins)} DECLARE @HasItemFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_ItemNumbers) THEN 1 ELSE 0 END;
{whereClause} DECLARE @HasWorkCenterFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_WorkCenters) THEN 1 ELSE 0 END;
DECLARE @HasOperatorFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_OperatorIDs) THEN 1 ELSE 0 END;
DECLARE @HasPartOpsFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_PartOperations) THEN 1 ELSE 0 END;
DECLARE @HasTimespanFilter BIT = CASE WHEN @MinDt IS NOT NULL OR @MaxDt IS NOT NULL THEN 1 ELSE 0 END;
DECLARE @ShouldSearchSteps BIT = CASE WHEN @HasTimespanFilter = 1 OR @HasWorkCenterFilter = 1 OR @HasOperatorFilter = 1 THEN 1 ELSE 0 END;
IF @ShouldSearchSteps = 1
BEGIN
WITH LU_WO AS(
SELECT DISTINCT
wo.WorkOrderNumber,
COALESCE(wo.LotNumber, CAST(wo.WorkOrderNumber AS VARCHAR(8))) AS LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder wo
LEFT OUTER JOIN #P_ItemNumbers p_in ON (@HasItemFilter = 0 OR wo.ItemNumber = p_in.ItemNumber)
INNER JOIN dbo.WorkOrderStep wos ON (wo.WorkOrderNumber = wos.WorkOrderNumber AND wo.BranchCode = wos.BranchCode)
LEFT OUTER JOIN dbo.WorkOrderTime wot ON (wos.WorkOrderNumber = wot.WorkOrderNumber AND wos.StepNumber = wot.StepNumber AND wos.BranchCode = wot.BranchCode)
LEFT OUTER JOIN #P_WorkCenters p_wc ON (@HasWorkCenterFilter = 0 OR wos.WorkCenterCode = p_wc.Code)
LEFT OUTER JOIN #P_OperatorIDs p_oi ON (@HasOperatorFilter = 0 OR wot.AddressNumber = p_oi.AddressNumber)
WHERE (@HasItemFilter = 0 OR p_in.ItemNumber IS NOT NULL)
AND (@HasWorkCenterFilter = 0 OR p_wc.Code IS NOT NULL)
AND (@HasOperatorFilter = 0 OR p_oi.AddressNumber IS NOT NULL)
AND (@HasTimespanFilter = 0 OR
(wos.EndDT <= @MaxDt AND wos.EndDT >= @MinDt) OR
(wot.GlDate <= @MaxDt AND wot.GlDate >= @MinDt))
UNION
SELECT DISTINCT
wo.WorkOrderNumber,
COALESCE(wo.LotNumber, CAST(wo.WorkOrderNumber AS VARCHAR(8))) AS LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder wo
LEFT OUTER JOIN #P_ItemNumbers p_in ON (@HasItemFilter = 0 OR wo.ItemNumber = p_in.ItemNumber)
INNER JOIN dbo.WorkOrderTime wot ON (wo.WorkOrderNumber = wot.WorkOrderNumber AND wo.BranchCode = wot.BranchCode)
LEFT OUTER JOIN #P_WorkCenters p_wc ON (@HasWorkCenterFilter = 0 OR wot.WorkCenterCode = p_wc.Code)
LEFT OUTER JOIN #P_OperatorIDs p_oi ON (@HasOperatorFilter = 0 OR wot.AddressNumber = p_oi.AddressNumber)
WHERE (@HasItemFilter = 0 OR p_in.ItemNumber IS NOT NULL)
AND (@HasWorkCenterFilter = 0 OR p_wc.Code IS NOT NULL)
AND (@HasOperatorFilter = 0 OR p_oi.AddressNumber IS NOT NULL)
AND @HasPartOpsFilter = 0
AND (@HasTimespanFilter = 0 OR (wot.GlDate <= @MaxDt AND wot.GlDate >= @MinDt))
)
MERGE INTO #Temp_WO AS TARGET
USING LU_WO AS SOURCE
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber AND TARGET.BranchCode = SOURCE.BranchCode)
WHEN MATCHED THEN
UPDATE SET TARGET.Flagged = 1
WHEN NOT MATCHED BY TARGET THEN
INSERT(WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, Flagged)
VALUES(SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
END
"""; """;
} }
@@ -304,7 +352,7 @@ public sealed class SqlKataSearchQueryBuilder : ISearchQueryBuilder
"""; """;
} }
private static string BuildMisExtractionQuery(SearchModel model) private static string BuildMisExtractionQuery()
{ {
return """ return """
--Get MIS search results --Get MIS search results
@@ -57,8 +57,8 @@ public sealed class SearchProcessor
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct); await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
// Build the search query // Build the search query using searchId only
var queryResult = _queryBuilder.BuildSearchQuery(model); var queryResult = _queryBuilder.BuildSearchQuery(model.Id);
if (_options.EnableDebugSql && !string.IsNullOrEmpty(_options.DebugSqlPath)) if (_options.EnableDebugSql && !string.IsNullOrEmpty(_options.DebugSqlPath))
{ {
@@ -111,8 +111,8 @@ public sealed class SearchProcessor
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct); await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
// Build the search query // Build the search query using searchId only
var queryResult = _queryBuilder.BuildSearchQuery(model); var queryResult = _queryBuilder.BuildSearchQuery(model.Id);
if (_options.EnableDebugSql && !string.IsNullOrEmpty(_options.DebugSqlPath)) if (_options.EnableDebugSql && !string.IsNullOrEmpty(_options.DebugSqlPath))
{ {
@@ -183,7 +183,7 @@ public sealed class SearchProcessor
} }
// Execute MIS result query // Execute MIS result query
var misQueryResult = _queryBuilder.BuildMisQuery(model); var misQueryResult = _queryBuilder.BuildMisQuery(model.Id);
var misResults = await connection.QueryAsync<MisSearchResult>( var misResults = await connection.QueryAsync<MisSearchResult>(
misQueryResult.Sql, misQueryResult.Sql,
misQueryResult.Parameters, misQueryResult.Parameters,
@@ -193,7 +193,7 @@ public sealed class SearchProcessor
_logger.LogDebug("Found {MisResultCount} MIS results", model.MisResults.Count); _logger.LogDebug("Found {MisResultCount} MIS results", model.MisResults.Count);
// Execute MIS non-match query // Execute MIS non-match query
var misNonMatchQueryResult = _queryBuilder.BuildMisNonMatchQuery(model); var misNonMatchQueryResult = _queryBuilder.BuildMisNonMatchQuery(model.Id);
var misNonMatchResults = await connection.QueryAsync<MisNonMatchSearchResult>( var misNonMatchResults = await connection.QueryAsync<MisNonMatchSearchResult>(
misNonMatchQueryResult.Sql, misNonMatchQueryResult.Sql,
misNonMatchQueryResult.Parameters, misNonMatchQueryResult.Parameters,
@@ -1,9 +1,5 @@
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using JdeScoping.DataAccess.QueryBuilders; using JdeScoping.DataAccess.QueryBuilders;
using NSubstitute;
using Shouldly; using Shouldly;
using SqlKata.Compilers; using SqlKata.Compilers;
using Xunit; using Xunit;
@@ -18,221 +14,173 @@ public sealed class SqlKataSearchQueryBuilderTests
private readonly SqlServerCompiler _compiler = new(); private readonly SqlServerCompiler _compiler = new();
[Fact] [Fact]
public void BuildSearchQuery_WithEmptyFilters_ProducesMinimalQuery() public void BuildSearchQuery_WithSearchId_ProducesValidQuery()
{ {
// Arrange // Arrange
var handlers = Array.Empty<IFilterHandler>(); var builder = new SqlKataSearchQueryBuilder(_compiler);
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers); var searchId = 123;
var model = new SearchModel();
// Act // Act
var result = builder.BuildSearchQuery(model); var result = builder.BuildSearchQuery(searchId);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.Sql.ShouldNotBeNullOrEmpty(); result.Sql.ShouldNotBeNullOrEmpty();
result.TempTableSetupSql.ShouldNotBeEmpty(); result.TempTableSetupSql.ShouldNotBeEmpty();
result.Parameters.ShouldContainKey("SearchId");
result.Parameters["SearchId"].ShouldBe(searchId);
}
// Should contain temp table creation [Fact]
public void BuildSearchQuery_ContainsTempTableCreation()
{
// Arrange
var builder = new SqlKataSearchQueryBuilder(_compiler);
// Act
var result = builder.BuildSearchQuery(1);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql); var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("#Temp_WO"); setupSql.ShouldContain("#Temp_WO");
setupSql.ShouldContain("CREATE TABLE"); setupSql.ShouldContain("CREATE TABLE");
} }
[Fact] [Fact]
public void BuildSearchQuery_WithEmptyFilters_ResultSqlContainsSelect() public void BuildSearchQuery_ContainsValidationCall()
{ {
// Arrange // Arrange
var handlers = Array.Empty<IFilterHandler>(); var builder = new SqlKataSearchQueryBuilder(_compiler);
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act // Act
var result = builder.BuildSearchQuery(model); var result = builder.BuildSearchQuery(1);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("usp_ValidateSearchCriteria");
setupSql.ShouldContain("@SearchId");
}
[Fact]
public void BuildSearchQuery_UsesExtractionFunctions()
{
// Arrange
var builder = new SqlKataSearchQueryBuilder(_compiler);
// Act
var result = builder.BuildSearchQuery(1);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
// Should use extraction functions instead of TVPs
setupSql.ShouldContain("fn_GetSearchWorkOrders(@SearchId)");
setupSql.ShouldContain("fn_GetSearchItemNumbers(@SearchId)");
setupSql.ShouldContain("fn_GetSearchProfitCenters(@SearchId)");
setupSql.ShouldContain("fn_GetSearchWorkCenters(@SearchId)");
setupSql.ShouldContain("fn_GetSearchOperatorIDs(@SearchId)");
setupSql.ShouldContain("fn_GetSearchComponentLots(@SearchId)");
setupSql.ShouldContain("fn_GetSearchPartOperations(@SearchId)");
setupSql.ShouldContain("fn_GetSearchMinimumDt(@SearchId)");
setupSql.ShouldContain("fn_GetSearchMaximumDt(@SearchId)");
}
[Fact]
public void BuildSearchQuery_CreatesFilterTempTables()
{
// Arrange
var builder = new SqlKataSearchQueryBuilder(_compiler);
// Act
var result = builder.BuildSearchQuery(1);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
// Should create filter temp tables
setupSql.ShouldContain("#P_WorkOrders");
setupSql.ShouldContain("#P_ItemNumbers");
setupSql.ShouldContain("#P_WorkCenters");
setupSql.ShouldContain("#P_OperatorIDs");
setupSql.ShouldContain("#P_ComponentLots");
setupSql.ShouldContain("#P_PartOperations");
}
[Fact]
public void BuildSearchQuery_ResultSqlContainsSelect()
{
// Arrange
var builder = new SqlKataSearchQueryBuilder(_compiler);
// Act
var result = builder.BuildSearchQuery(1);
// Assert // Assert
result.Sql.ShouldContain("SELECT"); result.Sql.ShouldContain("SELECT");
result.Sql.ShouldContain("WorkOrderNumber");
} }
[Fact] [Fact]
public void BuildSearchQuery_WithSingleFilter_ProducesCorrectStructure() public void BuildSearchQuery_ContainsStepFlaggingQuery()
{ {
// Arrange // Arrange
var workOrderHandler = new WorkOrderFilterHandler(); var builder = new SqlKataSearchQueryBuilder(_compiler);
var handlers = new IFilterHandler[] { workOrderHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345 }
]
};
// Act // Act
var result = builder.BuildSearchQuery(model); var result = builder.BuildSearchQuery(1);
// Assert // Assert
result.ShouldNotBeNull();
result.TempTableSetupSql.ShouldNotBeEmpty();
result.Parameters.ShouldContainKey("p_WorkOrderFilter");
var setupSql = string.Join("\n", result.TempTableSetupSql);
// Should have temp table creation and work order merge
setupSql.ShouldContain("#Temp_WO");
setupSql.ShouldContain("MERGE");
}
[Fact]
public void BuildSearchQuery_WithMultipleFilters_CombinesCorrectly()
{
// Arrange
var workOrderHandler = new WorkOrderFilterHandler();
var itemNumberHandler = new ItemNumberFilterHandler();
var handlers = new IFilterHandler[] { workOrderHandler, itemNumberHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345 }
],
ItemNumberFilter =
[
new ItemNumberFilterEntry { ItemNumber = "ABC123" }
]
};
// Act
var result = builder.BuildSearchQuery(model);
// Assert
result.ShouldNotBeNull();
result.Parameters.ShouldContainKey("p_WorkOrderFilter");
result.Parameters.ShouldContainKey("p_ItemNumberFilter");
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("#Temp_WO");
setupSql.ShouldContain("#P_ItemNumbers");
}
[Fact]
public void BuildSearchQuery_HandlersAreAppliedInPriorityOrder()
{
// Arrange
var lowPriorityHandler = Substitute.For<IFilterHandler>();
lowPriorityHandler.Priority.Returns(100);
lowPriorityHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(true);
lowPriorityHandler.Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>())
.Returns(new FilterResult(["-- LOW PRIORITY SQL"], new Dictionary<string, object>()));
var highPriorityHandler = Substitute.For<IFilterHandler>();
highPriorityHandler.Priority.Returns(1);
highPriorityHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(true);
highPriorityHandler.Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>())
.Returns(new FilterResult(["-- HIGH PRIORITY SQL"], new Dictionary<string, object>()));
// Pass handlers in reverse priority order to verify sorting
var handlers = new[] { lowPriorityHandler, highPriorityHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildSearchQuery(model);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
var highIndex = setupSql.IndexOf("-- HIGH PRIORITY SQL", StringComparison.Ordinal);
var lowIndex = setupSql.IndexOf("-- LOW PRIORITY SQL", StringComparison.Ordinal);
highIndex.ShouldBeGreaterThan(-1);
lowIndex.ShouldBeGreaterThan(-1);
highIndex.ShouldBeLessThan(lowIndex);
}
[Fact]
public void BuildSearchQuery_DisabledHandlersAreSkipped()
{
// Arrange
var enabledHandler = Substitute.For<IFilterHandler>();
enabledHandler.Priority.Returns(1);
enabledHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(true);
enabledHandler.Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>())
.Returns(new FilterResult(["-- ENABLED"], new Dictionary<string, object>()));
var disabledHandler = Substitute.For<IFilterHandler>();
disabledHandler.Priority.Returns(2);
disabledHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(false);
var handlers = new[] { enabledHandler, disabledHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildSearchQuery(model);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("-- ENABLED");
// Apply should never be called on disabled handler
disabledHandler.DidNotReceive().Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>());
}
[Fact]
public void BuildSearchQuery_WithTimespanFilter_IncludesStepFlagging()
{
// Arrange
var handlers = Array.Empty<IFilterHandler>();
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel
{
MinimumDt = DateTime.Now.AddDays(-30),
MaximumDt = DateTime.Now
};
// Act
var result = builder.BuildSearchQuery(model);
// Assert
// When ShouldSearchSteps returns true, step flagging query is added
var setupSql = string.Join("\n", result.TempTableSetupSql); var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("LU_WO"); setupSql.ShouldContain("LU_WO");
setupSql.ShouldContain("Flagged"); setupSql.ShouldContain("Flagged");
setupSql.ShouldContain("MERGE");
} }
[Fact] [Fact]
public void BuildMisQuery_ReturnsValidResult() public void BuildMisQuery_ReturnsValidResult()
{ {
// Arrange // Arrange
var handlers = Array.Empty<IFilterHandler>(); var builder = new SqlKataSearchQueryBuilder(_compiler);
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act // Act
var result = builder.BuildMisQuery(model); var result = builder.BuildMisQuery(1);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.Sql.ShouldNotBeNullOrEmpty(); result.Sql.ShouldNotBeNullOrEmpty();
result.Sql.ShouldContain("#TempMisData"); result.Sql.ShouldContain("#TempMisData");
result.Parameters.ShouldContainKey("SearchId");
} }
[Fact] [Fact]
public void BuildMisNonMatchQuery_ReturnsValidResult() public void BuildMisNonMatchQuery_ReturnsValidResult()
{ {
// Arrange // Arrange
var handlers = Array.Empty<IFilterHandler>(); var builder = new SqlKataSearchQueryBuilder(_compiler);
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act // Act
var result = builder.BuildMisNonMatchQuery(model); var result = builder.BuildMisNonMatchQuery(1);
// Assert // Assert
result.ShouldNotBeNull(); result.ShouldNotBeNull();
result.Sql.ShouldNotBeNullOrEmpty(); result.Sql.ShouldNotBeNullOrEmpty();
result.Sql.ShouldContain("WasJobStepAdded"); result.Sql.ShouldContain("WasJobStepAdded");
result.Sql.ShouldContain("MatchedJobStepNumber"); result.Sql.ShouldContain("MatchedJobStepNumber");
result.Parameters.ShouldContainKey("SearchId");
}
[Fact]
public void BuildSearchQuery_DifferentSearchIds_ProduceDifferentParameters()
{
// Arrange
var builder = new SqlKataSearchQueryBuilder(_compiler);
// Act
var result1 = builder.BuildSearchQuery(100);
var result2 = builder.BuildSearchQuery(200);
// Assert
result1.Parameters["SearchId"].ShouldBe(100);
result2.Parameters["SearchId"].ShouldBe(200);
} }
} }