diff --git a/Development Project (1)/README.md b/Development Project (1)/README.md
new file mode 100644
index 0000000..45a941d
--- /dev/null
+++ b/Development Project (1)/README.md
@@ -0,0 +1,152 @@
+# Inventory Management System
+
+A reusable, API-driven inventory management system built on ASP.NET Core 8. Designed to handle multiple clients with different product metadata needs without rebuilding the core logic each time.
+
+## What it does
+
+- Create products with arbitrary key/value metadata and category assignments
+- Search products by name, category, or any metadata attribute
+- Add and remove inventory per product (or in bulk)
+- Query inventory counts by product or filtered subset
+- Undo individual transactions by deleting them
+
+Products are never deleted — once added, they stay in the system. Inventory is tracked as a ledger of transactions, so the on-hand count is always the net SUM.
+
+## Prerequisites
+
+- .NET 8 SDK
+- SQL Server LocalDB (ships with Visual Studio) or any SQL Server instance
+- The `SparcpointInventory` database with the schema applied
+
+## Database setup
+
+Run the SQL scripts in the project against your SQL Server instance before starting the API. The schema creates tables under two schemas: `Instances` (products, categories, attributes) and `Transactions` (inventory ledger).
+
+Default connection string targets `(localdb)\MSSQLLocalDB`. To use a different instance, update `ConnectionStrings:InventoryDatabase` in `Interview.Web/appsettings.json`.
+
+## Running the API
+
+```bash
+cd "Interview.Web"
+dotnet run
+```
+
+Swagger UI is available at `https://localhost:{port}/swagger` in all environments. Use it to explore and test every endpoint with example payloads.
+
+## Project structure
+
+```
+Sparcpoint.Inventory.Abstractions/ Interfaces, models, request/filter types
+Sparcpoint.Inventory.SqlServer/ SQL Server implementations (Dapper)
+Sparcpoint.SqlServer.Abstractions/ ISqlExecutor, SqlServerQueryProvider (existing)
+Interview.Web/ ASP.NET Core API, controllers, filters, Startup
+Sparcpoint.Inventory.Tests/ Unit tests (Moq) + integration tests (LocalDB)
+```
+
+The Abstractions project defines the contracts. The SqlServer project implements them. The web project wires everything together and exposes it over HTTP. Any new host (worker service, CLI tool) just references SqlServer and calls `services.AddSqlInventoryServices(connectionString)`.
+
+## API overview
+
+### Products
+
+| Method | Route | Description |
+|--------|-------|-------------|
+| `POST` | `/api/v1/products` | Create a product with metadata and categories |
+| `GET` | `/api/v1/products/search` | Search by name, categoryIds, attributeKey/Value, page/pageSize |
+
+### Categories
+
+| Method | Route | Description |
+|--------|-------|-------------|
+| `POST` | `/api/v1/categories` | Create a category with optional parent IDs |
+| `GET` | `/api/v1/categories` | List all categories |
+
+### Inventory
+
+| Method | Route | Description |
+|--------|-------|-------------|
+| `POST` | `/api/v1/inventory/{productId}/add` | Add stock — returns transactionId |
+| `POST` | `/api/v1/inventory/{productId}/remove` | Remove stock — returns transactionId |
+| `POST` | `/api/v1/inventory/batch/add` | Add stock for multiple products (atomic) |
+| `POST` | `/api/v1/inventory/batch/remove` | Remove stock for multiple products (atomic) |
+| `DELETE` | `/api/v1/inventory/transactions/{id}` | Delete a transaction (undo its effect) |
+| `GET` | `/api/v1/inventory/{productId}/count` | Net inventory count for a product |
+| `GET` | `/api/v1/inventory/count` | Counts filtered by name, category, or attribute |
+
+Full request/response examples are in the Swagger UI.
+
+## Example: create a product
+
+```json
+POST /api/v1/products
+{
+ "name": "Widget Pro",
+ "description": "Industrial-grade widget",
+ "validSkus": "WGT-PRO-BLK,WGT-PRO-WHT",
+ "attributes": {
+ "Brand": "ACME",
+ "Color": "Black",
+ "SKU": "WGT-PRO-BLK"
+ },
+ "categoryIds": [1]
+}
+```
+
+Returns `201` with the new product ID.
+
+## Example: add inventory
+
+```json
+POST /api/v1/inventory/1/add
+{
+ "quantity": 50,
+ "typeCategory": "PURCHASE"
+}
+```
+
+Returns `201` with the transactionId. To undo: `DELETE /api/v1/inventory/transactions/{transactionId}`.
+
+## Key design decisions
+
+**Dapper over Entity Framework** — the EAV attribute schema and dynamic WHERE building work better with explicit SQL. Dapper sits cleanly on top of the existing `ISqlExecutor` abstraction without replacing it.
+
+**Attribute search uses EXISTS subqueries** — joining on EAV tables causes row multiplication before you can deduplicate. One correlated EXISTS block per attribute avoids that entirely and has a more predictable query plan.
+
+**Inventory as a ledger** — there's no "current stock" column. Adds insert positive quantities, removes insert negative. The count is always `SUM(Quantity) WHERE CompletedTimestamp IS NOT NULL`. Undo = delete the row. Simple, auditable, no compensating transactions needed.
+
+**FK pre-validation** — referenced IDs (product, category) are validated with an EXISTS check inside the same transaction before each INSERT. This converts SQL error 547 (FK violation) from an unhandled 500 into a clean 400 with a readable message. The global `ApiExceptionFilter` also catches any 547 that slips through as a TOCTOU fallback.
+
+**Single extension method for DI** — `services.AddSqlInventoryServices(connectionString)` wires all repositories. Adding a new one means touching one file, not every host project.
+
+## Running the tests
+
+### Unit tests
+
+```bash
+dotnet test --filter "Category!=Integration"
+```
+
+These use Moq to mock `ISqlExecutor` — no database needed, runs in CI without any setup.
+
+### Integration tests
+
+Require the `SparcpointInventory` database to be up with the schema applied.
+
+```bash
+# optional: point at a different SQL Server
+$env:INVENTORY_TEST_DB = "Server=myserver;Database=SparcpointInventory;Trusted_Connection=True;"
+
+dotnet test --filter "Category=Integration"
+```
+
+Integration tests use `TransactionScope` without `Complete()` — everything auto-rolls back after each test, so no cleanup scripts are needed and the database stays clean between runs.
+
+## Error handling
+
+| HTTP | Cause |
+|------|-------|
+| `400` | Invalid input (missing required fields, bad values, referenced ID doesn't exist) |
+| `404` | Transaction not found on delete |
+| `500` | Unexpected — shouldn't happen in normal usage |
+
+All error responses follow `{ "error": "message" }`.
diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln
index 637bfd9..1c6fa33 100644
--- a/Development Project/Development Project.sln
+++ b/Development Project/Development Project.sln
@@ -11,6 +11,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Core", "Sparcpoi
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.SqlServer.Abstractions", "Sparcpoint.SqlServer.Abstractions\Sparcpoint.SqlServer.Abstractions.csproj", "{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.Abstractions", "Sparcpoint.Inventory.Abstractions\Sparcpoint.Inventory.Abstractions.csproj", "{D1E2F3A4-B5C6-7890-ABCD-EF1234567891}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.SqlServer", "Sparcpoint.Inventory.SqlServer\Sparcpoint.Inventory.SqlServer.csproj", "{E2F3A4B5-C6D7-8901-BCDE-F12345678902}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Inventory.Tests", "Sparcpoint.Inventory.Tests\Sparcpoint.Inventory.Tests.csproj", "{F3A4B5C6-D7E8-9012-CDEF-123456789013}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -55,6 +61,30 @@ Global
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.Build.0 = Release|Any CPU
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.ActiveCfg = Release|Any CPU
{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.Build.0 = Release|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Debug|x86.Build.0 = Debug|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|x86.ActiveCfg = Release|Any CPU
+ {D1E2F3A4-B5C6-7890-ABCD-EF1234567891}.Release|x86.Build.0 = Release|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Debug|x86.Build.0 = Debug|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|x86.ActiveCfg = Release|Any CPU
+ {E2F3A4B5-C6D7-8901-BCDE-F12345678902}.Release|x86.Build.0 = Release|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Debug|x86.Build.0 = Debug|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|x86.ActiveCfg = Release|Any CPU
+ {F3A4B5C6-D7E8-9012-CDEF-123456789013}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Development Project/Interview.Web/Controllers/CategoryController.cs b/Development Project/Interview.Web/Controllers/CategoryController.cs
new file mode 100644
index 0000000..0b83869
--- /dev/null
+++ b/Development Project/Interview.Web/Controllers/CategoryController.cs
@@ -0,0 +1,70 @@
+using Microsoft.AspNetCore.Mvc;
+using Sparcpoint.Inventory.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Interview.Web.Controllers
+{
+ ///
+ /// Manages product categories including hierarchical parent-child relationships.
+ ///
+ [ApiController]
+ [Route("api/v1/categories")]
+ public class CategoryController : ControllerBase
+ {
+ private readonly ICategoryRepository _Categories;
+
+ public CategoryController(ICategoryRepository categories)
+ {
+ _Categories = categories ?? throw new ArgumentNullException(nameof(categories));
+ }
+
+ ///
+ /// Creates a new category. Optionally nest it under existing parent categories
+ /// by supplying ParentCategoryIds. Hierarchy is stored as an adjacency list.
+ /// Returns 400 if any ParentCategoryId does not reference an existing category.
+ ///
+ ///
+ /// Example (root category):
+ ///
+ /// POST /api/v1/categories
+ /// {
+ /// "name": "Electronics",
+ /// "description": "Electronic devices and accessories",
+ /// "parentCategoryIds": [],
+ /// "attributes": {}
+ /// }
+ ///
+ /// To nest under an existing category supply its ID in parentCategoryIds:
+ ///
+ /// {
+ /// "name": "Smartphones",
+ /// "description": "Mobile phones and accessories",
+ /// "parentCategoryIds": [1],
+ /// "attributes": {}
+ /// }
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(int), 201)]
+ [ProducesResponseType(400)]
+ public async Task AddCategory([FromBody] CreateCategoryRequest request)
+ {
+ var categoryId = await _Categories.AddAsync(request);
+ return StatusCode(201, categoryId);
+ }
+
+ ///
+ /// Returns all categories. Hierarchy is represented via ParentCategoryIds on each entry;
+ /// clients reconstruct the tree from this flat list.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ public async Task GetAll()
+ {
+ var categories = await _Categories.GetAllAsync();
+ return Ok(categories);
+ }
+ }
+}
diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs
new file mode 100644
index 0000000..0c09983
--- /dev/null
+++ b/Development Project/Interview.Web/Controllers/InventoryController.cs
@@ -0,0 +1,178 @@
+using Microsoft.AspNetCore.Mvc;
+using Sparcpoint.Inventory.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Interview.Web.Controllers
+{
+ ///
+ /// Manages inventory transactions: adding stock, removing stock,
+ /// undoing individual transactions, and querying counts.
+ ///
+ [ApiController]
+ [Route("api/v1/inventory")]
+ public class InventoryController : ControllerBase
+ {
+ private readonly IInventoryRepository _Inventory;
+
+ public InventoryController(IInventoryRepository inventory)
+ {
+ _Inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
+ }
+
+ ///
+ /// Adds stock for a single product. Returns the new TransactionId.
+ ///
+ ///
+ /// Example:
+ ///
+ /// POST /api/v1/inventory/1/add
+ /// {
+ /// "quantity": 50,
+ /// "typeCategory": "PURCHASE"
+ /// }
+ ///
+ /// typeCategory is optional. Common values: PURCHASE, RETURN, ADJUSTMENT
+ ///
+ ///
+ [HttpPost("{productId:int}/add")]
+ [ProducesResponseType(typeof(int), 201)]
+ [ProducesResponseType(400)]
+ public async Task AddInventory(
+ [FromRoute] int productId,
+ [FromBody] InventoryAdjustRequest request)
+ {
+ var transactionId = await _Inventory.AddAsync(productId, request.Quantity, request.TypeCategory);
+ return StatusCode(201, transactionId);
+ }
+
+ ///
+ /// Adds stock for multiple products in a single atomic operation.
+ /// All items succeed or all roll back.
+ ///
+ ///
+ /// Example:
+ ///
+ /// POST /api/v1/inventory/batch/add
+ /// [
+ /// { "productInstanceId": 1, "quantity": 100, "typeCategory": "PURCHASE" },
+ /// { "productInstanceId": 2, "quantity": 75, "typeCategory": "PURCHASE" }
+ /// ]
+ ///
+ ///
+ [HttpPost("batch/add")]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(400)]
+ public async Task AddInventoryBatch([FromBody] IEnumerable items)
+ {
+ await _Inventory.AddBatchAsync(items);
+ return NoContent();
+ }
+
+ ///
+ /// Removes stock for a single product. Returns the new TransactionId.
+ ///
+ ///
+ /// Example:
+ ///
+ /// POST /api/v1/inventory/1/remove
+ /// {
+ /// "quantity": 5,
+ /// "typeCategory": "SALE"
+ /// }
+ ///
+ /// typeCategory is optional. Common values: SALE, DAMAGE, ADJUSTMENT
+ ///
+ ///
+ [HttpPost("{productId:int}/remove")]
+ [ProducesResponseType(typeof(int), 201)]
+ [ProducesResponseType(400)]
+ public async Task RemoveInventory(
+ [FromRoute] int productId,
+ [FromBody] InventoryAdjustRequest request)
+ {
+ var transactionId = await _Inventory.RemoveAsync(productId, request.Quantity, request.TypeCategory);
+ return StatusCode(201, transactionId);
+ }
+
+ ///
+ /// Removes stock for multiple products in a single atomic operation.
+ ///
+ ///
+ /// Example:
+ ///
+ /// POST /api/v1/inventory/batch/remove
+ /// [
+ /// { "productInstanceId": 1, "quantity": 10, "typeCategory": "SALE" },
+ /// { "productInstanceId": 2, "quantity": 5, "typeCategory": "DAMAGE" }
+ /// ]
+ ///
+ ///
+ [HttpPost("batch/remove")]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(400)]
+ public async Task RemoveInventoryBatch([FromBody] IEnumerable items)
+ {
+ await _Inventory.RemoveBatchAsync(items);
+ return NoContent();
+ }
+
+ ///
+ /// Deletes a specific transaction, effectively undoing its inventory effect.
+ /// EVAL: deleting the row is the undo mechanism - cleaner than a compensating transaction
+ /// since it leaves no audit noise for a user correction. Returns 404 if the transaction
+ /// doesn't exist, caught by ApiExceptionFilter.
+ ///
+ [HttpDelete("transactions/{transactionId:int}")]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(404)]
+ public async Task DeleteTransaction([FromRoute] int transactionId)
+ {
+ await _Inventory.DeleteTransactionAsync(transactionId);
+ return NoContent();
+ }
+
+ ///
+ /// Returns the current net inventory count (sum of all completed transactions) for a product.
+ ///
+ [HttpGet("{productId:int}/count")]
+ [ProducesResponseType(typeof(decimal), 200)]
+ public async Task GetCount([FromRoute] int productId)
+ {
+ var count = await _Inventory.GetCountAsync(productId);
+ return Ok(count);
+ }
+
+ ///
+ /// Returns inventory counts for all products matching the given filter.
+ /// Useful for querying stock levels across a category or metadata subset.
+ ///
+ [HttpGet("count")]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ public async Task GetCountByFilter(
+ [FromQuery] string name = null,
+ [FromQuery] int[] categoryIds = null,
+ [FromQuery] string attributeKey = null,
+ [FromQuery] string attributeValue = null)
+ {
+ var filter = new ProductSearchFilter
+ {
+ Name = name,
+ CategoryIds = categoryIds?.Length > 0 ? categoryIds : null
+ };
+
+ if (!string.IsNullOrWhiteSpace(attributeKey) && !string.IsNullOrWhiteSpace(attributeValue))
+ {
+ filter.Attributes = new Dictionary
+ {
+ [attributeKey] = attributeValue
+ };
+ }
+
+ var counts = await _Inventory.GetCountByFilterAsync(filter);
+ return Ok(counts);
+ }
+ }
+
+}
diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs
index 267f4ec..25167dd 100644
--- a/Development Project/Interview.Web/Controllers/ProductController.cs
+++ b/Development Project/Interview.Web/Controllers/ProductController.cs
@@ -1,19 +1,101 @@
-using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc;
+using Sparcpoint.Inventory.Abstractions;
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Threading.Tasks;
namespace Interview.Web.Controllers
{
+ ///
+ /// Manages product catalog operations: creating and searching products.
+ ///
+ // EVAL: [ApiController] gives us automatic model validation (400 on bad requests) and drops the
+ // need for explicit [FromBody] on POST params. ControllerBase because there's no Razor views here.
+ [ApiController]
[Route("api/v1/products")]
- public class ProductController : Controller
+ public class ProductController : ControllerBase
{
- // NOTE: Sample Action
- [HttpGet]
- public Task GetAllProducts()
+ private readonly IProductRepository _Products;
+
+ public ProductController(IProductRepository products)
{
- return Task.FromResult((IActionResult)Ok(new object[] { }));
+ _Products = products ?? throw new ArgumentNullException(nameof(products));
+ }
+
+ ///
+ /// Adds a new product with its metadata and category assignments.
+ /// Products cannot be deleted once added (by design per requirements).
+ ///
+ ///
+ /// Example:
+ ///
+ /// POST /api/v1/products
+ /// {
+ /// "name": "iPhone 15 Pro",
+ /// "description": "Apple flagship smartphone",
+ /// "productImageUris": "https://example.com/iphone15.jpg",
+ /// "validSkus": "IPH15PRO-128,IPH15PRO-256",
+ /// "attributes": {
+ /// "Brand": "Apple",
+ /// "Color": "Black",
+ /// "SKU": "IPH15PRO-128"
+ /// },
+ /// "categoryIds": [1]
+ /// }
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(int), 201)]
+ [ProducesResponseType(400)]
+ public async Task AddProduct([FromBody] CreateProductRequest request)
+ {
+ var productId = await _Products.AddAsync(request);
+ // EVAL: returning 201 with the new ID - a full REST impl would return a Location header
+ // pointing to GET /products/{id} but that's out of scope here
+ return StatusCode(201, productId);
+ }
+
+ ///
+ /// Searches products by any combination of name, metadata attributes, and categories.
+ /// All supplied fields are combined with AND semantics.
+ ///
+ /// Optional partial name match (case-insensitive LIKE).
+ /// Optional category IDs - product must belong to at least one.
+ /// Optional metadata key to filter by.
+ /// Optional metadata value to filter by (paired with attributeKey).
+ /// Optional 1-based page number. Requires pageSize to activate pagination.
+ /// Optional page size (max 200). Requires page to activate pagination.
+ [HttpGet("search")]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ [ProducesResponseType(400)]
+ public async Task SearchProducts(
+ [FromQuery] string name = null,
+ [FromQuery] int[] categoryIds = null,
+ [FromQuery] string attributeKey = null,
+ [FromQuery] string attributeValue = null,
+ [FromQuery] int? page = null,
+ [FromQuery] int? pageSize = null)
+ {
+ var filter = new ProductSearchFilter
+ {
+ Name = name,
+ CategoryIds = categoryIds?.Length > 0 ? categoryIds : null,
+ Page = page,
+ PageSize = pageSize
+ };
+
+ // EVAL: single key/value attribute filter via query string for simplicity - a proper
+ // production API would take a POST body for richer multi-attribute searches
+ if (!string.IsNullOrWhiteSpace(attributeKey) && !string.IsNullOrWhiteSpace(attributeValue))
+ {
+ filter.Attributes = new Dictionary
+ {
+ [attributeKey] = attributeValue
+ };
+ }
+
+ var results = await _Products.SearchAsync(filter);
+ return Ok(results);
}
}
}
diff --git a/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs b/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs
new file mode 100644
index 0000000..7f9b9b8
--- /dev/null
+++ b/Development Project/Interview.Web/Filters/ApiExceptionFilter.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Data.SqlClient;
+using System;
+
+namespace Interview.Web.Filters
+{
+ ///
+ /// EVAL: maps exceptions to HTTP codes in one place so controllers don't need try/catch blocks
+ /// everywhere. ArgumentException (including Null and OutOfRange subclasses) becomes 400,
+ /// InvalidOperationException becomes 404, and SQL FK violations (error 547) also get caught
+ /// as 400 as a TOCTOU fallback - repos pre-validate FKs but a concurrent delete between
+ /// check and INSERT can still produce a 547. To add a new mapping just extend the switch here.
+ ///
+ public class ApiExceptionFilter : IActionFilter
+ {
+ // SQL Server error number for foreign key constraint violation.
+ private const int SqlForeignKeyViolation = 547;
+
+ public void OnActionExecuting(ActionExecutingContext context) { }
+
+ public void OnActionExecuted(ActionExecutedContext context)
+ {
+ if (context.Exception == null)
+ return;
+
+ switch (context.Exception)
+ {
+ case ArgumentException ex:
+ context.Result = new BadRequestObjectResult(new { error = ex.Message });
+ context.ExceptionHandled = true;
+ break;
+
+ case InvalidOperationException ex:
+ context.Result = new NotFoundObjectResult(new { error = ex.Message });
+ context.ExceptionHandled = true;
+ break;
+
+ case SqlException ex when ex.Number == SqlForeignKeyViolation:
+ context.Result = new BadRequestObjectResult(
+ new { error = "A referenced entity does not exist or has been removed." });
+ context.ExceptionHandled = true;
+ break;
+ }
+ }
+ }
+}
diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj
index d1e4d6e..0e1b788 100644
--- a/Development Project/Interview.Web/Interview.Web.csproj
+++ b/Development Project/Interview.Web/Interview.Web.csproj
@@ -2,6 +2,17 @@
net8.0
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
diff --git a/Development Project/Interview.Web/Properties/launchSettings.json b/Development Project/Interview.Web/Properties/launchSettings.json
index 4dcb6fa..1d45878 100644
--- a/Development Project/Interview.Web/Properties/launchSettings.json
+++ b/Development Project/Interview.Web/Properties/launchSettings.json
@@ -11,7 +11,7 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
- "launchUrl": "api/v1/products",
+ "launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs
index 56452f2..e852e0a 100644
--- a/Development Project/Interview.Web/Startup.cs
+++ b/Development Project/Interview.Web/Startup.cs
@@ -1,13 +1,11 @@
+using Interview.Web.Filters;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Sparcpoint.Inventory.SqlServer;
using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
namespace Interview.Web
{
@@ -20,13 +18,36 @@ public Startup(IConfiguration configuration)
public IConfiguration Configuration { get; }
- // This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
- services.AddControllers();
+ services.AddControllers(options =>
+ {
+ // EVAL: ApiExceptionFilter registered globally - maps ArgumentException → 400
+ // and InvalidOperationException → 404 without per-controller try/catch.
+ options.Filters.Add();
+ });
+
+ // EVAL: AddSwaggerGen is the correct registration for controller-based APIs with Swashbuckle.
+ // AddEndpointsApiExplorer() is only for Minimal APIs and is intentionally omitted here.
+ services.AddSwaggerGen(c =>
+ {
+ c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
+ {
+ Title = "Inventory Management API",
+ Version = "v1"
+ });
+ var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile);
+ c.IncludeXmlComments(xmlPath);
+ });
+
+ // EVAL: All inventory repositories are wired via a single extension method.
+ // Adding a new repository only requires a change in ServiceCollectionExtensions,
+ // not in every host project's Startup.
+ var connectionString = Configuration.GetConnectionString("InventoryDatabase");
+ services.AddSqlInventoryServices(connectionString);
}
- // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
@@ -36,15 +57,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
else
{
app.UseExceptionHandler("/Error");
- // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
- app.UseHttpsRedirection();
- app.UseStaticFiles();
+ // EVAL: Swagger is available in all environments for evaluation purposes.
+ // In a production system, restrict this behind env.IsDevelopment() or a feature flag.
+ app.UseSwagger();
+ app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory API v1"));
+ app.UseHttpsRedirection();
app.UseRouting();
-
app.UseAuthorization();
app.UseEndpoints(endpoints =>
diff --git a/Development Project/Interview.Web/appsettings.json b/Development Project/Interview.Web/appsettings.json
index d9d9a9b..3863ff5 100644
--- a/Development Project/Interview.Web/appsettings.json
+++ b/Development Project/Interview.Web/appsettings.json
@@ -6,5 +6,8 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "InventoryDatabase": "Server=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;"
+ }
}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs
new file mode 100644
index 0000000..9e9a0c6
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Filters/ProductSearchFilter.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// All fields are optional. Only non-null/non-empty fields are applied as filters.
+ /// Multiple attribute entries are combined with AND semantics.
+ ///
+ public class ProductSearchFilter
+ {
+ public string Name { get; set; }
+ public IDictionary Attributes { get; set; }
+ public int[] CategoryIds { get; set; }
+
+ ///
+ /// 1-based page number. Null disables pagination. Must be >= 1 when supplied.
+ /// Both Page and PageSize must be non-null for pagination to activate.
+ ///
+ public int? Page { get; set; }
+
+ ///
+ /// Number of rows per page. Null disables pagination. Clamped to a maximum of 200.
+ /// Both Page and PageSize must be non-null for pagination to activate.
+ ///
+ public int? PageSize { get; set; }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs
new file mode 100644
index 0000000..bee05b1
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/ICategoryRepository.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ public interface ICategoryRepository
+ {
+ ///
+ /// Creates a category, optionally linking it to parent categories.
+ /// Returns the new category's InstanceId.
+ ///
+ Task AddAsync(CreateCategoryRequest request);
+
+ ///
+ /// Returns all categories in the system.
+ ///
+ Task> GetAllAsync();
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs
new file mode 100644
index 0000000..e473360
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IInventoryRepository.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ public interface IInventoryRepository
+ {
+ ///
+ /// Records stock being added for a product. Quantity must be positive.
+ /// Returns the new TransactionId.
+ ///
+ Task AddAsync(int productInstanceId, decimal quantity, string typeCategory = null);
+
+ ///
+ /// Bulk add - all items processed within a single transaction.
+ ///
+ Task AddBatchAsync(IEnumerable items);
+
+ ///
+ /// Records stock being removed. Quantity must be positive (stored as negative internally).
+ /// Returns the new TransactionId.
+ ///
+ Task RemoveAsync(int productInstanceId, decimal quantity, string typeCategory = null);
+
+ ///
+ /// Bulk remove - all items processed within a single transaction.
+ ///
+ Task RemoveBatchAsync(IEnumerable items);
+
+ ///
+ /// EVAL: "Undo" mechanism - deleting the transaction row reverses its effect
+ /// on inventory count without requiring a compensating transaction.
+ ///
+ Task DeleteTransactionAsync(int transactionId);
+
+ ///
+ /// Returns current net quantity for a single product.
+ ///
+ Task GetCountAsync(int productInstanceId);
+
+ ///
+ /// Returns net quantities for all products matching the given filter.
+ ///
+ Task> GetCountByFilterAsync(ProductSearchFilter filter);
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs
new file mode 100644
index 0000000..a585f30
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Interfaces/IProductRepository.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// EVAL: Repository pattern - isolates data access behind a clean interface,
+ /// allowing SQL Server to be swapped for another store without touching callers.
+ ///
+ public interface IProductRepository
+ {
+ ///
+ /// Adds a new product with its metadata and category assignments.
+ /// Returns the new product's InstanceId.
+ ///
+ Task AddAsync(CreateProductRequest request);
+
+ ///
+ /// Searches products by any combination of name, attributes, and categories.
+ /// All supplied filter fields are applied with AND semantics.
+ ///
+ Task> SearchAsync(ProductSearchFilter filter);
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs
new file mode 100644
index 0000000..bd5241d
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Category.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// Represents a product category. Categories support hierarchy via
+ /// Instances.CategoryCategories (adjacency-list pattern).
+ ///
+ public class Category
+ {
+ public int InstanceId { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public DateTime CreatedTimestamp { get; set; }
+ public List ParentCategoryIds { get; set; } = new List();
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs
new file mode 100644
index 0000000..4ed4e38
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/InventoryTransaction.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// Represents a single inventory movement.
+ /// Positive Quantity = stock added; Negative Quantity = stock removed.
+ /// Deleting a transaction row is the "undo" mechanism (requirement 6).
+ ///
+ public class InventoryTransaction
+ {
+ public int TransactionId { get; set; }
+ public int ProductInstanceId { get; set; }
+ public decimal Quantity { get; set; }
+ public DateTime StartedTimestamp { get; set; }
+ public DateTime? CompletedTimestamp { get; set; }
+ public string TypeCategory { get; set; }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs
new file mode 100644
index 0000000..77893f1
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/Product.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// Represents a product instance in the inventory system.
+ ///
+ public class Product
+ {
+ public int InstanceId { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+
+ // EVAL: ProductImageUris and ValidSkus stored as raw strings (JSON/CSV).
+ // A future improvement would normalize these into child tables for indexability.
+ public string ProductImageUris { get; set; }
+ public string ValidSkus { get; set; }
+ public DateTime CreatedTimestamp { get; set; }
+
+ // EVAL: Arbitrary metadata - loaded separately after the main product query
+ // to avoid a Cartesian join explosion on multi-attribute products.
+ public List Attributes { get; set; } = new List();
+ public List CategoryIds { get; set; } = new List();
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs
new file mode 100644
index 0000000..bfaa0d7
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductAttribute.cs
@@ -0,0 +1,12 @@
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// A single key/value metadata entry attached to a product.
+ /// Maps to Instances.ProductAttributes table.
+ ///
+ public class ProductAttribute
+ {
+ public string Key { get; set; }
+ public string Value { get; set; }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs
new file mode 100644
index 0000000..7db1026
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Models/ProductInventoryCount.cs
@@ -0,0 +1,12 @@
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// Aggregated inventory count for a single product.
+ ///
+ public class ProductInventoryCount
+ {
+ public int ProductInstanceId { get; set; }
+ public string ProductName { get; set; }
+ public decimal TotalQuantity { get; set; }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs
new file mode 100644
index 0000000..59c6016
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateCategoryRequest.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ public class CreateCategoryRequest
+ {
+ [Required]
+ [MaxLength(64)]
+ public string Name { get; set; }
+
+ [Required]
+ [MaxLength(256)]
+ public string Description { get; set; }
+
+ ///
+ /// Parent category IDs supports hierarchical categorization.
+ /// Stored in Instances.CategoryCategories.
+ ///
+ public IEnumerable ParentCategoryIds { get; set; } = new List();
+ public IDictionary Attributes { get; set; } = new Dictionary();
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs
new file mode 100644
index 0000000..f7aecb4
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/CreateProductRequest.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ public class CreateProductRequest
+ {
+ [Required]
+ [MaxLength(64)]
+ public string Name { get; set; }
+
+ [Required]
+ [MaxLength(256)]
+ public string Description { get; set; }
+ public string ProductImageUris { get; set; } = string.Empty;
+ public string ValidSkus { get; set; } = string.Empty;
+
+ ///
+ /// Arbitrary key/value metadata for this product (e.g. Color, Brand, SKU).
+ ///
+ public IDictionary Attributes { get; set; } = new Dictionary();
+
+ ///
+ /// IDs of categories this product belongs to.
+ ///
+ public IEnumerable CategoryIds { get; set; } = new List();
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs
new file mode 100644
index 0000000..903160c
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryAdjustRequest.cs
@@ -0,0 +1,23 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// Request body for a single-product inventory adjustment (add or remove).
+ ///
+ public class InventoryAdjustRequest
+ {
+ ///
+ /// Must be a positive value regardless of operation direction.
+ /// The direction (add vs. remove) is determined by the endpoint called.
+ ///
+ [Range(0.001, double.MaxValue, ErrorMessage = "Quantity must be greater than zero.")]
+ public decimal Quantity { get; set; }
+
+ ///
+ /// Optional classification for this transaction (e.g. "SALE", "RETURN", "ADJUSTMENT").
+ /// Maps to Transactions.InventoryTransactions.TypeCategory.
+ ///
+ public string TypeCategory { get; set; }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs
new file mode 100644
index 0000000..cdc3905
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Requests/InventoryBatchItem.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Sparcpoint.Inventory.Abstractions
+{
+ ///
+ /// A single item in a bulk inventory operation.
+ ///
+ public class InventoryBatchItem
+ {
+ public int ProductInstanceId { get; set; }
+
+ [Range(0.001, double.MaxValue, ErrorMessage = "Quantity must be greater than zero.")]
+ public decimal Quantity { get; set; }
+ public string TypeCategory { get; set; }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj b/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj
new file mode 100644
index 0000000..84fa098
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Abstractions/Sparcpoint.Inventory.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+ netstandard2.0
+ 8.0
+
+
+
+
+
+
diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs b/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..611a150
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.SqlServer/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+
+namespace Sparcpoint.Inventory.SqlServer
+{
+ ///
+ /// EVAL: keeps Startup.cs clean - the whole inventory feature is one line from any host project.
+ /// New repositories just get added here, nothing else to touch.
+ ///
+ public static class ServiceCollectionExtensions
+ {
+ public static IServiceCollection AddSqlInventoryServices(
+ this IServiceCollection services,
+ string connectionString)
+ {
+ if (string.IsNullOrWhiteSpace(connectionString))
+ throw new ArgumentException("A SQL Server connection string is required.", nameof(connectionString));
+
+ // ISqlExecutor is registered as Scoped so each HTTP request gets its own connection.
+ services.AddScoped(_ => new SqlServerExecutor(connectionString));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj b/Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj
new file mode 100644
index 0000000..521be02
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.SqlServer/Sparcpoint.Inventory.SqlServer.csproj
@@ -0,0 +1,18 @@
+
+
+ netstandard2.0
+ 8.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs
new file mode 100644
index 0000000..87a0d8d
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlCategoryRepository.cs
@@ -0,0 +1,107 @@
+using Dapper;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Sparcpoint.Inventory.SqlServer
+{
+ public class SqlCategoryRepository : ICategoryRepository
+ {
+ private readonly ISqlExecutor _Executor;
+
+ public SqlCategoryRepository(ISqlExecutor executor)
+ {
+ _Executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task AddAsync(CreateCategoryRequest request)
+ {
+ if (request == null) throw new ArgumentNullException(nameof(request));
+ PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name));
+ PreConditions.StringNotNullOrWhitespace(request.Description, nameof(request.Description));
+
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ var categoryId = await conn.ExecuteScalarAsync(@"
+ INSERT INTO [Instances].[Categories] ([Name], [Description])
+ VALUES (@Name, @Description);
+ SELECT CAST(SCOPE_IDENTITY() AS INT);",
+ new { request.Name, request.Description }, tx);
+
+ // Link to parent categories for hierarchy support
+ if (request.ParentCategoryIds != null)
+ {
+ var parentIds = request.ParentCategoryIds.ToList();
+ if (parentIds.Count > 0)
+ {
+ // EVAL: validate parent IDs before inserting so we return a 400 instead of
+ // letting the FK violation blow up as a 500
+ var existingCount = await conn.ExecuteScalarAsync(
+ "SELECT COUNT(*) FROM [Instances].[Categories] WHERE [InstanceId] IN @Ids",
+ new { Ids = parentIds }, tx);
+
+ if (existingCount != parentIds.Count)
+ throw new ArgumentException(
+ "One or more ParentCategoryIds do not reference existing categories.",
+ nameof(request.ParentCategoryIds));
+
+ foreach (var parentId in parentIds)
+ {
+ await conn.ExecuteAsync(@"
+ INSERT INTO [Instances].[CategoryCategories] ([InstanceId], [CategoryInstanceId])
+ VALUES (@InstanceId, @CategoryInstanceId)",
+ new { InstanceId = categoryId, CategoryInstanceId = parentId }, tx);
+ }
+ }
+ }
+
+ if (request.Attributes != null)
+ {
+ foreach (var attr in request.Attributes)
+ {
+ await conn.ExecuteAsync(@"
+ INSERT INTO [Instances].[CategoryAttributes] ([InstanceId], [Key], [Value])
+ VALUES (@InstanceId, @Key, @Value)",
+ new { InstanceId = categoryId, attr.Key, attr.Value }, tx);
+ }
+ }
+
+ return categoryId;
+ });
+ }
+
+ public async Task> GetAllAsync()
+ {
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ var categories = (await conn.QueryAsync(@"
+ SELECT [InstanceId], [Name], [Description], [CreatedTimestamp]
+ FROM [Instances].[Categories]
+ ORDER BY [Name]", null, tx)).ToList();
+
+ if (categories.Any())
+ {
+ var ids = categories.Select(c => c.InstanceId).ToArray();
+ var parentLinks = await conn.QueryAsync<(int InstanceId, int CategoryInstanceId)>(
+ "SELECT [InstanceId], [CategoryInstanceId] FROM [Instances].[CategoryCategories] WHERE [InstanceId] IN @Ids",
+ new { Ids = ids }, tx);
+
+ var parentLookup = parentLinks
+ .GroupBy(p => p.InstanceId)
+ .ToDictionary(g => g.Key, g => g.Select(p => p.CategoryInstanceId).ToList());
+
+ foreach (var cat in categories)
+ {
+ cat.ParentCategoryIds = parentLookup.TryGetValue(cat.InstanceId, out var parents)
+ ? parents : new List();
+ }
+ }
+
+ return categories;
+ });
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs
new file mode 100644
index 0000000..d480026
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlInventoryRepository.cs
@@ -0,0 +1,187 @@
+using Dapper;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Sparcpoint.Inventory.SqlServer
+{
+ public class SqlInventoryRepository : IInventoryRepository
+ {
+ private readonly ISqlExecutor _Executor;
+
+ public SqlInventoryRepository(ISqlExecutor executor)
+ {
+ _Executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task AddAsync(int productInstanceId, decimal quantity, string typeCategory = null)
+ {
+ if (quantity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity to add must be positive.");
+
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ await InsertTransactionAsync(conn, tx, productInstanceId, quantity, typeCategory));
+ }
+
+ public async Task AddBatchAsync(IEnumerable items)
+ {
+ if (items == null) throw new ArgumentNullException(nameof(items));
+
+ // EVAL: all batch items run in one transaction - all succeed or all roll back
+ await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ foreach (var item in items)
+ {
+ if (item.Quantity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(item.Quantity), "Batch add quantity must be positive.");
+ await InsertTransactionAsync(conn, tx, item.ProductInstanceId, item.Quantity, item.TypeCategory);
+ }
+ });
+ }
+
+ public async Task RemoveAsync(int productInstanceId, decimal quantity, string typeCategory = null)
+ {
+ if (quantity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity to remove must be positive.");
+
+ // Store removals as negative quantities - net SUM gives the true on-hand count.
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ await InsertTransactionAsync(conn, tx, productInstanceId, -quantity, typeCategory));
+ }
+
+ public async Task RemoveBatchAsync(IEnumerable items)
+ {
+ if (items == null) throw new ArgumentNullException(nameof(items));
+
+ await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ foreach (var item in items)
+ {
+ if (item.Quantity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(item.Quantity), "Batch remove quantity must be positive.");
+ await InsertTransactionAsync(conn, tx, item.ProductInstanceId, -item.Quantity, item.TypeCategory);
+ }
+ });
+ }
+
+ public async Task DeleteTransactionAsync(int transactionId)
+ {
+ await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ var affected = await conn.ExecuteAsync(
+ "DELETE FROM [Transactions].[InventoryTransactions] WHERE [TransactionId] = @TransactionId",
+ new { TransactionId = transactionId }, tx);
+
+ if (affected == 0)
+ throw new InvalidOperationException($"Transaction {transactionId} not found.");
+ });
+ }
+
+ public async Task GetCountAsync(int productInstanceId)
+ {
+ // EVAL: CompletedTimestamp IS NOT NULL filters for committed transactions - right now
+ // every insert sets it immediately so this hits everything, but the column is there
+ // to support a future pending transaction workflow if we ever need it
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ await conn.ExecuteScalarAsync(@"
+ SELECT ISNULL(SUM([Quantity]), 0)
+ FROM [Transactions].[InventoryTransactions]
+ WHERE [ProductInstanceId] = @ProductInstanceId
+ AND [CompletedTimestamp] IS NOT NULL",
+ new { ProductInstanceId = productInstanceId }, tx));
+ }
+
+ public async Task> GetCountByFilterAsync(ProductSearchFilter filter)
+ {
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ // EVAL: SqlServerQueryProvider handles dynamic WHERE - SELECT/FROM/JOIN/GROUP BY are
+ // hardcoded because the provider has no API for those. Same filter approach as
+ // SqlProductRepository.SearchAsync: Name LIKE uses Where() + AddParameter(), EAV
+ // attributes use composed EXISTS subqueries with GetNextParameterName() for unique
+ // param names, CategoryIds uses a raw Where() with an IN list that Dapper expands natively.
+ var provider = SqlServerQueryProvider.Empty;
+
+ if (!string.IsNullOrWhiteSpace(filter?.Name))
+ {
+ provider.Where("p.[Name] LIKE @Name");
+ provider.AddParameter("Name", $"%{filter.Name}%");
+ }
+
+ if (filter?.Attributes != null)
+ {
+ foreach (var attr in filter.Attributes)
+ {
+ var keyParam = provider.GetNextParameterName("AttrKey"); // e.g. "AttrKey0"
+ var valParam = provider.GetNextParameterName("AttrVal"); // e.g. "AttrVal1"
+ var alias = keyParam.Substring("AttrKey".Length); // e.g. "0"
+ provider.Where($@"EXISTS (
+ SELECT 1 FROM [Instances].[ProductAttributes] pa{alias}
+ WHERE pa{alias}.[InstanceId] = p.[InstanceId]
+ AND pa{alias}.[Key] = @{keyParam} AND pa{alias}.[Value] = @{valParam})");
+ provider.AddParameter(keyParam, attr.Key);
+ provider.AddParameter(valParam, attr.Value);
+ }
+ }
+
+ if (filter?.CategoryIds != null && filter.CategoryIds.Any())
+ {
+ provider.Where(@"EXISTS (
+ SELECT 1 FROM [Instances].[ProductCategories] pc
+ WHERE pc.[InstanceId] = p.[InstanceId]
+ AND pc.[CategoryInstanceId] IN @CategoryIds)");
+ provider.AddParameter("CategoryIds", filter.CategoryIds);
+ }
+
+ // EVAL: "[Name]" without the "p." alias - SanitizeColumnName rejects "p.[Name]"
+ // (unbracketed prefix + bracketed name), and it's unambiguous anyway since it's the only [Name] in the SELECT
+ provider.OrderByAscending("[Name]");
+
+ var sqlBuilder = new StringBuilder(@"
+ SELECT p.[InstanceId] AS ProductInstanceId,
+ p.[Name] AS ProductName,
+ ISNULL(SUM(t.[Quantity]), 0) AS TotalQuantity
+ FROM [Instances].[Products] p
+ LEFT JOIN [Transactions].[InventoryTransactions] t
+ ON t.[ProductInstanceId] = p.[InstanceId]
+ AND t.[CompletedTimestamp] IS NOT NULL");
+
+ sqlBuilder.Append($" {provider.WhereClause}");
+ sqlBuilder.Append(" GROUP BY p.[InstanceId], p.[Name]");
+ sqlBuilder.Append($" {provider.OrderByClause}");
+
+ return await conn.QueryAsync(sqlBuilder.ToString(), provider.Parameters, tx);
+ });
+ }
+
+ private static async Task InsertTransactionAsync(
+ IDbConnection conn, IDbTransaction tx,
+ int productInstanceId, decimal quantity, string typeCategory)
+ {
+ // EVAL: check product existence inside the same transaction before the INSERT so we get a
+ // clean 400 instead of a 547 FK error surfacing as a 500 - this helper is shared by
+ // AddAsync, RemoveAsync, AddBatchAsync, and RemoveBatchAsync so one check covers all paths
+ var productExists = await conn.ExecuteScalarAsync(
+ "SELECT COUNT(1) FROM [Instances].[Products] WHERE [InstanceId] = @Id",
+ new { Id = productInstanceId }, tx);
+
+ if (productExists == 0)
+ throw new ArgumentException(
+ $"Product with InstanceId {productInstanceId} does not exist.",
+ nameof(productInstanceId));
+
+ return await conn.ExecuteScalarAsync(@"
+ INSERT INTO [Transactions].[InventoryTransactions]
+ ([ProductInstanceId], [Quantity], [TypeCategory], [CompletedTimestamp])
+ VALUES (@ProductInstanceId, @Quantity, @TypeCategory, SYSUTCDATETIME());
+ SELECT CAST(SCOPE_IDENTITY() AS INT);",
+ new { ProductInstanceId = productInstanceId, Quantity = quantity, TypeCategory = typeCategory },
+ tx);
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs
new file mode 100644
index 0000000..d4bc929
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.SqlServer/SqlProductRepository.cs
@@ -0,0 +1,212 @@
+using Dapper;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Sparcpoint.Inventory.SqlServer
+{
+ ///
+ /// EVAL: went with Dapper instead of EF Core because the EAV schema and dynamic WHERE building
+ /// would get messy with an ORM - easier to just write the SQL directly
+ ///
+ public class SqlProductRepository : IProductRepository
+ {
+ private readonly ISqlExecutor _Executor;
+
+ public SqlProductRepository(ISqlExecutor executor)
+ {
+ _Executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task AddAsync(CreateProductRequest request)
+ {
+ if (request == null) throw new ArgumentNullException(nameof(request));
+ PreConditions.StringNotNullOrWhitespace(request.Name, nameof(request.Name));
+ PreConditions.StringNotNullOrWhitespace(request.Description, nameof(request.Description));
+
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ // Insert the base product record
+ var productId = await conn.ExecuteScalarAsync(@"
+ INSERT INTO [Instances].[Products] ([Name], [Description], [ProductImageUris], [ValidSkus])
+ VALUES (@Name, @Description, @ProductImageUris, @ValidSkus);
+ SELECT CAST(SCOPE_IDENTITY() AS INT);",
+ new
+ {
+ request.Name,
+ request.Description,
+ ProductImageUris = request.ProductImageUris ?? string.Empty,
+ ValidSkus = request.ValidSkus ?? string.Empty
+ }, tx);
+
+ // Insert arbitrary metadata attributes
+ // EVAL: row-by-row inserts are fine for normal writes - for bulk imports a TVP would be faster
+ if (request.Attributes != null)
+ {
+ foreach (var attr in request.Attributes)
+ {
+ await conn.ExecuteAsync(@"
+ INSERT INTO [Instances].[ProductAttributes] ([InstanceId], [Key], [Value])
+ VALUES (@InstanceId, @Key, @Value)",
+ new { InstanceId = productId, attr.Key, attr.Value }, tx);
+ }
+ }
+
+ // Link product to categories
+ if (request.CategoryIds != null)
+ {
+ var categoryIds = request.CategoryIds.ToList();
+ if (categoryIds.Count > 0)
+ {
+ // EVAL: validate category IDs before inserting so we return a 400 instead of
+ // letting the FK violation (FK_ProductCategories_Categories) blow up as a 500
+ var existingCount = await conn.ExecuteScalarAsync(
+ "SELECT COUNT(*) FROM [Instances].[Categories] WHERE [InstanceId] IN @Ids",
+ new { Ids = categoryIds }, tx);
+
+ if (existingCount != categoryIds.Count)
+ throw new ArgumentException(
+ "One or more CategoryIds do not reference existing categories.",
+ nameof(request.CategoryIds));
+
+ foreach (var categoryId in categoryIds)
+ {
+ await conn.ExecuteAsync(@"
+ INSERT INTO [Instances].[ProductCategories] ([InstanceId], [CategoryInstanceId])
+ VALUES (@InstanceId, @CategoryInstanceId)",
+ new { InstanceId = productId, CategoryInstanceId = categoryId }, tx);
+ }
+ }
+ }
+
+ return productId;
+ });
+ }
+
+ public async Task> SearchAsync(ProductSearchFilter filter)
+ {
+ return await _Executor.ExecuteAsync(async (conn, tx) =>
+ {
+ // EVAL: SqlServerQueryProvider handles WHERE and ORDER BY - SELECT/FROM is hardcoded
+ // because the provider has no API for those clauses, it just adds to a base query.
+ // Name LIKE uses Where() + AddParameter() because WhereEquals generates "=" not LIKE.
+ // CategoryIds and EAV filters use raw Where() with composed subquery strings because
+ // the provider's WhereExists formats as "{col} EXISTS {expr}" which isn't valid SQL here.
+ // GetNextParameterName() keeps parameter names unique across the whole query and
+ // AddParameter() registers each value so provider.Parameters has the full bag for Dapper.
+ var provider = SqlServerQueryProvider.Empty;
+
+ // Name filter: partial match (LIKE, not equality WhereEquals does not apply)
+ if (!string.IsNullOrWhiteSpace(filter?.Name))
+ {
+ provider.Where("p.[Name] LIKE @Name");
+ provider.AddParameter("Name", $"%{filter.Name}%");
+ }
+
+ // EVAL: each attribute filter is an EXISTS subquery, not a JOIN - joining on EAV tables
+ // causes row multiplication (N attributes * M filter criteria before DISTINCT), EXISTS
+ // is cleaner and has a more predictable query plan.
+ // Key and val get different numeric suffixes from GetNextParameterName() - that's on
+ // purpose so every param name is globally unique. The table alias uses just the key's
+ // suffix (via Substring) as the per-iteration discriminator, val suffix only shows up
+ // as a param name so there's no conflict.
+ if (filter?.Attributes != null)
+ {
+ foreach (var attr in filter.Attributes)
+ {
+ var keyParam = provider.GetNextParameterName("AttrKey"); // e.g. "AttrKey0"
+ var valParam = provider.GetNextParameterName("AttrVal"); // e.g. "AttrVal1"
+ var alias = keyParam.Substring("AttrKey".Length); // e.g. "0" - unique per iteration
+ provider.Where($@"EXISTS (
+ SELECT 1 FROM [Instances].[ProductAttributes] pa{alias}
+ WHERE pa{alias}.[InstanceId] = p.[InstanceId]
+ AND pa{alias}.[Key] = @{keyParam}
+ AND pa{alias}.[Value] = @{valParam})");
+ provider.AddParameter(keyParam, attr.Key);
+ provider.AddParameter(valParam, attr.Value);
+ }
+ }
+
+ // Category filter: product must belong to at least one of the specified categories.
+ // The IN list is passed as a parameter; Dapper expands int[] to an IN clause natively.
+ if (filter?.CategoryIds != null && filter.CategoryIds.Any())
+ {
+ provider.Where(@"EXISTS (
+ SELECT 1 FROM [Instances].[ProductCategories] pc
+ WHERE pc.[InstanceId] = p.[InstanceId]
+ AND pc.[CategoryInstanceId] IN @CategoryIds)");
+ provider.AddParameter("CategoryIds", filter.CategoryIds);
+ }
+
+ // ORDER BY via the provider so the clause is managed consistently
+ provider.OrderByAscending("[Name]");
+
+ var sqlBase = @"
+ SELECT p.[InstanceId], p.[Name], p.[Description], p.[ProductImageUris], p.[ValidSkus], p.[CreatedTimestamp]
+ FROM [Instances].[Products] p";
+
+ var sqlBuilder = new StringBuilder(sqlBase);
+ sqlBuilder.Append($" {provider.WhereClause}");
+ sqlBuilder.Append($" {provider.OrderByClause}");
+
+ // EVAL: OFFSET-FETCH needs ORDER BY which is guaranteed above. Pagination only kicks in
+ // when both Page and PageSize are supplied - if only one is given we ignore it and return
+ // everything. PageSize is clamped to 200 instead of throwing because that's friendlier
+ // for clients that pass a huge value - request stays valid and the DB is protected.
+ if (filter?.Page != null && filter?.PageSize != null)
+ {
+ // EVAL: Page < 1 produces a negative OFFSET which is a SQL error, so we throw
+ // instead of clamping - this is always a caller bug, not just an oversized value
+ if (filter.Page < 1)
+ throw new ArgumentOutOfRangeException(nameof(filter.Page), "Page must be >= 1.");
+
+ var pageSize = Math.Min(filter.PageSize.Value, 200);
+ provider.AddParameter("Page", filter.Page.Value);
+ provider.AddParameter("PageSize", pageSize);
+ sqlBuilder.Append(" OFFSET (@Page - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY");
+ }
+
+ // provider.Parameters returns a defensive copy (Dictionary).
+ // Dapper accepts IDictionary directly - no DynamicParameters wrapper needed.
+ var products = (await conn.QueryAsync(sqlBuilder.ToString(), provider.Parameters, tx)).ToList();
+
+ // Load attributes and category links for all returned products in two bulk queries
+ // to avoid N+1 queries
+ if (products.Any())
+ {
+ var ids = products.Select(p => p.InstanceId).ToArray();
+
+ var attributes = await conn.QueryAsync<(int InstanceId, string Key, string Value)>(
+ "SELECT [InstanceId], [Key], [Value] FROM [Instances].[ProductAttributes] WHERE [InstanceId] IN @Ids",
+ new { Ids = ids }, tx);
+
+ var categoryLinks = await conn.QueryAsync<(int InstanceId, int CategoryInstanceId)>(
+ "SELECT [InstanceId], [CategoryInstanceId] FROM [Instances].[ProductCategories] WHERE [InstanceId] IN @Ids",
+ new { Ids = ids }, tx);
+
+ var attrLookup = attributes
+ .GroupBy(a => a.InstanceId)
+ .ToDictionary(g => g.Key, g => g.Select(a => new ProductAttribute { Key = a.Key, Value = a.Value }).ToList());
+
+ var catLookup = categoryLinks
+ .GroupBy(c => c.InstanceId)
+ .ToDictionary(g => g.Key, g => g.Select(c => c.CategoryInstanceId).ToList());
+
+ foreach (var product in products)
+ {
+ product.Attributes = attrLookup.TryGetValue(product.InstanceId, out var attrs)
+ ? attrs : new List();
+ product.CategoryIds = catLookup.TryGetValue(product.InstanceId, out var cats)
+ ? cats : new List();
+ }
+ }
+
+ return products;
+ });
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs
new file mode 100644
index 0000000..b744b9e
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/IntegrationTests.cs
@@ -0,0 +1,126 @@
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.Inventory.SqlServer;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Transactions;
+using System.Threading.Tasks;
+using Xunit;
+
+// EVAL: SparcpointInventory must exist on (localdb)\MSSQLLocalDB with the full schema applied
+// before running these, use the SQL scripts in the project. Without it you'll get
+// "Invalid object name" errors right away, these tests don't self-bootstrap.
+
+namespace Sparcpoint.Inventory.Tests
+{
+ // EVAL: using TransactionScope instead of TRUNCATE/DELETE between tests for a few reasons:
+ // rollback is one log operation vs truncating tables with FK constraints which needs a specific
+ // deletion order, TRUNCATE also needs ALTER TABLE perms we don't have, and if a test crashes
+ // mid-run scope.Dispose() without Complete() auto-rolls back so no orphaned data on the dev DB.
+ // Works with SqlServerExecutor because Microsoft.Data.SqlClient auto-enlists new connections in
+ // an ambient TransactionScope - conn.BeginTransaction() gets a nested transaction that
+ // participates in the scope, so disposing without Complete() rolls back everything.
+ [Trait("Category", "Integration")]
+ public class IntegrationTests
+ {
+ // EVAL: connection string pulled from env var so CI/CD or other environments can override it
+ // without touching the code - falls back to LocalDB for local dev
+ private static readonly string ConnectionString =
+ Environment.GetEnvironmentVariable("INVENTORY_TEST_DB")
+ ?? "Server=(localdb)\\MSSQLLocalDB;Database=SparcpointInventory;Trusted_Connection=True;TrustServerCertificate=True;";
+
+ // -----------------------------------------------------------------------------------------
+ // Test 1: Create a product with a known attribute, then verify SearchAsync finds it
+ // -----------------------------------------------------------------------------------------
+ [Fact]
+ public async Task AddProduct_ThenSearch_ByAttribute_ReturnsProduct()
+ {
+ using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
+
+ var executor = new SqlServerExecutor(ConnectionString);
+ var productRepo = new SqlProductRepository(executor);
+
+ var request = new CreateProductRequest
+ {
+ Name = "Integration Test Widget",
+ Description = "Test",
+ Attributes = new Dictionary
+ {
+ { "Brand", "ACME" }
+ }
+ };
+
+ await productRepo.AddAsync(request);
+
+ var results = await productRepo.SearchAsync(new ProductSearchFilter
+ {
+ Attributes = new Dictionary
+ {
+ { "Brand", "ACME" }
+ }
+ });
+
+ Assert.Contains(results, p => p.Name == "Integration Test Widget");
+
+ // No scope.Complete() - automatic rollback on dispose keeps the database clean.
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Test 2: Add inventory for a product and verify GetCountAsync returns the correct quantity
+ // -----------------------------------------------------------------------------------------
+ [Fact]
+ public async Task AddInventory_ThenGetCount_ReturnsCorrectQuantity()
+ {
+ using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
+
+ var executor = new SqlServerExecutor(ConnectionString);
+ var productRepo = new SqlProductRepository(executor);
+ var inventoryRepo = new SqlInventoryRepository(executor);
+
+ // A valid product is required to satisfy the FK on InventoryTransactions.
+ var productId = await productRepo.AddAsync(new CreateProductRequest
+ {
+ Name = "Inventory Count Test Product",
+ Description = "Test"
+ });
+
+ await inventoryRepo.AddAsync(productId, 50m);
+
+ var count = await inventoryRepo.GetCountAsync(productId);
+
+ Assert.Equal(50m, count);
+
+ // No scope.Complete() - automatic rollback.
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Test 3: Delete a transaction and verify the inventory count returns to zero
+ // -----------------------------------------------------------------------------------------
+ [Fact]
+ public async Task DeleteTransaction_UndoesInventoryEffect()
+ {
+ using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
+
+ var executor = new SqlServerExecutor(ConnectionString);
+ var productRepo = new SqlProductRepository(executor);
+ var inventoryRepo = new SqlInventoryRepository(executor);
+
+ var productId = await productRepo.AddAsync(new CreateProductRequest
+ {
+ Name = "Delete Transaction Test Product",
+ Description = "Test"
+ });
+
+ var transactionId = await inventoryRepo.AddAsync(productId, 100m);
+
+ await inventoryRepo.DeleteTransactionAsync(transactionId);
+
+ var count = await inventoryRepo.GetCountAsync(productId);
+
+ Assert.Equal(0m, count);
+
+ // No scope.Complete() - automatic rollback.
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs b/Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..6a6cc3f
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.Inventory.SqlServer;
+using System;
+using Xunit;
+
+namespace Sparcpoint.Inventory.Tests
+{
+ public class ServiceCollectionExtensionsTests
+ {
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void AddSqlInventoryServices_ThrowsOnBlankConnectionString(string cs)
+ {
+ var services = new ServiceCollection();
+ Assert.Throws(() => services.AddSqlInventoryServices(cs));
+ }
+
+ [Fact]
+ public void AddSqlInventoryServices_RegistersAllRepositories()
+ {
+ var services = new ServiceCollection();
+ services.AddSqlInventoryServices("Server=.;Database=Test;Trusted_Connection=True;TrustServerCertificate=True;");
+ var provider = services.BuildServiceProvider();
+ Assert.NotNull(provider.GetService());
+ Assert.NotNull(provider.GetService());
+ Assert.NotNull(provider.GetService());
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj b/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj
new file mode 100644
index 0000000..7978bd6
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/Sparcpoint.Inventory.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+ net8.0
+ false
+ latest
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs
new file mode 100644
index 0000000..cc3d74d
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/SqlCategoryRepositoryTests.cs
@@ -0,0 +1,90 @@
+using Moq;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.Inventory.SqlServer;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Data;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Sparcpoint.Inventory.Tests
+{
+ public class SqlCategoryRepositoryTests
+ {
+ [Fact]
+ public void Constructor_ThrowsOnNullExecutor()
+ {
+ Assert.Throws(() => new SqlCategoryRepository(null));
+ }
+
+ [Fact]
+ public async Task AddAsync_ThrowsOnNullRequest()
+ {
+ var executor = new Mock();
+ var repo = new SqlCategoryRepository(executor.Object);
+ await Assert.ThrowsAsync(() => repo.AddAsync(null));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public async Task AddAsync_ThrowsOnBlankName(string name)
+ {
+ var executor = new Mock();
+ var repo = new SqlCategoryRepository(executor.Object);
+ var request = new CreateCategoryRequest { Name = name, Description = "Desc" };
+ await Assert.ThrowsAsync(() => repo.AddAsync(request));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public async Task AddAsync_ThrowsOnBlankDescription(string description)
+ {
+ var executor = new Mock();
+ var repo = new SqlCategoryRepository(executor.Object);
+ var request = new CreateCategoryRequest { Name = "Electronics", Description = description };
+ await Assert.ThrowsAsync(() => repo.AddAsync(request));
+ }
+
+ [Fact]
+ public async Task AddAsync_DelegatesToExecutorAndReturnsCategoryId()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(7);
+
+ var repo = new SqlCategoryRepository(mockExecutor.Object);
+ var result = await repo.AddAsync(new CreateCategoryRequest
+ {
+ Name = "Electronics",
+ Description = "Electronic devices"
+ });
+
+ Assert.Equal(7, result);
+ mockExecutor.Verify(
+ e => e.ExecuteAsync(It.IsAny>>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_DelegatesToExecutor()
+ {
+ var mockExecutor = new Mock();
+ // EVAL: setup type must match the actual T in ExecuteAsync - GetAllAsync calls .ToList()
+ // internally so T is List not IEnumerable, using the wrong one makes
+ // Moq miss the setup and return null
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>>()))
+ .ReturnsAsync(new System.Collections.Generic.List());
+
+ var repo = new SqlCategoryRepository(mockExecutor.Object);
+ var result = await repo.GetAllAsync();
+
+ Assert.NotNull(result);
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs
new file mode 100644
index 0000000..fd4a665
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/SqlInventoryRepositoryTests.cs
@@ -0,0 +1,150 @@
+using Moq;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.Inventory.SqlServer;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Sparcpoint.Inventory.Tests
+{
+ public class SqlInventoryRepositoryTests
+ {
+ [Fact]
+ public void Constructor_ThrowsOnNullExecutor()
+ {
+ Assert.Throws(() => new SqlInventoryRepository(null));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(-100)]
+ public async Task AddAsync_ThrowsOnNonPositiveQuantity(decimal qty)
+ {
+ var executor = new Mock();
+ var repo = new SqlInventoryRepository(executor.Object);
+ await Assert.ThrowsAsync(() => repo.AddAsync(1, qty));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public async Task RemoveAsync_ThrowsOnNonPositiveQuantity(decimal qty)
+ {
+ var executor = new Mock();
+ var repo = new SqlInventoryRepository(executor.Object);
+ await Assert.ThrowsAsync(() => repo.RemoveAsync(1, qty));
+ }
+
+ [Fact]
+ public async Task AddBatchAsync_ThrowsOnNullItems()
+ {
+ var executor = new Mock();
+ var repo = new SqlInventoryRepository(executor.Object);
+ await Assert.ThrowsAsync(() => repo.AddBatchAsync(null));
+ }
+
+ [Fact]
+ public async Task RemoveBatchAsync_ThrowsOnNullItems()
+ {
+ var executor = new Mock();
+ var repo = new SqlInventoryRepository(executor.Object);
+ await Assert.ThrowsAsync(() => repo.RemoveBatchAsync(null));
+ }
+
+ [Fact]
+ public async Task GetCountAsync_DelegatesToExecutorAndReturnsResult()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(75.5m);
+
+ var repo = new SqlInventoryRepository(mockExecutor.Object);
+ var result = await repo.GetCountAsync(productInstanceId: 1);
+
+ Assert.Equal(75.5m, result);
+ mockExecutor.Verify(
+ e => e.ExecuteAsync(It.IsAny>>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task GetCountAsync_ReturnsZeroWhenExecutorReturnsZero()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(0m);
+
+ var repo = new SqlInventoryRepository(mockExecutor.Object);
+ var result = await repo.GetCountAsync(productInstanceId: 99);
+
+ Assert.Equal(0m, result);
+ }
+
+ [Fact]
+ public async Task AddBatchAsync_WithEmptyList_DoesNotThrow()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>()))
+ .Returns(Task.CompletedTask);
+
+ var repo = new SqlInventoryRepository(mockExecutor.Object);
+
+ // EVAL: empty batch is valid - just a no-op, nothing gets inserted
+ var exception = await Record.ExceptionAsync(() =>
+ repo.AddBatchAsync(new List()));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task RemoveBatchAsync_WithEmptyList_DoesNotThrow()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>()))
+ .Returns(Task.CompletedTask);
+
+ var repo = new SqlInventoryRepository(mockExecutor.Object);
+
+ var exception = await Record.ExceptionAsync(() =>
+ repo.RemoveBatchAsync(new List()));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task AddAsync_DelegatesToExecutorAndReturnsTransactionId()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(42);
+
+ var repo = new SqlInventoryRepository(mockExecutor.Object);
+ var transactionId = await repo.AddAsync(productInstanceId: 1, quantity: 10m);
+
+ Assert.Equal(42, transactionId);
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DelegatesToExecutorAndReturnsTransactionId()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(99);
+
+ var repo = new SqlInventoryRepository(mockExecutor.Object);
+ var transactionId = await repo.RemoveAsync(productInstanceId: 2, quantity: 5m);
+
+ Assert.Equal(99, transactionId);
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs
new file mode 100644
index 0000000..1931572
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositorySearchTests.cs
@@ -0,0 +1,85 @@
+using Moq;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.Inventory.SqlServer;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Sparcpoint.Inventory.Tests
+{
+ // EVAL: these verify SearchAsync delegates to the executor and passes filter state correctly -
+ // SQL is mocked here, integration tests handle actual query correctness against the DB
+ public class SqlProductRepositorySearchTests
+ {
+ [Fact]
+ public async Task SearchAsync_WithNullFilter_DelegatesToExecutor()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>>()))
+ .ReturnsAsync(new List());
+
+ var repo = new SqlProductRepository(mockExecutor.Object);
+
+ // Null filter = no criteria = returns all products (unbounded)
+ var exception = await Record.ExceptionAsync(() => repo.SearchAsync(null));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task SearchAsync_WithEmptyFilter_DelegatesToExecutor()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>>()))
+ .ReturnsAsync(new List());
+
+ var repo = new SqlProductRepository(mockExecutor.Object);
+ var exception = await Record.ExceptionAsync(() =>
+ repo.SearchAsync(new ProductSearchFilter()));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task AddAsync_CallsExecutorExactlyOnce()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(1);
+
+ var repo = new SqlProductRepository(mockExecutor.Object);
+ await repo.AddAsync(new CreateProductRequest
+ {
+ Name = "Test Product",
+ Description = "A test product"
+ });
+
+ mockExecutor.Verify(
+ e => e.ExecuteAsync(It.IsAny>>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task AddAsync_ReturnsProductIdFromExecutor()
+ {
+ var mockExecutor = new Mock();
+ mockExecutor
+ .Setup(e => e.ExecuteAsync(It.IsAny>>()))
+ .ReturnsAsync(15);
+
+ var repo = new SqlProductRepository(mockExecutor.Object);
+ var result = await repo.AddAsync(new CreateProductRequest
+ {
+ Name = "Widget Pro",
+ Description = "Professional widget"
+ });
+
+ Assert.Equal(15, result);
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs
new file mode 100644
index 0000000..11fe557
--- /dev/null
+++ b/Development Project/Sparcpoint.Inventory.Tests/SqlProductRepositoryTests.cs
@@ -0,0 +1,53 @@
+using Moq;
+using Sparcpoint.Inventory.Abstractions;
+using Sparcpoint.Inventory.SqlServer;
+using Sparcpoint.SqlServer.Abstractions;
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Sparcpoint.Inventory.Tests
+{
+ // EVAL: using Moq to keep repos isolated from a real SQL Server instance - these just cover
+ // guard clauses and input validation, integration tests handle actual query correctness
+ public class SqlProductRepositoryTests
+ {
+ [Fact]
+ public void Constructor_ThrowsOnNullExecutor()
+ {
+ Assert.Throws(() => new SqlProductRepository(null));
+ }
+
+ [Fact]
+ public async Task AddAsync_ThrowsOnNullRequest()
+ {
+ var executor = new Mock();
+ var repo = new SqlProductRepository(executor.Object);
+ await Assert.ThrowsAsync(() => repo.AddAsync(null));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public async Task AddAsync_ThrowsOnBlankName(string name)
+ {
+ var executor = new Mock();
+ var repo = new SqlProductRepository(executor.Object);
+ var request = new CreateProductRequest { Name = name, Description = "Desc" };
+ await Assert.ThrowsAsync(() => repo.AddAsync(request));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public async Task AddAsync_ThrowsOnBlankDescription(string description)
+ {
+ var executor = new Mock();
+ var repo = new SqlProductRepository(executor.Object);
+ var request = new CreateProductRequest { Name = "Valid Name", Description = description };
+ await Assert.ThrowsAsync(() => repo.AddAsync(request));
+ }
+ }
+}
diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs
index cca814f..8cedcba 100644
--- a/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs
+++ b/Development Project/Sparcpoint.SqlServer.Abstractions/ISqlExecutor.cs
@@ -1,6 +1,5 @@
using System;
using System.Data;
-using System.Data.SqlClient;
using System.Threading.Tasks;
namespace Sparcpoint.SqlServer.Abstractions
diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs
index 56a2dc8..13d078b 100644
--- a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs
+++ b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/JoinTables.cs
@@ -11,7 +11,11 @@ public void Add(string clause)
=> _Backer.Add(clause);
public void Add(string tableName, string abbv, string onClause)
- => _Backer.Add($"JOIN {tableName} {abbv} ON {onClause}");
+ {
+ tableName = SqlServerValidation.SanitizeTableName(tableName);
+ abbv = SqlServerValidation.SanitizeTableName(abbv);
+ _Backer.Add($"JOIN {tableName} {abbv} ON {onClause}");
+ }
public override string ToString()
{
diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs
index eb0c5d5..d47153b 100644
--- a/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs
+++ b/Development Project/Sparcpoint.SqlServer.Abstractions/Provider/SqlServerValidation.cs
@@ -5,7 +5,7 @@ namespace Sparcpoint.SqlServer.Abstractions
{
internal static class SqlServerValidation
{
- private static Regex SQL_OBJECT_NAME_PATTERN = new Regex(@"^\s*(([a-zA-Z][a-zA-Z0-9]*\.)?[a-zA-Z][a-zA-Z0-9_]*|(\[[a-zA-Z0-9_\s]+\]\.)?\[[a-zA-Z0-9_\s]+\])\s*$");
+ private static Regex SQL_OBJECT_NAME_PATTERN = new Regex(@"^\s*(([a-zA-Z][a-zA-Z0-9]*\.)?[a-zA-Z][a-zA-Z0-9_]*|(\[[a-zA-Z0-9_\s]+\]\.)?\[[a-zA-Z0-9_\s]+\])\s*$", RegexOptions.Compiled);
public static string SanitizeColumnName(string columnName)
{
if (!SQL_OBJECT_NAME_PATTERN.IsMatch(columnName))
@@ -22,7 +22,7 @@ public static string SanitizeTableName(string tableName)
return tableName.Trim();
}
- private static Regex PARAMETER_NAME_PATTERN = new Regex(@"^\s*\@?[a-zA-Z][a-zA-Z0-9_]*\s*$");
+ private static Regex PARAMETER_NAME_PATTERN = new Regex(@"^\s*\@?[a-zA-Z][a-zA-Z0-9_]*\s*$", RegexOptions.Compiled);
public static string SanitizeParameterName(string parameterName)
{
if (!PARAMETER_NAME_PATTERN.IsMatch(parameterName))
diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj b/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj
index 6ee3f78..0319b59 100644
--- a/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj
+++ b/Development Project/Sparcpoint.SqlServer.Abstractions/Sparcpoint.SqlServer.Abstractions.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs
index 3035408..13ccc44 100644
--- a/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs
+++ b/Development Project/Sparcpoint.SqlServer.Abstractions/SqlServerExecutor.cs
@@ -1,6 +1,6 @@
-using System;
+using Microsoft.Data.SqlClient;
+using System;
using System.Data;
-using System.Data.SqlClient;
using System.Threading.Tasks;
namespace Sparcpoint.SqlServer.Abstractions
@@ -19,9 +19,19 @@ public T Execute(Func command)
using (var sqlConn = Open())
using (var sqlTrans = sqlConn.BeginTransaction())
{
- var result = command(sqlConn, sqlTrans);
- sqlTrans.Commit();
- return result;
+ try
+ {
+ var result = command(sqlConn, sqlTrans);
+ sqlTrans.Commit();
+ return result;
+ }
+ catch
+ {
+ // EVAL: need the rollback here - if we skip it and something throws, the transaction
+ // stays open until the connection drops which can deadlock under load
+ sqlTrans.Rollback();
+ throw;
+ }
}
}
@@ -30,8 +40,16 @@ public async Task ExecuteAsync(Func command
using (var sqlConn = await OpenAsync())
using (var sqlTrans = sqlConn.BeginTransaction())
{
- await command(sqlConn, sqlTrans);
- sqlTrans.Commit();
+ try
+ {
+ await command(sqlConn, sqlTrans);
+ sqlTrans.Commit();
+ }
+ catch
+ {
+ sqlTrans.Rollback();
+ throw;
+ }
}
}
@@ -40,22 +58,30 @@ public async Task ExecuteAsync(Func
using (var sqlConn = await OpenAsync())
using (var sqlTrans = sqlConn.BeginTransaction())
{
- var result = await command(sqlConn, sqlTrans);
- sqlTrans.Commit();
- return result;
+ try
+ {
+ var result = await command(sqlConn, sqlTrans);
+ sqlTrans.Commit();
+ return result;
+ }
+ catch
+ {
+ sqlTrans.Rollback();
+ throw;
+ }
}
}
private SqlConnection Open()
{
- SqlConnection connection = new SqlConnection(_ConnectionString);
+ var connection = new SqlConnection(_ConnectionString);
connection.Open();
return connection;
}
private async Task OpenAsync()
{
- SqlConnection connection = new SqlConnection(_ConnectionString);
+ var connection = new SqlConnection(_ConnectionString);
await connection.OpenAsync();
return connection;
}
diff --git a/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs b/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs
index 008deb5..c270020 100644
--- a/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs
+++ b/Development Project/Sparcpoint.SqlServer.Abstractions/TransactionSqlServerExecutor.cs
@@ -2,7 +2,7 @@
using System;
using System.Collections.Generic;
using System.Data;
-using System.Data.SqlClient;
+using Microsoft.Data.SqlClient;
using System.Threading.Tasks;
namespace Sparcpoint.SqlServer.Abstractions