Codex review findings applied: - Use CTE pattern to pre-filter valid JSON before OPENJSON - Use OPENJSON...WITH for type-safe extraction (avoids double TRY_CONVERT) - Keep script gaps instead of renumbering (prevents migration drift) - Add xUnit Collection for test isolation (prevents parallel execution issues)
48 KiB
Search Criteria SQL Extraction Functions - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
Goal: Create SQL functions to extract SearchCriteria JSON values, eliminating C# TVP code.
Architecture: Inline TVFs for performance-critical extraction, validation stored procedure for strict error handling. C# query builder passes SearchId only.
Tech Stack: SQL Server 2022, OPENJSON, TRY_CONVERT, xUnit, Dapper
Codex Review Findings (Applied)
Critical fixes applied:
- CROSS APPLY OPENJSON before WHERE - Use CTE with pre-filtered search to prevent OPENJSON from running on invalid JSON
- Use OPENJSON...WITH for simple arrays - Avoids double TRY_CONVERT, cleaner syntax
Design decisions:
- Script numbering - Keep new scripts as 045-048, do NOT renumber existing 040-044 (avoids migration drift)
- Test isolation - Use xUnit Collection attribute to disable parallel execution
- Date format - Document that ISO 8601 format without timezone is expected (System.Text.Json default)
Task 1: Create Scalar Extraction Functions
Files:
- Create:
src/JdeScoping.Database/Scripts/045_CreateScalarExtractionFunctions.sql
Step 1: Write the SQL script
-- Migration: 045_CreateScalarExtractionFunctions
-- Scalar functions to extract simple values from Search.Criteria JSON
-- fn_GetSearchMinimumDt: Extract MinimumDt datetime
IF OBJECT_ID('dbo.fn_GetSearchMinimumDt', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchMinimumDt;
GO
CREATE FUNCTION dbo.fn_GetSearchMinimumDt(@SearchId INT)
RETURNS DATETIME2(7)
AS
BEGIN
DECLARE @Result DATETIME2(7);
SELECT @Result = TRY_CONVERT(DATETIME2(7), JSON_VALUE(s.Criteria, '$.MinimumDt'))
FROM dbo.Search s
WHERE s.ID = @SearchId
AND s.Criteria IS NOT NULL
AND ISJSON(s.Criteria) = 1;
RETURN @Result;
END
GO
-- fn_GetSearchMaximumDt: Extract MaximumDt datetime
IF OBJECT_ID('dbo.fn_GetSearchMaximumDt', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchMaximumDt;
GO
CREATE FUNCTION dbo.fn_GetSearchMaximumDt(@SearchId INT)
RETURNS DATETIME2(7)
AS
BEGIN
DECLARE @Result DATETIME2(7);
SELECT @Result = TRY_CONVERT(DATETIME2(7), JSON_VALUE(s.Criteria, '$.MaximumDt'))
FROM dbo.Search s
WHERE s.ID = @SearchId
AND s.Criteria IS NOT NULL
AND ISJSON(s.Criteria) = 1;
RETURN @Result;
END
GO
-- fn_GetSearchExtractMisData: Extract ExtractMisData boolean
IF OBJECT_ID('dbo.fn_GetSearchExtractMisData', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchExtractMisData;
GO
CREATE FUNCTION dbo.fn_GetSearchExtractMisData(@SearchId INT)
RETURNS BIT
AS
BEGIN
DECLARE @Result BIT;
SELECT @Result = TRY_CONVERT(BIT, JSON_VALUE(s.Criteria, '$.ExtractMisData'))
FROM dbo.Search s
WHERE s.ID = @SearchId
AND s.Criteria IS NOT NULL
AND ISJSON(s.Criteria) = 1;
RETURN @Result;
END
GO
Step 2: Verify script syntax
Run: sqlcmd -S localhost,1434 -U sa -P <password> -d JdeScoping -i src/JdeScoping.Database/Scripts/045_CreateScalarExtractionFunctions.sql
Expected: Commands completed successfully
Step 3: Commit
git add src/JdeScoping.Database/Scripts/045_CreateScalarExtractionFunctions.sql
git commit -m "feat(db): add scalar extraction functions for SearchCriteria JSON"
Task 2: Create Simple Table Extraction Functions
Files:
- Create:
src/JdeScoping.Database/Scripts/046_CreateSimpleTableFunctions.sql
Step 1: Write the SQL script
Uses CTE pattern to pre-filter valid JSON before OPENJSON (prevents runtime errors on invalid JSON). Uses OPENJSON...WITH for type conversion (single conversion, cleaner syntax).
-- Migration: 046_CreateSimpleTableFunctions
-- Inline TVFs to extract simple arrays from Search.Criteria JSON
-- Pattern: CTE pre-filters valid JSON, OPENJSON...WITH for type-safe extraction
-- fn_GetSearchWorkOrders: Extract WorkOrderNumbers array
IF OBJECT_ID('dbo.fn_GetSearchWorkOrders', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchWorkOrders;
GO
CREATE FUNCTION dbo.fn_GetSearchWorkOrders(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.WorkOrderNumber
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.WorkOrderNumbers')
WITH (WorkOrderNumber BIGINT '$') j
WHERE j.WorkOrderNumber IS NOT NULL
);
GO
-- fn_GetSearchItemNumbers: Extract ItemNumbers array
IF OBJECT_ID('dbo.fn_GetSearchItemNumbers', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchItemNumbers;
GO
CREATE FUNCTION dbo.fn_GetSearchItemNumbers(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.ItemNumber
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.ItemNumbers')
WITH (ItemNumber VARCHAR(128) '$') j
WHERE j.ItemNumber IS NOT NULL
);
GO
-- fn_GetSearchProfitCenters: Extract ProfitCenters array
IF OBJECT_ID('dbo.fn_GetSearchProfitCenters', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchProfitCenters;
GO
CREATE FUNCTION dbo.fn_GetSearchProfitCenters(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.Code
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.ProfitCenters')
WITH (Code VARCHAR(12) '$') j
WHERE j.Code IS NOT NULL
);
GO
-- fn_GetSearchWorkCenters: Extract WorkCenters array
IF OBJECT_ID('dbo.fn_GetSearchWorkCenters', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchWorkCenters;
GO
CREATE FUNCTION dbo.fn_GetSearchWorkCenters(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.Code
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.WorkCenters')
WITH (Code VARCHAR(12) '$') j
WHERE j.Code IS NOT NULL
);
GO
-- fn_GetSearchOperatorIDs: Extract OperatorIDs array
IF OBJECT_ID('dbo.fn_GetSearchOperatorIDs', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchOperatorIDs;
GO
CREATE FUNCTION dbo.fn_GetSearchOperatorIDs(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.OperatorID
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.OperatorIDs')
WITH (OperatorID VARCHAR(128) '$') j
WHERE j.OperatorID IS NOT NULL
);
GO
Step 2: Verify script syntax
Run: sqlcmd -S localhost,1434 -U sa -P <password> -d JdeScoping -i src/JdeScoping.Database/Scripts/046_CreateSimpleTableFunctions.sql
Expected: Commands completed successfully
Step 3: Commit
git add src/JdeScoping.Database/Scripts/046_CreateSimpleTableFunctions.sql
git commit -m "feat(db): add inline TVFs for simple array extraction from SearchCriteria"
Task 3: Create Complex Table Extraction Functions
Files:
- Create:
src/JdeScoping.Database/Scripts/047_CreateComplexTableFunctions.sql
Step 1: Write the SQL script
Uses same CTE pattern to pre-filter valid JSON before OPENJSON.
-- Migration: 047_CreateComplexTableFunctions
-- Inline TVFs to extract complex object arrays from Search.Criteria JSON
-- Pattern: CTE pre-filters valid JSON, OPENJSON...WITH for type-safe extraction
-- fn_GetSearchComponentLots: Extract ComponentLotNumbers array of objects
IF OBJECT_ID('dbo.fn_GetSearchComponentLots', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchComponentLots;
GO
CREATE FUNCTION dbo.fn_GetSearchComponentLots(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.LotNumber, j.ItemNumber
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.ComponentLotNumbers')
WITH (
LotNumber VARCHAR(30) '$.LotNumber',
ItemNumber VARCHAR(128) '$.ItemNumber'
) j
);
GO
-- fn_GetSearchPartOperations: Extract PartOperations array of objects
IF OBJECT_ID('dbo.fn_GetSearchPartOperations', 'IF') IS NOT NULL
DROP FUNCTION dbo.fn_GetSearchPartOperations;
GO
CREATE FUNCTION dbo.fn_GetSearchPartOperations(@SearchId INT)
RETURNS TABLE
AS
RETURN
(
WITH ValidSearch AS (
SELECT Criteria
FROM dbo.Search
WHERE ID = @SearchId
AND Criteria IS NOT NULL
AND ISJSON(Criteria) = 1
)
SELECT j.ItemNumber, j.OperationNumber, j.MisNumber, j.MisRevision
FROM ValidSearch s
CROSS APPLY OPENJSON(s.Criteria, '$.PartOperations')
WITH (
ItemNumber VARCHAR(128) '$.ItemNumber',
OperationNumber VARCHAR(10) '$.OperationNumber',
MisNumber VARCHAR(10) '$.MisNumber',
MisRevision VARCHAR(10) '$.MisRevision'
) j
);
GO
Step 2: Verify script syntax
Run: sqlcmd -S localhost,1434 -U sa -P <password> -d JdeScoping -i src/JdeScoping.Database/Scripts/047_CreateComplexTableFunctions.sql
Expected: Commands completed successfully
Step 3: Commit
git add src/JdeScoping.Database/Scripts/047_CreateComplexTableFunctions.sql
git commit -m "feat(db): add inline TVFs for complex object extraction from SearchCriteria"
Task 4: Create Validation Stored Procedure
Files:
- Create:
src/JdeScoping.Database/Scripts/048_CreateValidateSearchCriteriaProcedure.sql
Step 1: Write the SQL script
-- Migration: 048_CreateValidateSearchCriteriaProcedure
-- Stored procedure for strict validation with THROW errors
IF OBJECT_ID('dbo.usp_ValidateSearchCriteria', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ValidateSearchCriteria;
GO
CREATE PROCEDURE dbo.usp_ValidateSearchCriteria(@SearchId INT)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Criteria VARCHAR(MAX);
DECLARE @ErrorMsg NVARCHAR(400);
-- Get criteria for the search
SELECT @Criteria = Criteria
FROM dbo.Search
WHERE ID = @SearchId;
-- Validate search exists
IF @@ROWCOUNT = 0
BEGIN
SET @ErrorMsg = CONCAT('Search ID ', @SearchId, ' not found');
THROW 50001, @ErrorMsg, 1;
END
-- Validate criteria not null
IF @Criteria IS NULL
BEGIN
SET @ErrorMsg = CONCAT('Search ID ', @SearchId, ' has no criteria');
THROW 50002, @ErrorMsg, 1;
END
-- Validate criteria not empty
IF @Criteria = ''
BEGIN
SET @ErrorMsg = CONCAT('Search ID ', @SearchId, ' has no criteria');
THROW 50002, @ErrorMsg, 1;
END
-- Validate JSON format
IF ISJSON(@Criteria) = 0
BEGIN
SET @ErrorMsg = CONCAT('Search ID ', @SearchId, ' has invalid JSON');
THROW 50003, @ErrorMsg, 1;
END
-- If we get here, validation passed
RETURN 0;
END
GO
Step 2: Verify script syntax
Run: sqlcmd -S localhost,1434 -U sa -P <password> -d JdeScoping -i src/JdeScoping.Database/Scripts/048_CreateValidateSearchCriteriaProcedure.sql
Expected: Commands completed successfully
Step 3: Commit
git add src/JdeScoping.Database/Scripts/048_CreateValidateSearchCriteriaProcedure.sql
git commit -m "feat(db): add validation stored procedure with THROW errors"
Task 5: Delete Obsolete Table Type Scripts
Files:
- Delete:
src/JdeScoping.Database/Scripts/033_CreateWorkOrderFilterParameterType.sql - Delete:
src/JdeScoping.Database/Scripts/034_CreateItemNumberFilterParameterType.sql - Delete:
src/JdeScoping.Database/Scripts/035_CreateProfitCenterFilterParameterType.sql - Delete:
src/JdeScoping.Database/Scripts/036_CreateWorkCenterFilterParameterType.sql - Delete:
src/JdeScoping.Database/Scripts/037_CreateOperatorFilterParameterType.sql - Delete:
src/JdeScoping.Database/Scripts/038_CreateComponentLotFilterParameterType.sql - Delete:
src/JdeScoping.Database/Scripts/039_CreateItemOperationMisFilterParameterType.sql
Step 1: Delete the files
rm src/JdeScoping.Database/Scripts/033_CreateWorkOrderFilterParameterType.sql
rm src/JdeScoping.Database/Scripts/034_CreateItemNumberFilterParameterType.sql
rm src/JdeScoping.Database/Scripts/035_CreateProfitCenterFilterParameterType.sql
rm src/JdeScoping.Database/Scripts/036_CreateWorkCenterFilterParameterType.sql
rm src/JdeScoping.Database/Scripts/037_CreateOperatorFilterParameterType.sql
rm src/JdeScoping.Database/Scripts/038_CreateComponentLotFilterParameterType.sql
rm src/JdeScoping.Database/Scripts/039_CreateItemOperationMisFilterParameterType.sql
Step 2: Commit
git add -A
git commit -m "chore(db): remove obsolete Table Type scripts (replaced by extraction functions)"
Task 6: Set Up Database Test Infrastructure
Files:
- Create:
tests/JdeScoping.Database.Tests/Infrastructure/DatabaseTestBase.cs - Modify:
tests/JdeScoping.Database.Tests/JdeScoping.Database.Tests.csproj - Delete:
tests/JdeScoping.Database.Tests/Placeholder.cs
Step 1: Update project file to add dependencies
Add references to test infrastructure and Dapper:
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\JdeScoping.Core\JdeScoping.Core.csproj" />
<ProjectReference Include="..\JdeScoping.Api.IntegrationTests\JdeScoping.Api.IntegrationTests.csproj" />
</ItemGroup>
Step 2: Create DatabaseTestBase.cs
Uses xUnit Collection to disable parallel test execution (prevents cross-test contamination).
using System.Text.Json;
using Dapper;
using JdeScoping.Core.Models.Search;
using Microsoft.Data.SqlClient;
namespace JdeScoping.Database.Tests.Infrastructure;
/// <summary>
/// xUnit Collection definition to disable parallel execution for database tests.
/// All test classes using [Collection("DatabaseTests")] run sequentially.
/// </summary>
[CollectionDefinition("DatabaseTests")]
public class DatabaseTestCollection : ICollectionFixture<DatabaseTestFixture>
{
}
/// <summary>
/// Shared fixture for database tests. Ensures database is available.
/// </summary>
public class DatabaseTestFixture : IAsyncLifetime
{
public const string ConnectionString = "Server=localhost,1434;Database=JdeScoping;User Id=sa;Password=YourPassword123!;TrustServerCertificate=True;";
public async Task InitializeAsync()
{
// Verify database is accessible
using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
}
/// <summary>
/// Base class for database function tests.
/// Each test gets its own connection and cleans up created data.
/// </summary>
[Collection("DatabaseTests")]
public abstract class DatabaseTestBase : IAsyncLifetime
{
protected SqlConnection Connection { get; private set; } = null!;
private readonly List<int> _createdSearchIds = [];
public async Task InitializeAsync()
{
Connection = new SqlConnection(DatabaseTestFixture.ConnectionString);
await Connection.OpenAsync();
}
public async Task DisposeAsync()
{
// Clean up test data
foreach (var id in _createdSearchIds)
{
await Connection.ExecuteAsync("DELETE FROM dbo.Search WHERE ID = @Id", new { Id = id });
}
await Connection.DisposeAsync();
}
/// <summary>
/// Insert a test search with the given criteria and return the ID.
/// </summary>
protected async Task<int> InsertTestSearchAsync(SearchCriteria criteria, string userName = "testuser", string name = "Test Search")
{
var criteriaJson = JsonSerializer.Serialize(criteria);
var id = await Connection.QuerySingleAsync<int>(
@"INSERT INTO dbo.Search (UserName, Name, Status, Criteria)
OUTPUT INSERTED.ID
VALUES (@UserName, @Name, 0, @Criteria)",
new { UserName = userName, Name = name, Criteria = criteriaJson });
_createdSearchIds.Add(id);
return id;
}
/// <summary>
/// Insert a test search with raw criteria JSON (for testing invalid JSON scenarios).
/// </summary>
protected async Task<int> InsertTestSearchWithRawCriteriaAsync(string? criteriaJson, string userName = "testuser", string name = "Test Search")
{
var id = await Connection.QuerySingleAsync<int>(
@"INSERT INTO dbo.Search (UserName, Name, Status, Criteria)
OUTPUT INSERTED.ID
VALUES (@UserName, @Name, 0, @Criteria)",
new { UserName = userName, Name = name, Criteria = criteriaJson });
_createdSearchIds.Add(id);
return id;
}
}
Step 3: Delete placeholder file
rm tests/JdeScoping.Database.Tests/Placeholder.cs
Step 4: Commit
git add -A
git commit -m "feat(tests): add DatabaseTestBase infrastructure for SQL function tests"
Task 8: Write Scalar Function Tests
Files:
- Create:
tests/JdeScoping.Database.Tests/Functions/ScalarFunctionTests.cs
Step 1: Write the tests
using Dapper;
using FluentAssertions;
using JdeScoping.Core.Models.Search;
using JdeScoping.Database.Tests.Infrastructure;
namespace JdeScoping.Database.Tests.Functions;
public class ScalarFunctionTests : DatabaseTestBase
{
[Fact]
public async Task fn_GetSearchMinimumDt_ValidSearch_ReturnsDateTime()
{
// Arrange
var expectedDate = new DateTime(2024, 6, 15, 10, 30, 0);
var criteria = new SearchCriteria { MinimumDt = expectedDate };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var result = await Connection.QuerySingleOrDefaultAsync<DateTime?>(
"SELECT dbo.fn_GetSearchMinimumDt(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeCloseTo(expectedDate, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task fn_GetSearchMinimumDt_NullValue_ReturnsNull()
{
// Arrange
var criteria = new SearchCriteria { MinimumDt = null };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var result = await Connection.QuerySingleOrDefaultAsync<DateTime?>(
"SELECT dbo.fn_GetSearchMinimumDt(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeNull();
}
[Fact]
public async Task fn_GetSearchMinimumDt_SearchNotFound_ReturnsNull()
{
// Act
var result = await Connection.QuerySingleOrDefaultAsync<DateTime?>(
"SELECT dbo.fn_GetSearchMinimumDt(@SearchId)",
new { SearchId = 99999 });
// Assert
result.Should().BeNull();
}
[Fact]
public async Task fn_GetSearchMinimumDt_InvalidJson_ReturnsNull()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync("not valid json");
// Act
var result = await Connection.QuerySingleOrDefaultAsync<DateTime?>(
"SELECT dbo.fn_GetSearchMinimumDt(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeNull();
}
[Fact]
public async Task fn_GetSearchMaximumDt_ValidSearch_ReturnsDateTime()
{
// Arrange
var expectedDate = new DateTime(2024, 12, 31, 23, 59, 59);
var criteria = new SearchCriteria { MaximumDt = expectedDate };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var result = await Connection.QuerySingleOrDefaultAsync<DateTime?>(
"SELECT dbo.fn_GetSearchMaximumDt(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeCloseTo(expectedDate, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task fn_GetSearchExtractMisData_True_ReturnsTrue()
{
// Arrange
var criteria = new SearchCriteria { ExtractMisData = true };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var result = await Connection.QuerySingleOrDefaultAsync<bool?>(
"SELECT dbo.fn_GetSearchExtractMisData(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task fn_GetSearchExtractMisData_False_ReturnsFalse()
{
// Arrange
var criteria = new SearchCriteria { ExtractMisData = false };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var result = await Connection.QuerySingleOrDefaultAsync<bool?>(
"SELECT dbo.fn_GetSearchExtractMisData(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task fn_GetSearchExtractMisData_NullCriteria_ReturnsNull()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync(null);
// Act
var result = await Connection.QuerySingleOrDefaultAsync<bool?>(
"SELECT dbo.fn_GetSearchExtractMisData(@SearchId)",
new { SearchId = searchId });
// Assert
result.Should().BeNull();
}
}
Step 2: Run tests to verify they pass
Run: dotnet test tests/JdeScoping.Database.Tests --filter "FullyQualifiedName~ScalarFunctionTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Database.Tests/Functions/ScalarFunctionTests.cs
git commit -m "test(db): add scalar function tests for MinimumDt, MaximumDt, ExtractMisData"
Task 9: Write Simple Table Function Tests
Files:
- Create:
tests/JdeScoping.Database.Tests/Functions/SimpleTableFunctionTests.cs
Step 1: Write the tests
using Dapper;
using FluentAssertions;
using JdeScoping.Core.Models.Search;
using JdeScoping.Database.Tests.Infrastructure;
namespace JdeScoping.Database.Tests.Functions;
public class SimpleTableFunctionTests : DatabaseTestBase
{
// WorkOrders
[Fact]
public async Task fn_GetSearchWorkOrders_ValidSearch_ReturnsWorkOrders()
{
// Arrange
var criteria = new SearchCriteria { WorkOrderNumbers = [12345, 67890, 11111] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<long>(
"SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEquivalentTo([12345L, 67890L, 11111L]);
}
[Fact]
public async Task fn_GetSearchWorkOrders_EmptyArray_ReturnsEmpty()
{
// Arrange
var criteria = new SearchCriteria { WorkOrderNumbers = [] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<long>(
"SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task fn_GetSearchWorkOrders_SearchNotFound_ReturnsEmpty()
{
// Act
var results = await Connection.QueryAsync<long>(
"SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)",
new { SearchId = 99999 });
// Assert
results.Should().BeEmpty();
}
// ItemNumbers
[Fact]
public async Task fn_GetSearchItemNumbers_ValidSearch_ReturnsItemNumbers()
{
// Arrange
var criteria = new SearchCriteria { ItemNumbers = ["ITEM001", "ITEM002", "ITEM003"] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<string>(
"SELECT ItemNumber FROM dbo.fn_GetSearchItemNumbers(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEquivalentTo(["ITEM001", "ITEM002", "ITEM003"]);
}
[Fact]
public async Task fn_GetSearchItemNumbers_SpecialCharacters_ReturnsCorrectly()
{
// Arrange
var criteria = new SearchCriteria { ItemNumbers = ["ITEM-001", "ITEM_002", "ITEM.003"] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<string>(
"SELECT ItemNumber FROM dbo.fn_GetSearchItemNumbers(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEquivalentTo(["ITEM-001", "ITEM_002", "ITEM.003"]);
}
// ProfitCenters
[Fact]
public async Task fn_GetSearchProfitCenters_ValidSearch_ReturnsCodes()
{
// Arrange
var criteria = new SearchCriteria { ProfitCenters = ["PC01", "PC02"] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<string>(
"SELECT Code FROM dbo.fn_GetSearchProfitCenters(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEquivalentTo(["PC01", "PC02"]);
}
// WorkCenters
[Fact]
public async Task fn_GetSearchWorkCenters_ValidSearch_ReturnsCodes()
{
// Arrange
var criteria = new SearchCriteria { WorkCenters = ["WC001", "WC002", "WC003"] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<string>(
"SELECT Code FROM dbo.fn_GetSearchWorkCenters(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEquivalentTo(["WC001", "WC002", "WC003"]);
}
// OperatorIDs
[Fact]
public async Task fn_GetSearchOperatorIDs_ValidSearch_ReturnsOperatorIDs()
{
// Arrange
var criteria = new SearchCriteria { OperatorIDs = ["OP001", "OP002"] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<string>(
"SELECT OperatorID FROM dbo.fn_GetSearchOperatorIDs(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEquivalentTo(["OP001", "OP002"]);
}
// Edge cases
[Fact]
public async Task fn_GetSearchWorkOrders_InvalidJson_ReturnsEmpty()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync("not valid json");
// Act
var results = await Connection.QueryAsync<long>(
"SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task fn_GetSearchWorkOrders_NullCriteria_ReturnsEmpty()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync(null);
// Act
var results = await Connection.QueryAsync<long>(
"SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task fn_GetSearchWorkOrders_LargeArray_ReturnsAll()
{
// Arrange
var workOrders = Enumerable.Range(1, 1000).Select(i => (long)i).ToList();
var criteria = new SearchCriteria { WorkOrderNumbers = workOrders };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<long>(
"SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().HaveCount(1000);
results.Should().BeEquivalentTo(workOrders);
}
}
Step 2: Run tests to verify they pass
Run: dotnet test tests/JdeScoping.Database.Tests --filter "FullyQualifiedName~SimpleTableFunctionTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Database.Tests/Functions/SimpleTableFunctionTests.cs
git commit -m "test(db): add simple table function tests for array extraction"
Task 10: Write Complex Table Function Tests
Files:
- Create:
tests/JdeScoping.Database.Tests/Functions/ComplexTableFunctionTests.cs
Step 1: Write the tests
using Dapper;
using FluentAssertions;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using JdeScoping.Database.Tests.Infrastructure;
namespace JdeScoping.Database.Tests.Functions;
public class ComplexTableFunctionTests : DatabaseTestBase
{
// ComponentLots
[Fact]
public async Task fn_GetSearchComponentLots_ValidSearch_ReturnsLots()
{
// Arrange
var criteria = new SearchCriteria
{
ComponentLotNumbers =
[
new LotViewModel { LotNumber = "LOT001", ItemNumber = "ITEM001" },
new LotViewModel { LotNumber = "LOT002", ItemNumber = "ITEM002" }
]
};
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>(
"SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().HaveCount(2);
results.Should().Contain(x => x.LotNumber == "LOT001" && x.ItemNumber == "ITEM001");
results.Should().Contain(x => x.LotNumber == "LOT002" && x.ItemNumber == "ITEM002");
}
[Fact]
public async Task fn_GetSearchComponentLots_EmptyArray_ReturnsEmpty()
{
// Arrange
var criteria = new SearchCriteria { ComponentLotNumbers = [] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>(
"SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task fn_GetSearchComponentLots_SearchNotFound_ReturnsEmpty()
{
// Act
var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>(
"SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)",
new { SearchId = 99999 });
// Assert
results.Should().BeEmpty();
}
// PartOperations
[Fact]
public async Task fn_GetSearchPartOperations_ValidSearch_ReturnsOperations()
{
// Arrange
var criteria = new SearchCriteria
{
PartOperations =
[
new PartOperationViewModel
{
ItemNumber = "ITEM001",
OperationNumber = "10",
MisNumber = "MIS001",
MisRevision = "A"
},
new PartOperationViewModel
{
ItemNumber = "ITEM002",
OperationNumber = "20",
MisNumber = "MIS002",
MisRevision = "B"
}
]
};
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>(
"SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().HaveCount(2);
results.Should().Contain(x => x.ItemNumber == "ITEM001" && x.OperationNumber == "10" && x.MisNumber == "MIS001" && x.MisRevision == "A");
results.Should().Contain(x => x.ItemNumber == "ITEM002" && x.OperationNumber == "20" && x.MisNumber == "MIS002" && x.MisRevision == "B");
}
[Fact]
public async Task fn_GetSearchPartOperations_NullOptionalFields_ReturnsNulls()
{
// Arrange
var criteria = new SearchCriteria
{
PartOperations =
[
new PartOperationViewModel
{
ItemNumber = "ITEM001",
OperationNumber = "10",
MisNumber = null,
MisRevision = null
}
]
};
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string? MisNumber, string? MisRevision)>(
"SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().HaveCount(1);
var result = results.First();
result.ItemNumber.Should().Be("ITEM001");
result.OperationNumber.Should().Be("10");
result.MisNumber.Should().BeNull();
result.MisRevision.Should().BeNull();
}
[Fact]
public async Task fn_GetSearchPartOperations_InvalidJson_ReturnsEmpty()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync("not valid json");
// Act
var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>(
"SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().BeEmpty();
}
// Unicode test
[Fact]
public async Task fn_GetSearchComponentLots_UnicodeCharacters_ReturnsCorrectly()
{
// Arrange
var criteria = new SearchCriteria
{
ComponentLotNumbers =
[
new LotViewModel { LotNumber = "LOT-日本語", ItemNumber = "アイテム001" }
]
};
var searchId = await InsertTestSearchAsync(criteria);
// Act
var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>(
"SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)",
new { SearchId = searchId });
// Assert
results.Should().HaveCount(1);
results.First().LotNumber.Should().Be("LOT-日本語");
results.First().ItemNumber.Should().Be("アイテム001");
}
}
Step 2: Run tests to verify they pass
Run: dotnet test tests/JdeScoping.Database.Tests --filter "FullyQualifiedName~ComplexTableFunctionTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Database.Tests/Functions/ComplexTableFunctionTests.cs
git commit -m "test(db): add complex table function tests for ComponentLots and PartOperations"
Task 11: Write Validation Procedure Tests
Files:
- Create:
tests/JdeScoping.Database.Tests/Procedures/ValidateSearchCriteriaProcedureTests.cs
Step 1: Write the tests
using Dapper;
using FluentAssertions;
using JdeScoping.Core.Models.Search;
using JdeScoping.Database.Tests.Infrastructure;
using Microsoft.Data.SqlClient;
namespace JdeScoping.Database.Tests.Procedures;
public class ValidateSearchCriteriaProcedureTests : DatabaseTestBase
{
[Fact]
public async Task usp_ValidateSearchCriteria_ValidSearch_Succeeds()
{
// Arrange
var criteria = new SearchCriteria { WorkOrderNumbers = [12345] };
var searchId = await InsertTestSearchAsync(criteria);
// Act
var act = async () => await Connection.ExecuteAsync(
"EXEC dbo.usp_ValidateSearchCriteria @SearchId",
new { SearchId = searchId });
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task usp_ValidateSearchCriteria_SearchNotFound_ThrowsError50001()
{
// Act
var act = async () => await Connection.ExecuteAsync(
"EXEC dbo.usp_ValidateSearchCriteria @SearchId",
new { SearchId = 99999 });
// Assert
var ex = await act.Should().ThrowAsync<SqlException>();
ex.Which.Number.Should().Be(50001);
ex.Which.Message.Should().Contain("Search ID 99999 not found");
}
[Fact]
public async Task usp_ValidateSearchCriteria_NullCriteria_ThrowsError50002()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync(null);
// Act
var act = async () => await Connection.ExecuteAsync(
"EXEC dbo.usp_ValidateSearchCriteria @SearchId",
new { SearchId = searchId });
// Assert
var ex = await act.Should().ThrowAsync<SqlException>();
ex.Which.Number.Should().Be(50002);
ex.Which.Message.Should().Contain("has no criteria");
}
[Fact]
public async Task usp_ValidateSearchCriteria_EmptyCriteria_ThrowsError50002()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync("");
// Act
var act = async () => await Connection.ExecuteAsync(
"EXEC dbo.usp_ValidateSearchCriteria @SearchId",
new { SearchId = searchId });
// Assert
var ex = await act.Should().ThrowAsync<SqlException>();
ex.Which.Number.Should().Be(50002);
ex.Which.Message.Should().Contain("has no criteria");
}
[Fact]
public async Task usp_ValidateSearchCriteria_InvalidJson_ThrowsError50003()
{
// Arrange
var searchId = await InsertTestSearchWithRawCriteriaAsync("not valid json {{{");
// Act
var act = async () => await Connection.ExecuteAsync(
"EXEC dbo.usp_ValidateSearchCriteria @SearchId",
new { SearchId = searchId });
// Assert
var ex = await act.Should().ThrowAsync<SqlException>();
ex.Which.Number.Should().Be(50003);
ex.Which.Message.Should().Contain("has invalid JSON");
}
[Fact]
public async Task usp_ValidateSearchCriteria_EmptyJsonObject_Succeeds()
{
// Arrange - empty JSON object is valid
var searchId = await InsertTestSearchWithRawCriteriaAsync("{}");
// Act
var act = async () => await Connection.ExecuteAsync(
"EXEC dbo.usp_ValidateSearchCriteria @SearchId",
new { SearchId = searchId });
// Assert
await act.Should().NotThrowAsync();
}
}
Step 2: Run tests to verify they pass
Run: dotnet test tests/JdeScoping.Database.Tests --filter "FullyQualifiedName~ValidateSearchCriteriaProcedureTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Database.Tests/Procedures/ValidateSearchCriteriaProcedureTests.cs
git commit -m "test(db): add validation procedure tests for error handling"
Task 12: Run All Database Tests
Step 1: Run all tests
Run: dotnet test tests/JdeScoping.Database.Tests -v normal
Expected: All tests pass
Step 2: If any failures, fix and re-run
Task 13: Update ISearchQueryBuilder Interface
Files:
- Modify:
src/JdeScoping.DataAccess/Interfaces/ISearchQueryBuilder.cs
Step 1: Read current interface
Step 2: Update to use searchId parameter
using JdeScoping.DataAccess.Models;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Builds SQL queries for search operations.
/// </summary>
public interface ISearchQueryBuilder
{
/// <summary>
/// Builds the main search query using extraction functions.
/// </summary>
/// <param name="searchId">The search ID to extract criteria from.</param>
/// <returns>Query result with SQL and parameters.</returns>
SearchQueryResult BuildSearchQuery(int searchId);
/// <summary>
/// Builds the MIS data extraction query.
/// </summary>
/// <param name="searchId">The search ID to extract criteria from.</param>
/// <returns>Query result with SQL and parameters.</returns>
SearchQueryResult BuildMisQuery(int searchId);
/// <summary>
/// Builds the MIS non-match extraction query.
/// </summary>
/// <param name="searchId">The search ID.</param>
/// <returns>Query result with SQL and parameters.</returns>
SearchQueryResult BuildMisNonMatchQuery(int searchId);
}
Step 3: Commit
git add src/JdeScoping.DataAccess/Interfaces/ISearchQueryBuilder.cs
git commit -m "refactor(data-access): update ISearchQueryBuilder to use searchId parameter"
Task 14: Update SqlKataSearchQueryBuilder
Files:
- Modify:
src/JdeScoping.DataAccess/QueryBuilders/SqlKataSearchQueryBuilder.cs
Step 1: Read current implementation
Step 2: Update to use extraction functions
Replace TVP temp table population with function calls. The key change is in filter handler setup:
Instead of:
INSERT INTO #P_WorkOrders SELECT * FROM @p_WorkOrders
Now generates:
INSERT INTO #P_WorkOrders SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)
Step 3: Remove filter handler dependencies
The query builder no longer needs filter handlers that populate from SearchModel.
Step 4: Commit
git add src/JdeScoping.DataAccess/QueryBuilders/SqlKataSearchQueryBuilder.cs
git commit -m "refactor(data-access): update SqlKataSearchQueryBuilder to use extraction functions"
Task 15: Update SearchProcessor
Files:
- Modify:
src/JdeScoping.DataAccess/Services/SearchProcessor.cs
Step 1: Read current implementation
Step 2: Simplify to pass searchId only
Remove code that:
- Deserializes SearchCriteria
- Populates SearchModel filter lists
- Creates TVP parameters
Replace with:
- Pass searchId to query builder
- Query builder generates SQL that calls extraction functions
Step 3: Commit
git add src/JdeScoping.DataAccess/Services/SearchProcessor.cs
git commit -m "refactor(data-access): simplify SearchProcessor to pass searchId only"
Task 16: Simplify SearchModel
Files:
- Modify:
src/JdeScoping.DataAccess/Models/SearchModel.cs
Step 1: Remove filter properties
Remove:
WorkOrderFilterandWorkOrderFilterEnabledItemNumberFilterandItemNumberFilterEnabledProfitCenterFilterandProfitCenterFilterEnabledWorkCenterFilterandWorkCenterFilterEnabledOperatorFilterandOperatorFilterEnabledComponentLotFilterandComponentLotFilterEnabledItemOperationMisFilterandItemOperationMisFilterEnabledMinimumDt,MaximumDt,TimespanFilterEnabledExtractMisData
Keep:
Id,UserName,NameSubmitDt,StartDt,EndDtResults,MisResults,MisNonMatchResults
Step 2: Commit
git add src/JdeScoping.DataAccess/Models/SearchModel.cs
git commit -m "refactor(data-access): simplify SearchModel by removing filter properties"
Task 17: Delete TableValuedParameterExtensions
Files:
- Delete:
src/JdeScoping.DataAccess/Extensions/TableValuedParameterExtensions.cs
Step 1: Delete the file
rm src/JdeScoping.DataAccess/Extensions/TableValuedParameterExtensions.cs
Step 2: Commit
git add -A
git commit -m "chore(data-access): delete obsolete TableValuedParameterExtensions"
Task 18: Delete FilterEntries
Files:
- Delete:
src/JdeScoping.DataAccess/Models/FilterEntries/directory
Step 1: List files to delete
ls src/JdeScoping.DataAccess/Models/FilterEntries/
Step 2: Delete the directory
rm -rf src/JdeScoping.DataAccess/Models/FilterEntries/
Step 3: Commit
git add -A
git commit -m "chore(data-access): delete obsolete FilterEntries models"
Task 19: Update Related Tests
Files:
- Modify/Delete tests that use old SearchModel or TVP code
Step 1: Find affected tests
grep -r "WorkOrderFilter\|TableValuedParameter\|FilterEntry" tests/
Step 2: Update or delete affected tests
TableValuedParameterExtensionsTests.cs- DELETEWorkOrderFilterHandlerTests.cs- DELETE or UPDATESqlKataSearchQueryBuilderTests.cs- UPDATE to use new signature
Step 3: Run all tests to verify
dotnet test
Step 4: Commit
git add -A
git commit -m "test: update tests for new SearchQueryBuilder signature"
Task 20: Run Full Test Suite
Step 1: Build solution
dotnet build
Step 2: Run all tests
dotnet test
Step 3: Fix any failures
Task 21: Manual End-to-End Verification
Step 1: Start the application
Step 2: Create a search with various criteria
Step 3: Submit the search and verify it processes correctly
Step 4: Check the results are correct
Task 22: Update OpenSpec Specifications
Files:
- Modify:
openspec/specs/data-access/spec.md - Modify:
openspec/specs/search-processing/spec.md - Modify:
openspec/specs/database-schema/spec.md
Step 1: Update data-access spec
Remove TVP references, add extraction function requirements.
Step 2: Update search-processing spec
Update query generation requirements.
Step 3: Update database-schema spec
Document the 11 extraction functions and validation procedure.
Step 4: Run validation
openspec validate --specs
Step 5: Commit
git add openspec/
git commit -m "docs(specs): update specifications for extraction functions"
Task 23: Update Architecture Documentation
Files:
- Modify:
DOCUMENTATION/Architecture/(if exists)
Step 1: Update data flow diagrams
Remove TVP step, add SQL extraction step.
Step 2: Commit
git add DOCUMENTATION/
git commit -m "docs: update architecture documentation for extraction functions"
Task 24: Add Database Tests Documentation
Files:
- Create:
DOCUMENTATION/Testing/DatabaseTests.md
Step 1: Document test infrastructure
- DatabaseTestBase usage
- Connection string configuration
- Test categories
Step 2: Commit
git add DOCUMENTATION/
git commit -m "docs: add Database.Tests documentation"
Task 25: Create ExtractionFunctions Reference
Files:
- Create:
DOCUMENTATION/Database/ExtractionFunctions.md
Step 1: Document all functions
- Function signatures
- Parameters
- Return types
- Examples
- Error handling behavior
Step 2: Commit
git add DOCUMENTATION/
git commit -m "docs: add ExtractionFunctions reference documentation"
Task 26: Final Verification and Cleanup
Step 1: Run full test suite one more time
dotnet test
Step 2: Verify all acceptance criteria are met
Step 3: Create summary commit if needed
git log --oneline -20