diff --git a/Development Project Summary.docx b/Development Project Summary.docx new file mode 100644 index 0000000..7f02247 Binary files /dev/null and b/Development Project Summary.docx differ diff --git a/Development Project.zip b/Development Project.zip new file mode 100644 index 0000000..9a45eb1 Binary files /dev/null and b/Development Project.zip differ diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln index 637bfd9..f62ea1e 100644 --- a/Development Project/Development Project.sln +++ b/Development Project/Development Project.sln @@ -1,60 +1,74 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31410.357 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36408.4 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Interview.Web", "Interview.Web\Interview.Web.csproj", "{EE17B748-4D84-46AE-9E83-8D04B92DD6A9}" EndProject -Project("{00D1A9C2-B5F0-4AF3-8072-F6C62B433612}") = "Sparcpoint.Inventory.Database", "Sparcpoint.Inventory.Database\Sparcpoint.Inventory.Database.sqlproj", "{62E57E63-D706-49CF-B2BF-C9BED8189C36}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Core", "Sparcpoint.Core\Sparcpoint.Core.csproj", "{C4ECDF4B-3396-4512-84DB-ECECA8C5600E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.SqlServer.Abstractions", "Sparcpoint.SqlServer.Abstractions\Sparcpoint.SqlServer.Abstractions.csproj", "{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sparcpoint.Inventory.Database", "Sparcpoint.Inventory.Database\Sparcpoint.Inventory.Database.csproj", "{5C8CD168-28EE-F797-C2EF-FC6D604BC884}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Debug|x64.Build.0 = Debug|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Debug|x86.ActiveCfg = Debug|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Debug|x86.Build.0 = Debug|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Release|Any CPU.Build.0 = Release|Any CPU + {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Release|x64.ActiveCfg = Release|Any CPU + {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Release|x64.Build.0 = Release|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Release|x86.ActiveCfg = Release|Any CPU {EE17B748-4D84-46AE-9E83-8D04B92DD6A9}.Release|x86.Build.0 = Release|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Debug|Any CPU.Build.0 = Debug|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Debug|x86.ActiveCfg = Debug|x86 - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Debug|x86.Build.0 = Debug|x86 - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Debug|x86.Deploy.0 = Debug|x86 - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Release|Any CPU.ActiveCfg = Release|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Release|Any CPU.Build.0 = Release|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Release|Any CPU.Deploy.0 = Release|Any CPU - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Release|x86.ActiveCfg = Release|x86 - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Release|x86.Build.0 = Release|x86 - {62E57E63-D706-49CF-B2BF-C9BED8189C36}.Release|x86.Deploy.0 = Release|x86 {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Debug|x64.Build.0 = Debug|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Debug|x86.ActiveCfg = Debug|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Debug|x86.Build.0 = Debug|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Release|Any CPU.Build.0 = Release|Any CPU + {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Release|x64.ActiveCfg = Release|Any CPU + {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Release|x64.Build.0 = Release|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Release|x86.ActiveCfg = Release|Any CPU {C4ECDF4B-3396-4512-84DB-ECECA8C5600E}.Release|x86.Build.0 = Release|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Debug|x64.ActiveCfg = Debug|Any CPU + {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Debug|x64.Build.0 = Debug|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Debug|x86.ActiveCfg = Debug|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Debug|x86.Build.0 = Debug|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.Build.0 = Release|Any CPU + {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x64.ActiveCfg = Release|Any CPU + {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x64.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 + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Debug|x64.Build.0 = Debug|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Debug|x86.Build.0 = Debug|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Release|Any CPU.Build.0 = Release|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Release|x64.ActiveCfg = Release|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Release|x64.Build.0 = Release|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Release|x86.ActiveCfg = Release|Any CPU + {5C8CD168-28EE-F797-C2EF-FC6D604BC884}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 267f4ec..0c0dd84 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -1,19 +1,109 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; +using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Sparcpoint.Core.Abstract; +using Sparcpoint.Core.Models; namespace Interview.Web.Controllers { - [Route("api/v1/products")] - public class ProductController : Controller + [ApiController] + [Route("api/[controller]")] + public class ProductsController : ControllerBase { - // NOTE: Sample Action + private readonly IProductRepository _repo; + + public ProductsController(IProductRepository repo) + { + _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + } + + [HttpPost] + public async Task Create([FromBody] ProductCreateRequest request) + { + if (request is null) + return BadRequest("Request body is required."); + + // Basic server-side validation (expand in service layer as needed) + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest("Name is required."); + + var productId = await _repo.AddProductAsync(request); + + // Return 201 Created with location header + var uri = $"/api/products/{productId}"; + return Created(uri, new { Id = productId }); + } + + /// + /// Lightweight, URL-query searchable endpoint for common searches. + /// Supports name, description, single sku, categoryId (repeatable), paging and simple ordering. + /// [HttpGet] - public Task GetAllProducts() + public async Task Get( + [FromQuery] string name = null, + [FromQuery] string description = null, + [FromQuery] string sku = null, + [FromQuery(Name = "categoryId")] int[] categoryIds = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] string orderBy = null, + [FromQuery] bool orderDesc = false) { - return Task.FromResult((IActionResult)Ok(new object[] { })); + if (page <= 0) page = 1; + if (pageSize <= 0) pageSize = 50; + if (pageSize > 500) pageSize = 500; // defensive cap + + var criteria = new ProductSearchCriteria + { + Name = name, + Description = description, + Skus = string.IsNullOrWhiteSpace(sku) ? null : new[] { sku }, + CategoryIds = categoryIds, + Page = page, + PageSize = pageSize, + OrderBy = orderBy, + OrderDescending = orderDesc + }; + + var results = await _repo.SearchProductsAsync(criteria) ?? new System.Collections.Generic.List(); + + var response = new ProductSearchResponse + { + Items = results.ToList(), + TotalCount = results.Count, // EVAL: replace with repo-provided total if available + Page = page, + PageSize = pageSize + }; + + return Ok(response); + } + + /// + /// Complex search using ProductSearchCriteria in the request body. + /// Use this for metadata filters, multiple SKUs, match modes, etc. + /// + [HttpPost("search")] + public async Task Search([FromBody] ProductSearchCriteria criteria) + { + if (criteria is null) + return BadRequest("Request body is required."); + + if (criteria.Page <= 0) criteria.Page = 1; + if (criteria.PageSize <= 0) criteria.PageSize = 50; + if (criteria.PageSize > 500) criteria.PageSize = 500; // server-side cap + + var results = await _repo.SearchProductsAsync(criteria) ?? new System.Collections.Generic.List(); + + var response = new ProductSearchResponse + { + Items = results.ToList(), + TotalCount = results.Count, // EVAL: replace with repo-provided total if available + Page = criteria.Page, + PageSize = criteria.PageSize + }; + + return Ok(response); } } } diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d1e4d6e..e5d9f53 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -4,4 +4,14 @@ net8.0 + + + + + + + + + + diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 56452f2..8f9a96a 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -4,6 +4,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Sparcpoint.Core.Abstract; +using Sparcpoint.Inventory.Database.Data; +using Sparcpoint.SqlServer.Abstractions; using System; using System.Collections.Generic; using System.Linq; @@ -24,6 +27,15 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { services.AddControllers(); + + // Register ISqlExecutor and repository + // Pull connection string from: ConnectionStrings:DefaultConnection or SqlServer:ConnectionString + var connectionString = Configuration.GetConnectionString("DefaultConnection") + ?? Configuration["SqlServer:ConnectionString"] + ?? throw new InvalidOperationException("Connection string not configured."); + + services.AddSingleton(_ => new SqlServerExecutor(connectionString)); + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/Development Project/Sparcpoint.Core/Abstract/IProductRepository.cs b/Development Project/Sparcpoint.Core/Abstract/IProductRepository.cs new file mode 100644 index 0000000..086a875 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/IProductRepository.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Sparcpoint.Core.Models; + +namespace Sparcpoint.Core.Abstract +{ + public interface IProductRepository + { + Task AddProductAsync(ProductCreateRequest request); + Task> SearchProductsAsync(ProductSearchCriteria criteria); + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Core/Models/ProductCreateRequest.cs b/Development Project/Sparcpoint.Core/Models/ProductCreateRequest.cs new file mode 100644 index 0000000..4e9bfcc --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/ProductCreateRequest.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Sparcpoint.Core.Models +{ + public class ProductCreateRequest + { + [Required] + [StringLength(256)] + public string Name { get; set; } + + [StringLength(256)] + public string Description { get; set; } + + public IEnumerable ProductImageUris { get; set; } + + public IEnumerable ValidSkus { get; set; } + + // Metadata keys/values validated in service/validator + public Dictionary Metadata { get; set; } = new Dictionary(); + + public IEnumerable CategoryIds { get; set; } + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Core/Models/ProductSearchCriteria.cs b/Development Project/Sparcpoint.Core/Models/ProductSearchCriteria.cs new file mode 100644 index 0000000..8f25da3 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/ProductSearchCriteria.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace Sparcpoint.Core.Models +{ + public enum MetadataMatchMode + { + Exact = 0, + Contains = 1 + } + + public class ProductSearchCriteria + { + // General fields (string matching) + public string Name { get; set; } + public string Description { get; set; } + + // SKU filtering + public IEnumerable Skus { get; set; } + + // Category filtering + public IEnumerable CategoryIds { get; set; } + // If true, include descendant categories when resolving category filters + public bool IncludeCategoryDescendants { get; set; } + + // Metadata key/value filters + public Dictionary Metadata { get; set; } = new Dictionary(); + // Matching behavior for metadata values (Exact vs substring Contains) + public MetadataMatchMode MetadataMatchMode { get; set; } = MetadataMatchMode.Exact; + // If true, all metadata pairs must match (AND). If false, any matching pair satisfies (OR). + public bool MatchAllMetadata { get; set; } = true; + + // Paging & sorting + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + // Column name to order by (validate/whitelist server-side) + public string OrderBy { get; set; } + public bool OrderDescending { get; set; } + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Core/Models/ProductSearchResponse.cs b/Development Project/Sparcpoint.Core/Models/ProductSearchResponse.cs new file mode 100644 index 0000000..cafae0d --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/ProductSearchResponse.cs @@ -0,0 +1,13 @@ + +using System.Collections.Generic; + +namespace Sparcpoint.Core.Models +{ + public class ProductSearchResponse + { + public IList Items { get; set; } = new List(); + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Core/Models/ProductSearchResult.cs b/Development Project/Sparcpoint.Core/Models/ProductSearchResult.cs new file mode 100644 index 0000000..db6771f --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/ProductSearchResult.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace Sparcpoint.Core.Models +{ + public class ProductSearchResult + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + + // JSON columns deserialized by repository + public IEnumerable ProductImageUris { get; set; } + public IEnumerable ValidSkus { get; set; } + + // Key/value metadata from ProductAttributes + public Dictionary Metadata { get; set; } = new Dictionary(); + + // Assigned category ids (many-to-many) + public IEnumerable CategoryIds { get; set; } + + // Product created timestamp if available in schema + public DateTime? CreatedTimestamp { get; set; } + } +} + diff --git a/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj b/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj index fdceab5..328878f 100644 --- a/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj +++ b/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj @@ -16,6 +16,7 @@ + diff --git a/Development Project/Sparcpoint.Inventory.Database/Alter_Categories_AddParent.sql b/Development Project/Sparcpoint.Inventory.Database/Alter_Categories_AddParent.sql new file mode 100644 index 0000000..e69de29 diff --git a/Development Project/Sparcpoint.Inventory.Database/Data/ProductRepository.cs b/Development Project/Sparcpoint.Inventory.Database/Data/ProductRepository.cs new file mode 100644 index 0000000..9a4432c --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Database/Data/ProductRepository.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Dapper; +using Sparcpoint.Core.Abstract; +using Sparcpoint.Core.Models; +using Sparcpoint.SqlServer.Abstractions; + +namespace Sparcpoint.Inventory.Database.Data +{ + public class ProductRepository : IProductRepository + { + private readonly ISqlExecutor _sqlExecutor; + + public ProductRepository(ISqlExecutor sqlExecutor) + { + _sqlExecutor = sqlExecutor ?? throw new ArgumentNullException(nameof(sqlExecutor)); + } + + public async Task AddProductAsync(ProductCreateRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + + return await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + var insertSql = @" + INSERT INTO [Instances].[Products] (Name, Description, ProductImageUris, ValidSkus) + VALUES (@Name, @Description, @ProductImageUris, @ValidSkus); + SELECT CAST(SCOPE_IDENTITY() AS INT); +"; + + var imageJson = JsonSerializer.Serialize(request.ProductImageUris ?? Array.Empty()); + var skusJson = JsonSerializer.Serialize(request.ValidSkus ?? Array.Empty()); + + var productId = await conn.ExecuteScalarAsync( + insertSql, + new { request.Name, request.Description, ProductImageUris = imageJson, ValidSkus = skusJson }, + trans); + + if (request.Metadata != null && request.Metadata.Count > 0) + { + var attrs = request.Metadata.Select(kv => new { InstanceId = productId, Key = kv.Key, Value = kv.Value }).ToList(); + await conn.ExecuteAsync( + "INSERT INTO [Instances].[ProductAttributes] (InstanceId, [Key], [Value]) VALUES (@InstanceId, @Key, @Value);", + attrs, + trans); + } + + if (request.CategoryIds != null && request.CategoryIds.Any()) + { + using (var cmd = new SqlCommand("[Instances].[usp_Product_SetCategories]", (SqlConnection)conn, (SqlTransaction)trans)) + { + cmd.CommandType = CommandType.StoredProcedure; + cmd.Parameters.AddWithValue("@ProductInstanceId", productId); + + var dt = new DataTable(); + dt.Columns.Add("Value", typeof(int)); + foreach (var id in request.CategoryIds.Distinct()) + dt.Rows.Add(id); + + var tvp = new SqlParameter("@CategoryIds", SqlDbType.Structured) + { + TypeName = "dbo.IntegerList", + Value = dt + }; + cmd.Parameters.Add(tvp); + + await cmd.ExecuteNonQueryAsync(); + } + } + + return productId; + }); + } + + private static readonly HashSet _allowedOrderByColumns = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Name", "Description", "InstanceId" + }; + + public async Task> SearchProductsAsync(ProductSearchCriteria criteria) + { + return await _sqlExecutor.ExecuteAsync(async (conn, trans) => + { + // EVAL: Removed the unused LEFT JOINs on ProductCategories and ProductAttributes. + // Category filtering uses an EXISTS subquery (avoids row duplication from one-to-many joins). + // Metadata filtering also uses EXISTS subqueries added dynamically below. + // This means the GROUP BY is no longer needed either. + var sql = @" + SELECT p.InstanceId, p.Name, p.Description, p.ProductImageUris, p.ValidSkus + FROM [Instances].[Products] p + WHERE 1=1"; + + var parameters = new DynamicParameters(); + + if (!string.IsNullOrWhiteSpace(criteria.Name)) + { + sql += " AND p.Name LIKE @Name"; + parameters.Add("@Name", "%" + criteria.Name + "%"); + } + + if (!string.IsNullOrWhiteSpace(criteria.Description)) + { + sql += " AND p.Description LIKE @Description"; + parameters.Add("@Description", "%" + criteria.Description + "%"); + } + + //Category filtering uses EXISTS instead of a JOIN, + // preventing duplicate product rows when a product has multiple categories. + if (criteria.CategoryIds != null && criteria.CategoryIds.Any()) + { + sql += @" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductCategories] pc + WHERE pc.InstanceId = p.InstanceId + AND pc.CategoryInstanceId IN @CategoryIds)"; + parameters.Add("@CategoryIds", criteria.CategoryIds.ToArray()); + } + + // MetadataMatchMode and MatchAllMetadata are respected. + // AND mode: one EXISTS per key/value pair, all must match. + // OR mode: a single EXISTS with multiple OR conditions, any pair satisfies. + if (criteria.Metadata != null && criteria.Metadata.Count > 0) + { + int i = 0; + if (criteria.MatchAllMetadata) + { + // AND: every key/value pair must exist on the product + foreach (var kv in criteria.Metadata) + { + var valueCondition = criteria.MetadataMatchMode == MetadataMatchMode.Contains + ? $"pa{i}.[Value] LIKE @MetaValue{i}" + : $"pa{i}.[Value] = @MetaValue{i}"; + + sql += $@" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductAttributes] pa{i} + WHERE pa{i}.InstanceId = p.InstanceId + AND pa{i}.[Key] = @MetaKey{i} + AND {valueCondition})"; + + parameters.Add($"@MetaKey{i}", kv.Key); + parameters.Add($"@MetaValue{i}", criteria.MetadataMatchMode == MetadataMatchMode.Contains + ? "%" + kv.Value + "%" + : kv.Value); + i++; + } + } + else + { + // OR: any one key/value pair matching is sufficient + var orClauses = new List(); + foreach (var kv in criteria.Metadata) + { + var valueCondition = criteria.MetadataMatchMode == MetadataMatchMode.Contains + ? $"pa.[Value] LIKE @MetaValue{i}" + : $"pa.[Value] = @MetaValue{i}"; + + orClauses.Add($"(pa.[Key] = @MetaKey{i} AND {valueCondition})"); + parameters.Add($"@MetaKey{i}", kv.Key); + parameters.Add($"@MetaValue{i}", criteria.MetadataMatchMode == MetadataMatchMode.Contains + ? "%" + kv.Value + "%" + : kv.Value); + i++; + } + + sql += $@" AND EXISTS ( + SELECT 1 FROM [Instances].[ProductAttributes] pa + WHERE pa.InstanceId = p.InstanceId + AND ({string.Join(" OR ", orClauses)}))"; + } + } + + // OrderBy is applied using a whitelist to prevent SQL injection. + // Column names cannot be parameterized in SQL, so we validate against known safe values. + if (!string.IsNullOrWhiteSpace(criteria.OrderBy) && _allowedOrderByColumns.Contains(criteria.OrderBy)) + { + var direction = criteria.OrderDescending ? "DESC" : "ASC"; + sql += $" ORDER BY p.[{criteria.OrderBy}] {direction}"; + } + else + { + // EVAL: Default sort ensures stable, consistent paging + sql += " ORDER BY p.InstanceId ASC"; + } + + var products = await conn.QueryAsync(sql, parameters, trans); + return products.ToList(); + }); + } + } +} \ No newline at end of file diff --git a/Development Project/Sparcpoint.Inventory.Database/Instances/Procedures/usp_Product_SetCategories.sql b/Development Project/Sparcpoint.Inventory.Database/Instances/Procedures/usp_Product_SetCategories.sql new file mode 100644 index 0000000..3d7bbea --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Database/Instances/Procedures/usp_Product_SetCategories.sql @@ -0,0 +1,28 @@ +CREATE PROCEDURE [Instances].[usp_Product_SetCategories] + @ProductInstanceId INT, + @CategoryIds dbo.IntegerList READONLY +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRANSACTION; + BEGIN TRY + IF NOT EXISTS (SELECT 1 FROM [Instances].[Products] WHERE [InstanceId] = @ProductInstanceId) + THROW 51000, 'Product not found', 1; + + DELETE FROM [Instances].[ProductCategories] + WHERE [InstanceId] = @ProductInstanceId; + + INSERT INTO [Instances].[ProductCategories] ([InstanceId], [CategoryInstanceId]) + SELECT DISTINCT @ProductInstanceId, cid.[Value] + FROM @CategoryIds cid + INNER JOIN [Instances].[Categories] c ON c.[InstanceId] = cid.[Value]; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO \ No newline at end of file diff --git a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Alter_Categories_AddParent.sql b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Alter_Categories_AddParent.sql new file mode 100644 index 0000000..4a48c11 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Alter_Categories_AddParent.sql @@ -0,0 +1,13 @@ +ALTER TABLE [Instances].[Categories] +ADD [ParentInstanceId] INT NULL; + +GO + +ALTER TABLE [Instances].[Categories] +ADD CONSTRAINT [FK_Categories_ParentCategory] FOREIGN KEY ([ParentInstanceId]) REFERENCES [Instances].[Categories]([InstanceId]) ON DELETE NO ACTION; + +GO + +CREATE INDEX [IX_Categories_ParentInstanceId] ON [Instances].[Categories] ([ParentInstanceId]); + +GO \ No newline at end of file diff --git a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Categories.sql b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Categories.sql index 195aa06..3ca932f 100644 --- a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Categories.sql +++ b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Categories.sql @@ -3,7 +3,7 @@ [InstanceId] INT NOT NULL PRIMARY KEY IDENTITY(1,1), [Name] VARCHAR(64) NOT NULL, [Description] VARCHAR(256) NOT NULL, - [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME() ) GO \ No newline at end of file diff --git a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/ProductCategories.sql b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/ProductCategories.sql index dfe040b..dbe0e53 100644 --- a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/ProductCategories.sql +++ b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/ProductCategories.sql @@ -1,8 +1,15 @@ CREATE TABLE [Instances].[ProductCategories] ( [InstanceId] INT NOT NULL, - [CategoryInstanceId] INT NOT NULL, - CONSTRAINT [PK_ProductCategories] PRIMARY KEY ([InstanceId], [CategoryInstanceId]), - CONSTRAINT [FK_ProductCategories_Products] FOREIGN KEY ([InstanceId]) REFERENCES [Instances].[Products]([InstanceId]) ON DELETE CASCADE, - CONSTRAINT [FK_ProductCategories_Categories] FOREIGN KEY ([CategoryInstanceId]) REFERENCES [Instances].[Categories]([InstanceId]) ON DELETE CASCADE -) + [CategoryInstanceId] INT NOT NULL, + [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_ProductCategories] PRIMARY KEY ([InstanceId], [CategoryInstanceId]), + CONSTRAINT [FK_ProductCategories_Products] FOREIGN KEY ([InstanceId]) REFERENCES [Instances].[Products]([InstanceId]) ON DELETE CASCADE, + CONSTRAINT [FK_ProductCategories_Categories] FOREIGN KEY ([CategoryInstanceId]) REFERENCES [Instances].[Categories]([InstanceId]) ON DELETE CASCADE +); + +GO + +CREATE INDEX [IX_ProductCategories_CategoryInstanceId] ON [Instances].[ProductCategories] ([CategoryInstanceId]); + +GO diff --git a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Products.sql b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Products.sql index 3361dc8..4e4179f 100644 --- a/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Products.sql +++ b/Development Project/Sparcpoint.Inventory.Database/Instances/Tables/Products.sql @@ -5,7 +5,7 @@ [Description] VARCHAR(256) NOT NULL, [ProductImageUris] VARCHAR(MAX) NOT NULL, [ValidSkus] VARCHAR(MAX) NOT NULL, - [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [CreatedTimestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME() ) GO diff --git a/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.csproj b/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.csproj new file mode 100644 index 0000000..fc8c917 --- /dev/null +++ b/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + false + Sparcpoint.Inventory.Database + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.sqlproj b/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.sqlproj index 579e7f6..22d7149 100644 --- a/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.sqlproj +++ b/Development Project/Sparcpoint.Inventory.Database/Sparcpoint.Inventory.Database.sqlproj @@ -109,6 +109,8 @@ + + @@ -116,5 +118,9 @@ + + + + \ No newline at end of file diff --git a/Development Project/Sparcpoint.Inventory.Database/usp_Product_SetCategories.sql b/Development Project/Sparcpoint.Inventory.Database/usp_Product_SetCategories.sql new file mode 100644 index 0000000..e69de29