diff --git a/AzureSearchEmulator.UnitTests/AzureSearchEmulator.UnitTests.csproj b/AzureSearchEmulator.UnitTests/AzureSearchEmulator.UnitTests.csproj new file mode 100644 index 0000000..0bb5df2 --- /dev/null +++ b/AzureSearchEmulator.UnitTests/AzureSearchEmulator.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + nullable + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/AzureSearchEmulator.UnitTests/LuceneNetIndexSearcherTests.cs b/AzureSearchEmulator.UnitTests/LuceneNetIndexSearcherTests.cs new file mode 100644 index 0000000..240c670 --- /dev/null +++ b/AzureSearchEmulator.UnitTests/LuceneNetIndexSearcherTests.cs @@ -0,0 +1,930 @@ +using AzureSearchEmulator.Models; +using AzureSearchEmulator.SearchData; +using AzureSearchEmulator.Searching; +using Lucene.Net.Index; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Unit tests for LuceneNetIndexSearcher, verifying end-to-end search behavior +/// including $search, $filter, $orderby, paging, and their combinations. +/// Uses a stub ILuceneIndexReaderFactory backed by an in-memory Lucene index. +/// +public class LuceneNetIndexSearcherTests : IDisposable +{ + private readonly LuceneTestHelper _helper; + private readonly LuceneNetIndexSearcher _searcherService; + + public LuceneNetIndexSearcherTests() + { + var index = LuceneTestHelper.CreateProductIndex(); + _helper = new LuceneTestHelper(index, LuceneTestHelper.CreateProductDocuments()); + + var readerFactory = new StubIndexReaderFactory(_helper.Directory); + _searcherService = new LuceneNetIndexSearcher(readerFactory); + } + + public void Dispose() + { + _helper.Dispose(); + } + + // ===== Basic search ($search) ===== + + [Fact] + public async Task Search_MatchAll_ReturnsAllDocuments() + { + var request = new SearchRequest { Search = "*", Top = 50 }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + } + + [Fact] + public async Task Search_NullSearch_ReturnsAllDocuments() + { + var request = new SearchRequest { Search = null, Top = 50 }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + } + + [Fact] + public async Task Search_SimpleTextQuery_ReturnsMatchingDocuments() + { + var request = new SearchRequest { Search = "laptop", Top = 50 }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + Assert.True(response.Results.Count >= 2); // Both laptops + } + + [Fact] + public async Task Search_NoMatch_ReturnsEmptyResults() + { + var request = new SearchRequest { Search = "xyznonexistent", Top = 50 }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Empty(response.Results); + } + + [Fact] + public async Task Search_WithSearchFields_RestrictsToSpecifiedFields() + { + var request = new SearchRequest + { + Search = "laptop", + SearchFields = "Name", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + Assert.All(response.Results, r => + { + var name = r["Name"]?.GetValue() ?? ""; + Assert.Contains("Laptop", name, StringComparison.OrdinalIgnoreCase); + }); + } + + // ===== Filter only ($filter) ===== + + [Fact] + public async Task Search_FilterOnly_BooleanField_ReturnsFilteredDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "InStock eq true", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(4, response.Results.Count); + Assert.All(response.Results, r => + { + var inStock = r["InStock"]?.GetValue() ?? false; + Assert.True(inStock); + }); + } + + [Fact] + public async Task Search_FilterOnly_StringField_ReturnsFilteredDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "Category eq 'Accessories'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + Assert.All(response.Results, r => + { + var category = r["Category"]?.GetValue() ?? ""; + Assert.Equal("Accessories", category); + }); + } + + [Fact] + public async Task Search_FilterOnly_IntegerRange_ReturnsFilteredDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "Rating lt 4", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + Assert.Equal("Monitor 4K", response.Results[0]["Name"]?.GetValue()); + } + + [Fact] + public async Task Search_Filter_NotEqual_ExcludesMatchingDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "Category ne 'Electronics'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + Assert.All(response.Results, r => + { + var category = r["Category"]?.GetValue() ?? ""; + Assert.NotEqual("Electronics", category); + }); + } + + [Fact] + public async Task Search_Filter_NotOperatorWithParens() + { + var request = new SearchRequest + { + Search = "*", + Filter = "not (InStock eq true)", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + Assert.Equal("Mechanical Keyboard", response.Results[0]["Name"]?.GetValue()); + } + + [Fact] + public async Task Search_Filter_NotOperatorWithoutParens() + { + var request = new SearchRequest + { + Search = "*", + Filter = "not InStock eq true", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + Assert.Equal("Mechanical Keyboard", response.Results[0]["Name"]?.GetValue()); + } + + // ===== Search + Filter combined ===== + + [Fact] + public async Task Search_TextQueryWithFilter_ReturnsBothConstraintsMet() + { + var request = new SearchRequest + { + Search = "laptop", + Filter = "Rating gt 4", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + Assert.All(response.Results, r => + { + var rating = r["Rating"]?.GetValue() ?? 0; + Assert.True(rating > 4); + }); + } + + [Fact] + public async Task Search_TextQueryWithFilter_NarrowsResults() + { + // Search for "laptop" (should get 2+), filter by Rating lt 5 (should narrow to Budget Laptop) + var request = new SearchRequest + { + Search = "laptop", + Filter = "Rating lt 5", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + Assert.All(response.Results, r => + { + var rating = r["Rating"]?.GetValue() ?? 0; + Assert.True(rating < 5); + }); + } + + // ===== Filter with search.ismatch ===== + + [Fact] + public async Task Search_FilterSearchIsMatch_ReturnsMatchingDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "search.ismatch('laptop')", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + } + + [Fact] + public async Task Search_FilterSearchIsMatch_WithBooleanFilter_ReturnsIntersection() + { + var request = new SearchRequest + { + Search = "*", + Filter = "search.ismatch('laptop') and InStock eq true", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + Assert.All(response.Results, r => + { + var inStock = r["InStock"]?.GetValue() ?? false; + Assert.True(inStock); + }); + } + + [Fact] + public async Task Search_FilterSearchIsMatchScoring_ReturnsMatchingDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "search.ismatchscoring('monitor')", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + } + + // ===== search.in function ===== + + [Fact] + public async Task Search_FilterSearchIn_ReturnsMatchingDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "search.in(Category, 'Accessories')", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + } + + // ===== AND / OR filter combinations ===== + + [Fact] + public async Task Search_FilterAnd_ReturnsIntersection() + { + var request = new SearchRequest + { + Search = "*", + Filter = "Category eq 'Accessories' and InStock eq true", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + Assert.Equal("Gaming Mouse", response.Results[0]["Name"]?.GetValue()); + } + + [Fact] + public async Task Search_FilterOr_ReturnsUnion() + { + var request = new SearchRequest + { + Search = "*", + Filter = "Category eq 'Accessories' or Rating gt 4", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + // Accessories: Gaming Mouse (3), Mechanical Keyboard (4) + // Rating > 4: Laptop Pro (1), Mechanical Keyboard (4) + // Union: 1, 3, 4 + Assert.Equal(3, response.Results.Count); + } + + // ===== Sorting ($orderby) ===== + + [Fact] + public async Task Search_OrderByPriceAsc_ReturnsSortedResults() + { + var request = new SearchRequest + { + Search = "*", + Orderby = "Price asc", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + double? prev = null; + foreach (var result in response.Results) + { + var price = result["Price"]?.GetValue() ?? 0; + if (prev.HasValue) + { + Assert.True(price >= prev, $"Expected {price} >= {prev}"); + } + prev = price; + } + } + + [Fact] + public async Task Search_OrderByPriceDesc_ReturnsSortedResults() + { + var request = new SearchRequest + { + Search = "*", + Orderby = "Price desc", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + double? prev = null; + foreach (var result in response.Results) + { + var price = result["Price"]?.GetValue() ?? 0; + if (prev.HasValue) + { + Assert.True(price <= prev, $"Expected {price} <= {prev}"); + } + prev = price; + } + } + + [Fact] + public async Task Search_OrderByRatingAsc_ReturnsSortedResults() + { + var request = new SearchRequest + { + Search = "*", + Orderby = "Rating asc", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + int? prev = null; + foreach (var result in response.Results) + { + var rating = result["Rating"]?.GetValue() ?? 0; + if (prev.HasValue) + { + Assert.True(rating >= prev, $"Expected {rating} >= {prev}"); + } + prev = rating; + } + } + + // ===== Paging (skip/top) ===== + + [Fact] + public async Task Search_Paging_FirstPage_ReturnsCorrectCount() + { + var request = new SearchRequest + { + Search = "*", + Top = 2, + Skip = 0 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + } + + [Fact] + public async Task Search_Paging_SecondPage_ReturnsCorrectCount() + { + var request = new SearchRequest + { + Search = "*", + Top = 2, + Skip = 2 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + } + + [Fact] + public async Task Search_Paging_LastPage_ReturnsRemainder() + { + var request = new SearchRequest + { + Search = "*", + Top = 2, + Skip = 4 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + } + + [Fact] + public async Task Search_Paging_BeyondEnd_ReturnsEmpty() + { + var request = new SearchRequest + { + Search = "*", + Top = 10, + Skip = 100 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Empty(response.Results); + } + + [Fact] + public async Task Search_Paging_PagesDoNotOverlap() + { + var page1Request = new SearchRequest { Search = "*", Orderby = "Price asc", Top = 2, Skip = 0 }; + var page2Request = new SearchRequest { Search = "*", Orderby = "Price asc", Top = 2, Skip = 2 }; + + var page1 = await _searcherService.Search(_helper.Index, page1Request); + var page2 = await _searcherService.Search(_helper.Index, page2Request); + + var page1Ids = page1.Results.Select(r => r["Id"]?.GetValue()).ToHashSet(); + var page2Ids = page2.Results.Select(r => r["Id"]?.GetValue()).ToHashSet(); + + Assert.Empty(page1Ids.Intersect(page2Ids)); + } + + // ===== Count ===== + + [Fact] + public async Task Search_WithCount_ReturnsTotal() + { + var request = new SearchRequest + { + Search = "*", + Count = true, + Top = 2 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Count); + Assert.Equal(2, response.Results.Count); + } + + [Fact] + public async Task Search_WithoutCount_CountIsNotSet() + { + var request = new SearchRequest + { + Search = "*", + Count = false, + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Null(response.Count); + } + + [Fact] + public async Task Search_WithCountAndFilter_ReturnsFilteredTotal() + { + var request = new SearchRequest + { + Search = "*", + Filter = "Category eq 'Electronics'", + Count = true, + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(3, response.Count); + Assert.Equal(3, response.Results.Count); + } + + // ===== GetDocCount ===== + + [Fact] + public async Task GetDocCount_ReturnsCorrectCount() + { + var count = await _searcherService.GetDocCount(_helper.Index); + Assert.Equal(5, count); + } + + // ===== GetDoc ===== + + [Fact] + public async Task GetDoc_ExistingDocument_ReturnsDocument() + { + var doc = await _searcherService.GetDoc(_helper.Index, "1"); + + Assert.NotNull(doc); + Assert.Equal("Laptop Pro 15", doc["Name"]?.GetValue()); + } + + [Fact] + public async Task GetDoc_NonExistingDocument_ReturnsNull() + { + var doc = await _searcherService.GetDoc(_helper.Index, "999"); + Assert.Null(doc); + } + + // ===== SearchMode ===== + + [Fact] + public async Task Search_SearchModeAny_MatchesAnyTerm() + { + var request = new SearchRequest + { + Search = "laptop gaming", + SearchMode = "any", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + // "any" mode: should match docs containing "laptop" OR "gaming" + Assert.True(response.Results.Count >= 2); + } + + [Fact] + public async Task Search_SearchModeAll_RequiresAllTerms() + { + var request = new SearchRequest + { + Search = "laptop gaming", + SearchMode = "all", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + // "all" mode: requires both "laptop" AND "gaming" - unlikely to match + Assert.True(response.Results.Count < 5); + } + + // ===== No searchable fields ===== + + [Fact] + public async Task Search_NoSearchableFields_Throws() + { + var index = new SearchIndex + { + Name = "no-searchable", + Fields = [new SearchField { Name = "Id", Type = "Edm.String", Key = true, Searchable = false }] + }; + + var readerFactory = new StubIndexReaderFactory(_helper.Directory); + var searcher = new LuceneNetIndexSearcher(readerFactory); + var request = new SearchRequest { Search = "test", Top = 50 }; + + await Assert.ThrowsAsync(() => searcher.Search(index, request)); + } + + // ===== Null filter ===== + + [Fact] + public async Task Search_NullFilter_ReturnsAllDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = null, + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + } + + [Fact] + public async Task Search_EmptyFilter_ReturnsAllDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = "", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + } + + // ===== Search score ===== + + [Fact] + public async Task Search_ReturnsSearchScore() + { + var request = new SearchRequest { Search = "laptop", Top = 50 }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.NotEmpty(response.Results); + Assert.All(response.Results, r => + { + Assert.True(r.ContainsKey("@search.score")); + var score = r["@search.score"]?.GetValue() ?? 0; + Assert.True(score > 0); + }); + } + + // ===== Wildcard search ===== + + [Fact] + public async Task Search_WildcardStar_ReturnsAllDocuments() + { + var request = new SearchRequest { Search = "*:*", Top = 50 }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(5, response.Results.Count); + } + + /// + /// Stub implementation of ILuceneIndexReaderFactory backed by a RAMDirectory. + /// + private class StubIndexReaderFactory(Lucene.Net.Store.Directory directory) : ILuceneIndexReaderFactory + { + public IndexReader GetIndexReader(string indexName) + { + return DirectoryReader.Open(directory); + } + + public IndexReader RefreshReader(string indexName) => GetIndexReader(indexName); + + public void ClearCachedReader(string indexName) { } + } +} + +/// +/// Tests for filtering with GUID string values through the full LuceneNetIndexSearcher pipeline. +/// Verifies that $filter with string equality on filterable (non-searchable) fields +/// containing GUIDs works correctly, both standalone and combined with $search. +/// +public class LuceneNetIndexSearcher_GuidFilterTests : IDisposable +{ + private readonly LuceneTestHelper _helper; + private readonly LuceneNetIndexSearcher _searcherService; + + private const string Guid1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string Guid2 = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string Guid3 = "c3d4e5f6-a7b8-9012-cdef-123456789012"; + + public LuceneNetIndexSearcher_GuidFilterTests() + { + var index = CreateIndex(); + _helper = new LuceneTestHelper(index, CreateDocuments()); + var readerFactory = new StubIndexReaderFactory(_helper.Directory); + _searcherService = new LuceneNetIndexSearcher(readerFactory); + } + + public void Dispose() + { + _helper.Dispose(); + } + + private static SearchIndex CreateIndex() + { + return new SearchIndex + { + Name = "items", + Fields = + [ + new SearchField { Name = "Id", Type = "Edm.String", Key = true, Searchable = false, Filterable = true }, + new SearchField { Name = "ProductId", Type = "Edm.String", Searchable = false, Filterable = true }, + new SearchField { Name = "Type", Type = "Edm.String", Searchable = false, Filterable = true }, + new SearchField { Name = "Name", Type = "Edm.String", Searchable = true }, + new SearchField { Name = "Description", Type = "Edm.String", Searchable = true }, + ] + }; + } + + private static List CreateDocuments() + { + return + [ + CreateDoc("1", Guid1, "Product", "Widget Alpha", "A premium widget for all occasions"), + CreateDoc("2", Guid2, "Product", "Widget Beta", "An affordable widget for everyday use"), + CreateDoc("3", Guid3, "Variant", "Widget Gamma", "A variant of the gamma widget line"), + CreateDoc("4", Guid1, "Variant", "Widget Delta", "A delta variant of the alpha product"), + CreateDoc("5", Guid2, "Accessory", "Widget Cable", "USB cable for connecting widgets"), + ]; + } + + private static Lucene.Net.Documents.Document CreateDoc(string id, string productId, string type, string name, string description) + { + return new Lucene.Net.Documents.Document + { + new Lucene.Net.Documents.StringField("Id", id, Lucene.Net.Documents.Field.Store.YES), + new Lucene.Net.Documents.StringField("ProductId", productId, Lucene.Net.Documents.Field.Store.YES), + new Lucene.Net.Documents.StringField("Type", type, Lucene.Net.Documents.Field.Store.YES), + new Lucene.Net.Documents.TextField("Name", name, Lucene.Net.Documents.Field.Store.YES), + new Lucene.Net.Documents.TextField("Description", description, Lucene.Net.Documents.Field.Store.YES), + }; + } + + // ===== Filter-only tests (Search = "*") ===== + + [Fact] + public async Task Filter_GuidEquality_ReturnsMatchingDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = $"ProductId eq '{Guid1}'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + var ids = response.Results.Select(r => r["Id"]?.GetValue()).OrderBy(id => id).ToList(); + Assert.Equal("1", ids[0]); + Assert.Equal("4", ids[1]); + } + + [Fact] + public async Task Filter_GuidEquality_NoMatch_ReturnsEmpty() + { + var request = new SearchRequest + { + Search = "*", + Filter = "ProductId eq '00000000-0000-0000-0000-000000000000'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Empty(response.Results); + } + + [Fact] + public async Task Filter_CompoundOrWithGuid_ReturnsCorrectResults() + { + // The exact pattern reported: ProductId eq '{id}' or (Type eq 'Product' and Id eq '{id}') + var request = new SearchRequest + { + Search = "*", + Filter = $"ProductId eq '{Guid1}' or (Type eq 'Product' and Id eq '2')", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + var ids = response.Results.Select(r => r["Id"]?.GetValue()).OrderBy(id => id).ToList(); + Assert.Equal(3, ids.Count); + Assert.Equal("1", ids[0]); // ProductId matches Guid1 + Assert.Equal("2", ids[1]); // Type=Product AND Id=2 + Assert.Equal("4", ids[2]); // ProductId matches Guid1 + } + + [Fact] + public async Task Filter_GuidEqualityAndType_ReturnsIntersection() + { + var request = new SearchRequest + { + Search = "*", + Filter = $"ProductId eq '{Guid1}' and Type eq 'Variant'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + Assert.Equal("4", response.Results[0]["Id"]?.GetValue()); + } + + [Fact] + public async Task Filter_GuidNotEqual_ExcludesMatchingDocuments() + { + var request = new SearchRequest + { + Search = "*", + Filter = $"ProductId ne '{Guid1}'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(3, response.Results.Count); + var ids = response.Results.Select(r => r["Id"]?.GetValue()).OrderBy(id => id).ToList(); + Assert.Equal("2", ids[0]); + Assert.Equal("3", ids[1]); + Assert.Equal("5", ids[2]); + } + + // ===== Search + Filter combined ===== + + [Fact] + public async Task Search_TextWithGuidFilter_ReturnsIntersection() + { + // Search for "widget" (all docs match) but filter to only Guid1 products + var request = new SearchRequest + { + Search = "widget", + Filter = $"ProductId eq '{Guid1}'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Results.Count); + var ids = response.Results.Select(r => r["Id"]?.GetValue()).OrderBy(id => id).ToList(); + Assert.Equal("1", ids[0]); + Assert.Equal("4", ids[1]); + } + + [Fact] + public async Task Search_TextWithGuidFilter_NarrowsSearchResults() + { + // Search for "cable" (matches doc 5) but filter to Guid1 (docs 1,4) — no overlap + var request = new SearchRequest + { + Search = "cable", + Filter = $"ProductId eq '{Guid1}'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Empty(response.Results); + } + + [Fact] + public async Task Search_TextWithCompoundGuidOrFilter_ReturnsCorrectResults() + { + // Search for "variant" combined with the compound OR filter pattern + var request = new SearchRequest + { + Search = "variant", + Filter = $"ProductId eq '{Guid1}' or (Type eq 'Product' and Id eq '2')", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + // "variant" appears in docs 3 and 4 (Name/Description) + // Filter allows docs 1, 2, 4 + // Intersection: doc 4 + var ids = response.Results.Select(r => r["Id"]?.GetValue()).OrderBy(id => id).ToList(); + Assert.Contains("4", ids); + } + + [Fact] + public async Task Filter_GuidEquality_WithCount_ReturnsCorrectCount() + { + var request = new SearchRequest + { + Search = "*", + Filter = $"ProductId eq '{Guid2}'", + Count = true, + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Equal(2, response.Count); + Assert.Equal(2, response.Results.Count); + var ids = response.Results.Select(r => r["Id"]?.GetValue()).OrderBy(id => id).ToList(); + Assert.Equal("2", ids[0]); + Assert.Equal("5", ids[1]); + } + + [Fact] + public async Task Filter_GuidEquality_ReturnsCorrectFieldValues() + { + var request = new SearchRequest + { + Search = "*", + Filter = $"ProductId eq '{Guid3}'", + Top = 50 + }; + var response = await _searcherService.Search(_helper.Index, request); + + Assert.Single(response.Results); + var result = response.Results[0]; + Assert.Equal("3", result["Id"]?.GetValue()); + Assert.Equal(Guid3, result["ProductId"]?.GetValue()); + Assert.Equal("Variant", result["Type"]?.GetValue()); + Assert.Equal("Widget Gamma", result["Name"]?.GetValue()); + } + + private class StubIndexReaderFactory(Lucene.Net.Store.Directory directory) : ILuceneIndexReaderFactory + { + public IndexReader GetIndexReader(string indexName) => DirectoryReader.Open(directory); + public IndexReader RefreshReader(string indexName) => GetIndexReader(indexName); + public void ClearCachedReader(string indexName) { } + } +} diff --git a/AzureSearchEmulator.UnitTests/LuceneTestHelper.cs b/AzureSearchEmulator.UnitTests/LuceneTestHelper.cs new file mode 100644 index 0000000..eba9d77 --- /dev/null +++ b/AzureSearchEmulator.UnitTests/LuceneTestHelper.cs @@ -0,0 +1,98 @@ +using AzureSearchEmulator.Models; +using AzureSearchEmulator.SearchData; +using Lucene.Net.Documents; +using Lucene.Net.Index; +using Lucene.Net.Search; +using Lucene.Net.Store; +using Lucene.Net.Util; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Provides helpers for creating in-memory Lucene indexes for unit testing. +/// +public sealed class LuceneTestHelper : IDisposable +{ + private const LuceneVersion Version = LuceneVersion.LUCENE_48; + + public RAMDirectory Directory { get; } + public SearchIndex Index { get; } + + public LuceneTestHelper(SearchIndex index, IEnumerable documents) + { + Index = index; + Directory = new RAMDirectory(); + + var analyzer = AnalyzerHelper.GetPerFieldIndexAnalyzer(index.Fields); + var config = new IndexWriterConfig(Version, analyzer); + + using var writer = new IndexWriter(Directory, config); + foreach (var doc in documents) + { + writer.AddDocument(doc); + } + writer.Commit(); + } + + public IndexSearcher CreateSearcher() + { + var reader = DirectoryReader.Open(Directory); + return new IndexSearcher(reader); + } + + public void Dispose() + { + Directory.Dispose(); + } + + /// + /// Creates a standard product index definition used across tests. + /// + public static SearchIndex CreateProductIndex() + { + return new SearchIndex + { + Name = "products", + Fields = + [ + new SearchField { Name = "Id", Type = "Edm.String", Key = true, Searchable = true }, + new SearchField { Name = "Name", Type = "Edm.String", Searchable = true }, + new SearchField { Name = "Description", Type = "Edm.String", Searchable = true }, + new SearchField { Name = "Price", Type = "Edm.Double", Searchable = false, Filterable = true, Sortable = true }, + new SearchField { Name = "Category", Type = "Edm.String", Searchable = false, Filterable = true }, + new SearchField { Name = "InStock", Type = "Edm.Boolean", Searchable = false, Filterable = true }, + new SearchField { Name = "Rating", Type = "Edm.Int32", Searchable = false, Filterable = true, Sortable = true }, + ] + }; + } + + /// + /// Creates standard product documents for testing. + /// + public static List CreateProductDocuments() + { + return + [ + CreateProductDoc("1", "Laptop Pro 15", "High-performance laptop with 16GB RAM and 512GB SSD", 1299.99, "Electronics", true, 5), + CreateProductDoc("2", "Laptop Budget 13", "Affordable laptop perfect for students and everyday use", 499.99, "Electronics", true, 4), + CreateProductDoc("3", "Gaming Mouse", "Precision gaming mouse with 16000 DPI sensor", 59.99, "Accessories", true, 4), + CreateProductDoc("4", "Mechanical Keyboard", "Mechanical keyboard with Cherry MX switches and RGB lighting", 149.99, "Accessories", false, 5), + CreateProductDoc("5", "Monitor 4K", "27-inch 4K monitor with 60Hz refresh rate", 599.99, "Electronics", true, 3), + ]; + } + + private static Document CreateProductDoc(string id, string name, string description, double price, string category, bool inStock, int rating) + { + var doc = new Document + { + new StringField("Id", id, Field.Store.YES), + new TextField("Name", name, Field.Store.YES), + new TextField("Description", description, Field.Store.YES), + new DoubleField("Price", price, Field.Store.YES), + new StringField("Category", category, Field.Store.YES), + new Int32Field("InStock", inStock ? 1 : 0, Field.Store.YES), + new Int32Field("Rating", rating, Field.Store.YES), + }; + return doc; + } +} diff --git a/AzureSearchEmulator.UnitTests/ODataQueryVisitorTests.cs b/AzureSearchEmulator.UnitTests/ODataQueryVisitorTests.cs new file mode 100644 index 0000000..ad290c2 --- /dev/null +++ b/AzureSearchEmulator.UnitTests/ODataQueryVisitorTests.cs @@ -0,0 +1,699 @@ +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Searching; +using Lucene.Net.Documents; +using Lucene.Net.Search; +using Microsoft.OData.UriParser; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Unit tests for ODataQueryVisitor, verifying that OData filter expressions +/// are correctly translated to Lucene queries and produce correct search results. +/// +public class ODataQueryVisitorTests : IDisposable +{ + private readonly LuceneTestHelper _helper; + private readonly IndexSearcher _searcher; + + public ODataQueryVisitorTests() + { + var index = LuceneTestHelper.CreateProductIndex(); + _helper = new LuceneTestHelper(index, LuceneTestHelper.CreateProductDocuments()); + _searcher = _helper.CreateSearcher(); + } + + public void Dispose() + { + _helper.Dispose(); + } + + private Query ParseFilter(string filter) + { + var parser = new UriQueryExpressionParser(100); + var filterToken = parser.ParseFilter(filter); + return filterToken.Accept(new ODataQueryVisitor(_helper.Index)); + } + + private List SearchWithFilter(string filter) + { + var query = ParseFilter(filter); + var docs = _searcher.Search(query, 100); + return docs.ScoreDocs + .Select(sd => _searcher.Doc(sd.Doc).Get("Id")) + .OrderBy(id => id) + .ToList(); + } + + // ===== Equality (eq) ===== + + [Fact] + public void Filter_StringEquality_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter("Category eq 'Electronics'"); + + Assert.Equal(3, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + Assert.Contains("5", ids); + } + + [Fact] + public void Filter_StringEquality_NoMatch_ReturnsEmpty() + { + var ids = SearchWithFilter("Category eq 'Clothing'"); + Assert.Empty(ids); + } + + [Fact] + public void Filter_BooleanEqualityTrue_ReturnsInStockItems() + { + var ids = SearchWithFilter("InStock eq true"); + + Assert.Equal(4, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + Assert.Contains("3", ids); + Assert.Contains("5", ids); + Assert.DoesNotContain("4", ids); + } + + [Fact] + public void Filter_BooleanEqualityFalse_ReturnsOutOfStockItems() + { + var ids = SearchWithFilter("InStock eq false"); + + Assert.Single(ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_IntegerEquality_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter("Rating eq 5"); + + Assert.Equal(2, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("4", ids); + } + + // ===== Not Equal (ne) ===== + + [Fact] + public void Filter_StringNotEqual_ExcludesMatchingDocuments() + { + var ids = SearchWithFilter("Category ne 'Electronics'"); + + Assert.Equal(2, ids.Count); + Assert.Contains("3", ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_BooleanNotEqual_ReturnsOpposite() + { + var ids = SearchWithFilter("InStock ne true"); + + Assert.Single(ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_IntegerNotEqual_ExcludesMatchingDocuments() + { + var ids = SearchWithFilter("Rating ne 5"); + + Assert.Equal(3, ids.Count); + Assert.Contains("2", ids); // rating 4 + Assert.Contains("3", ids); // rating 4 + Assert.Contains("5", ids); // rating 3 + } + + // ===== Less Than (lt) ===== + + [Fact] + public void Filter_IntegerLessThan_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter("Rating lt 4"); + + Assert.Single(ids); + Assert.Contains("5", ids); // Monitor 4K with rating 3 + } + + [Fact] + public void Filter_IntegerLessThan_ExcludesBoundary() + { + // Rating lt 3 should NOT include the item with rating exactly 3 + var ids = SearchWithFilter("Rating lt 3"); + Assert.Empty(ids); + } + + // ===== Less Than or Equal (le) ===== + + [Fact] + public void Filter_IntegerLessThanOrEqual_IncludesBoundary() + { + var ids = SearchWithFilter("Rating le 3"); + + Assert.Single(ids); + Assert.Contains("5", ids); + } + + [Fact] + public void Filter_IntegerLessThanOrEqual_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter("Rating le 4"); + + Assert.Equal(3, ids.Count); + Assert.Contains("2", ids); + Assert.Contains("3", ids); + Assert.Contains("5", ids); + } + + // ===== Greater Than (gt) ===== + + [Fact] + public void Filter_IntegerGreaterThan_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter("Rating gt 4"); + + Assert.Equal(2, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_IntegerGreaterThan_ExcludesBoundary() + { + var ids = SearchWithFilter("Rating gt 5"); + Assert.Empty(ids); + } + + // ===== Greater Than or Equal (ge) ===== + + [Fact] + public void Filter_IntegerGreaterThanOrEqual_IncludesBoundary() + { + var ids = SearchWithFilter("Rating ge 5"); + + Assert.Equal(2, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_IntegerGreaterThanOrEqual_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter("Rating ge 4"); + + Assert.Equal(4, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + Assert.Contains("3", ids); + Assert.Contains("4", ids); + } + + // ===== Boolean Operators (and, or) ===== + + [Fact] + public void Filter_And_ReturnsBothConditionsMatched() + { + var ids = SearchWithFilter("Category eq 'Electronics' and InStock eq true"); + + Assert.Equal(3, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + Assert.Contains("5", ids); + } + + [Fact] + public void Filter_Or_ReturnsEitherConditionMatched() + { + var ids = SearchWithFilter("Category eq 'Accessories' or Rating gt 4"); + + Assert.Equal(3, ids.Count); + Assert.Contains("1", ids); // rating 5 + Assert.Contains("3", ids); // Accessories + Assert.Contains("4", ids); // Accessories and rating 5 + } + + [Fact] + public void Filter_And_WithDifferentFields_ReturnsIntersection() + { + var ids = SearchWithFilter("Category eq 'Accessories' and InStock eq true"); + + Assert.Single(ids); + Assert.Contains("3", ids); // Gaming Mouse is the only in-stock Accessory + } + + [Fact] + public void Filter_And_NoOverlap_ReturnsEmpty() + { + var ids = SearchWithFilter("Category eq 'Electronics' and Category eq 'Accessories'"); + Assert.Empty(ids); + } + + // ===== NOT operator ===== + + [Fact] + public void Filter_Not_NegatesBooleanEquality() + { + // OData requires parentheses: "not (expr)" to negate a binary expression + var ids = SearchWithFilter("not (InStock eq true)"); + + Assert.Single(ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_Not_NegatesStringEquality() + { + var ids = SearchWithFilter("not (Category eq 'Electronics')"); + + Assert.Equal(2, ids.Count); + Assert.Contains("3", ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_Not_NegatesSearchIsMatch() + { + var ids = SearchWithFilter("not search.ismatch('keyboard')"); + + Assert.NotEmpty(ids); + Assert.DoesNotContain("4", ids); // Mechanical Keyboard should be excluded + } + + // ===== IN operator ===== + + [Fact] + public void Filter_In_MatchesMultipleValues() + { + var ids = SearchWithFilter("Category in ('Electronics', 'Accessories')"); + + Assert.Equal(5, ids.Count); // All products match one of these categories + } + + [Fact] + public void Filter_In_MatchesSingleValue() + { + var ids = SearchWithFilter("Category in ('Accessories')"); + + Assert.Equal(2, ids.Count); + Assert.Contains("3", ids); + Assert.Contains("4", ids); + } + + // ===== search.in function ===== + + [Fact] + public void Filter_SearchIn_MatchesMultipleValues() + { + var ids = SearchWithFilter("search.in(Category, 'Electronics,Accessories')"); + + Assert.Equal(5, ids.Count); + } + + [Fact] + public void Filter_SearchIn_MatchesSingleValue() + { + var ids = SearchWithFilter("search.in(Category, 'Electronics')"); + + Assert.Equal(3, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + Assert.Contains("5", ids); + } + + [Fact] + public void Filter_SearchIn_WithCustomDelimiter() + { + var ids = SearchWithFilter("search.in(Category, 'Electronics|Accessories', '|')"); + + Assert.Equal(5, ids.Count); + } + + // ===== search.ismatch function ===== + + [Fact] + public void Filter_SearchIsMatch_BasicSearch_FindsMatchingDocuments() + { + var ids = SearchWithFilter("search.ismatch('laptop')"); + + Assert.NotEmpty(ids); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + } + + [Fact] + public void Filter_SearchIsMatch_WithFieldRestriction_SearchesOnlySpecifiedField() + { + var ids = SearchWithFilter("search.ismatch('laptop', 'Name')"); + + Assert.NotEmpty(ids); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + } + + [Fact] + public void Filter_SearchIsMatch_WithMultipleFields() + { + var ids = SearchWithFilter("search.ismatch('gaming', 'Name,Description')"); + + Assert.NotEmpty(ids); + Assert.Contains("3", ids); // Gaming Mouse + } + + [Fact] + public void Filter_SearchIsMatch_CombinedWithAnd() + { + var ids = SearchWithFilter("search.ismatch('laptop') and InStock eq true"); + + Assert.NotEmpty(ids); + Assert.DoesNotContain("4", ids); + } + + [Fact] + public void Filter_SearchIsMatch_NoResults() + { + var ids = SearchWithFilter("search.ismatch('nonexistentproduct')"); + Assert.Empty(ids); + } + + // ===== search.ismatchscoring function ===== + + [Fact] + public void Filter_SearchIsMatchScoring_FindsMatchingDocuments() + { + var ids = SearchWithFilter("search.ismatchscoring('laptop')"); + + Assert.NotEmpty(ids); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + } + + [Fact] + public void Filter_SearchIsMatchScoring_WithFieldRestriction() + { + var ids = SearchWithFilter("search.ismatchscoring('monitor', 'Name')"); + + Assert.NotEmpty(ids); + Assert.Contains("5", ids); + } + + // ===== Complex / combined filters ===== + + [Fact] + public void Filter_ComplexAndOr_ReturnsCorrectResults() + { + // Electronics that are in stock OR Accessories with rating 5 + var ids = SearchWithFilter("(Category eq 'Electronics' and InStock eq true) or (Category eq 'Accessories' and Rating eq 5)"); + + Assert.Equal(4, ids.Count); + Assert.Contains("1", ids); // Electronics, in stock + Assert.Contains("2", ids); // Electronics, in stock + Assert.Contains("4", ids); // Accessories, rating 5 + Assert.Contains("5", ids); // Electronics, in stock + } + + [Fact] + public void Filter_RangeQuery_BetweenValues() + { + // Rating between 4 and 5 (inclusive) + var ids = SearchWithFilter("Rating ge 4 and Rating le 5"); + + Assert.Equal(4, ids.Count); + Assert.Contains("1", ids); // rating 5 + Assert.Contains("2", ids); // rating 4 + Assert.Contains("3", ids); // rating 4 + Assert.Contains("4", ids); // rating 5 + } + + [Fact] + public void Filter_SearchIsMatch_WithBooleanAndComparison() + { + // Full-text search for "laptop" combined with integer filter + var ids = SearchWithFilter("search.ismatch('laptop') and Rating lt 5"); + + Assert.NotEmpty(ids); + Assert.Contains("2", ids); // Budget Laptop with rating 4 + Assert.DoesNotContain("1", ids); // Laptop Pro has rating 5 + } + + [Fact] + public void Filter_SearchIsMatch_OrWithEquality() + { + var ids = SearchWithFilter("search.ismatch('monitor') or Category eq 'Accessories'"); + + Assert.True(ids.Count >= 2); + Assert.Contains("5", ids); // Monitor + Assert.Contains("3", ids); // Accessories + Assert.Contains("4", ids); // Accessories + } + + // ===== Error handling ===== + + [Fact] + public void Filter_SearchIsMatch_RequiresIndex() + { + var parser = new UriQueryExpressionParser(100); + var filterToken = parser.ParseFilter("search.ismatch('test')"); + var visitor = new ODataQueryVisitor(); // No index provided + + Assert.Throws(() => filterToken.Accept(visitor)); + } + + [Fact] + public void Filter_SearchIn_TooFewArguments_Throws() + { + var parser = new UriQueryExpressionParser(100); + var filterToken = parser.ParseFilter("search.in(Category)"); + var visitor = new ODataQueryVisitor(_helper.Index); + + Assert.Throws(() => filterToken.Accept(visitor)); + } + + // ===== "not" without parentheses ===== + + [Fact] + public void Filter_NotWithoutParens_NegatesExpression() + { + // "not InStock eq true" is valid Azure Search syntax + var ids = SearchWithFilter("not InStock eq true"); + + Assert.Single(ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_NotWithoutParens_NegatesStringEquality() + { + var ids = SearchWithFilter("not Category eq 'Electronics'"); + + Assert.Equal(2, ids.Count); + Assert.Contains("3", ids); + Assert.Contains("4", ids); + } + + // ===== Double/float range comparisons ===== + + [Fact] + public void Filter_DoubleLessThan_ReturnsDocumentsBelowThreshold() + { + var ids = SearchWithFilter("Price lt 100.0"); + + Assert.Single(ids); + Assert.Contains("3", ids); // Gaming Mouse at 59.99 + } + + [Fact] + public void Filter_DoubleGreaterThan_ReturnsDocumentsAboveThreshold() + { + var ids = SearchWithFilter("Price gt 600.0"); + + Assert.Single(ids); + Assert.Contains("1", ids); // Laptop Pro 15 at 1299.99 + } + + [Fact] + public void Filter_DoubleRange_ReturnsDocumentsInRange() + { + var ids = SearchWithFilter("Price ge 100.0 and Price le 600.0"); + + Assert.Equal(3, ids.Count); + Assert.Contains("2", ids); // 499.99 + Assert.Contains("4", ids); // 149.99 + Assert.Contains("5", ids); // 599.99 + } + + [Fact] + public void Filter_DoubleLessThanOrEqual_IncludesBoundary() + { + var ids = SearchWithFilter("Price le 59.99"); + + Assert.Single(ids); + Assert.Contains("3", ids); + } + + [Fact] + public void Filter_DoubleGreaterThanOrEqual_IncludesBoundary() + { + var ids = SearchWithFilter("Price ge 599.99"); + + Assert.Equal(2, ids.Count); + Assert.Contains("1", ids); // 1299.99 + Assert.Contains("5", ids); // 599.99 + } + + [Fact] + public void Filter_DoubleEquality_PrecisionLimitation() + { + // OData parses "59.99" as System.Single (float). Promoting float to double + // introduces precision error: (double)59.99f = 59.9900016784668, not 59.99. + // This means exact equality on double fields with decimal literals won't match. + // Range queries (lt, le, gt, ge) work correctly since the precision error + // is negligible for range comparisons. + var ids = SearchWithFilter("Price eq 59.99"); + Assert.Empty(ids); + } +} + +/// +/// Tests for string equality filtering on fields containing GUIDs. +/// Fields like ProductId, Type, and Id are filterable but not searchable, +/// so they are indexed as StringField (exact, not analyzed). +/// +public class ODataQueryVisitor_GuidFilterTests : IDisposable +{ + private readonly LuceneTestHelper _helper; + private readonly IndexSearcher _searcher; + + private const string Guid1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string Guid2 = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string Guid3 = "c3d4e5f6-a7b8-9012-cdef-123456789012"; + + public ODataQueryVisitor_GuidFilterTests() + { + var index = CreateGuidIndex(); + _helper = new LuceneTestHelper(index, CreateGuidDocuments()); + _searcher = _helper.CreateSearcher(); + } + + public void Dispose() + { + _helper.Dispose(); + } + + private static SearchIndex CreateGuidIndex() + { + return new SearchIndex + { + Name = "items", + Fields = + [ + new SearchField { Name = "Id", Type = "Edm.String", Key = true, Searchable = false, Filterable = true }, + new SearchField { Name = "ProductId", Type = "Edm.String", Searchable = false, Filterable = true }, + new SearchField { Name = "Type", Type = "Edm.String", Searchable = false, Filterable = true }, + new SearchField { Name = "Name", Type = "Edm.String", Searchable = true }, + ] + }; + } + + private static List CreateGuidDocuments() + { + return + [ + CreateDoc("1", Guid1, "Product", "Widget A"), + CreateDoc("2", Guid2, "Product", "Widget B"), + CreateDoc("3", Guid3, "Variant", "Widget C"), + CreateDoc("4", Guid1, "Variant", "Widget D"), // Same ProductId as doc 1 + ]; + } + + private static Document CreateDoc(string id, string productId, string type, string name) + { + return new Document + { + new StringField("Id", id, Field.Store.YES), + new StringField("ProductId", productId, Field.Store.YES), + new StringField("Type", type, Field.Store.YES), + new TextField("Name", name, Field.Store.YES), + }; + } + + private Query ParseFilter(string filter) + { + var parser = new UriQueryExpressionParser(100); + var filterToken = parser.ParseFilter(filter); + return filterToken.Accept(new ODataQueryVisitor(_helper.Index)); + } + + private List SearchWithFilter(string filter) + { + var query = ParseFilter(filter); + var docs = _searcher.Search(query, 100); + return docs.ScoreDocs + .Select(sd => _searcher.Doc(sd.Doc).Get("Id")) + .OrderBy(id => id) + .ToList(); + } + + [Fact] + public void Filter_GuidEquality_ReturnsMatchingDocuments() + { + var ids = SearchWithFilter($"ProductId eq '{Guid1}'"); + + Assert.Equal(2, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("4", ids); + } + + [Fact] + public void Filter_GuidEquality_NoMatch_ReturnsEmpty() + { + var ids = SearchWithFilter("ProductId eq '00000000-0000-0000-0000-000000000000'"); + Assert.Empty(ids); + } + + [Fact] + public void Filter_GuidEquality_CompoundOrFilter() + { + // The exact pattern reported: ProductId eq '{id}' or (Type eq 'Product' and Id eq '{id}') + var filter = $"ProductId eq '{Guid1}' or (Type eq 'Product' and Id eq '2')"; + var ids = SearchWithFilter(filter); + + Assert.Equal(3, ids.Count); + Assert.Contains("1", ids); // ProductId matches Guid1 + Assert.Contains("2", ids); // Type=Product AND Id=2 + Assert.Contains("4", ids); // ProductId matches Guid1 + } + + [Fact] + public void Filter_StringEquality_OnFilterableField() + { + var ids = SearchWithFilter("Type eq 'Product'"); + + Assert.Equal(2, ids.Count); + Assert.Contains("1", ids); + Assert.Contains("2", ids); + } + + [Fact] + public void Filter_StringNotEqual_OnGuidField() + { + var ids = SearchWithFilter($"ProductId ne '{Guid1}'"); + + Assert.Equal(2, ids.Count); + Assert.Contains("2", ids); + Assert.Contains("3", ids); + } + + [Fact] + public void Filter_GuidEquality_AndWithType() + { + var ids = SearchWithFilter($"ProductId eq '{Guid1}' and Type eq 'Variant'"); + + Assert.Single(ids); + Assert.Contains("4", ids); + } +} diff --git a/AzureSearchEmulator.sln b/AzureSearchEmulator.sln index 6a384ee..4992911 100644 --- a/AzureSearchEmulator.sln +++ b/AzureSearchEmulator.sln @@ -15,36 +15,102 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.Aspire. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.Aspire.Tests", "AzureSearchEmulator.Aspire.Tests\AzureSearchEmulator.Aspire.Tests.csproj", "{EDF10963-768B-426B-B4E2-F2C1DBC2C47C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.UnitTests", "AzureSearchEmulator.UnitTests\AzureSearchEmulator.UnitTests.csproj", "{08EBF14C-8EC1-4CFB-9035-60E102CDF283}" +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 {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Debug|x64.Build.0 = Debug|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Debug|x86.Build.0 = Debug|Any CPU {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Release|Any CPU.Build.0 = Release|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Release|x64.ActiveCfg = Release|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Release|x64.Build.0 = Release|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Release|x86.ActiveCfg = Release|Any CPU + {1A2BDBB3-02F7-4B9D-9CE0-8E2C92772080}.Release|x86.Build.0 = Release|Any CPU {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Debug|x64.Build.0 = Debug|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Debug|x86.Build.0 = Debug|Any CPU {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Release|Any CPU.ActiveCfg = Release|Any CPU {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Release|Any CPU.Build.0 = Release|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Release|x64.ActiveCfg = Release|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Release|x64.Build.0 = Release|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Release|x86.ActiveCfg = Release|Any CPU + {6436C8DB-47E5-40DD-90FC-852AE57F4B4E}.Release|x86.Build.0 = Release|Any CPU {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|x64.ActiveCfg = Debug|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|x64.Build.0 = Debug|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|x86.ActiveCfg = Debug|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|x86.Build.0 = Debug|Any CPU {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|Any CPU.ActiveCfg = Release|Any CPU {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|Any CPU.Build.0 = Release|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|x64.ActiveCfg = Release|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|x64.Build.0 = Release|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|x86.ActiveCfg = Release|Any CPU + {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|x86.Build.0 = Release|Any CPU {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|x64.Build.0 = Debug|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|x86.Build.0 = Debug|Any CPU {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|Any CPU.Build.0 = Release|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|x64.ActiveCfg = Release|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|x64.Build.0 = Release|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|x86.ActiveCfg = Release|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|x86.Build.0 = Release|Any CPU {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|x64.ActiveCfg = Debug|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|x64.Build.0 = Debug|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|x86.ActiveCfg = Debug|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|x86.Build.0 = Debug|Any CPU {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|Any CPU.ActiveCfg = Release|Any CPU {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|Any CPU.Build.0 = Release|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|x64.ActiveCfg = Release|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|x64.Build.0 = Release|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|x86.ActiveCfg = Release|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|x86.Build.0 = Release|Any CPU {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|x64.Build.0 = Debug|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|x86.Build.0 = Debug|Any CPU {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|Any CPU.Build.0 = Release|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|x64.ActiveCfg = Release|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|x64.Build.0 = Release|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|x86.ActiveCfg = Release|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|x86.Build.0 = Release|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Debug|x64.ActiveCfg = Debug|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Debug|x64.Build.0 = Debug|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Debug|x86.ActiveCfg = Debug|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Debug|x86.Build.0 = Debug|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Release|Any CPU.Build.0 = Release|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Release|x64.ActiveCfg = Release|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Release|x64.Build.0 = Release|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Release|x86.ActiveCfg = Release|Any CPU + {08EBF14C-8EC1-4CFB-9035-60E102CDF283}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs index 8568bb6..63c19e5 100644 --- a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs +++ b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs @@ -60,6 +60,34 @@ public Query Visit(BinaryOperatorToken tokenIn) }; } + // Handle "not field eq value" which OData parses as "(not field) eq value" + if (tokenIn is + { + Left: UnaryOperatorToken { OperatorKind: UnaryOperatorKind.Not, Operand: EndPathToken { Identifier: string negatedPath } }, + Right: LiteralToken negatedLiteral + }) + { + var equalQuery = tokenIn.OperatorKind switch + { + BinaryOperatorKind.Equal => HandleEqualComparison(negatedPath, negatedLiteral), + BinaryOperatorKind.LessThan => HandleLessThanComparison(negatedPath, negatedLiteral), + BinaryOperatorKind.LessThanOrEqual => HandleLessThanOrEqualComparison(negatedPath, negatedLiteral), + BinaryOperatorKind.GreaterThan => HandleGreaterThanComparison(negatedPath, negatedLiteral), + BinaryOperatorKind.GreaterThanOrEqual => HandleGreaterThanOrEqualComparison(negatedPath, negatedLiteral), + BinaryOperatorKind.NotEqual => HandleNotEqualComparison(negatedPath, negatedLiteral), + _ => throw new NotImplementedException($"Operator {tokenIn.OperatorKind} not implemented") + }; + + return new BooleanQuery + { + Clauses = + { + new BooleanClause(new MatchAllDocsQuery(), Occur.MUST), + new BooleanClause(equalQuery, Occur.MUST_NOT) + } + }; + } + throw new NotImplementedException(); } @@ -80,8 +108,9 @@ private static Query HandleEqualComparison(string path, LiteralToken literalToke string stringValue => new TermQuery(new Term(path, stringValue)), int intValue => NumericRangeQuery.NewInt32Range(path, intValue, intValue, true, true), long longValue => NumericRangeQuery.NewInt64Range(path, longValue, longValue, true, true), - float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, floatValue, true, true), + float floatValue => NumericRangeQuery.NewDoubleRange(path, (double)floatValue, (double)floatValue, true, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, doubleValue, true, true), + decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, (double)decimalValue, (double)decimalValue, true, true), bool boolValue => NumericRangeQuery.NewInt32Range(path, boolValue ? 1 : 0, boolValue ? 1 : 0, true, true), _ => throw new NotImplementedException() }; @@ -93,8 +122,9 @@ private static Query HandleLessThanComparison(string path, LiteralToken literalT { int intValue => NumericRangeQuery.NewInt32Range(path, int.MinValue, intValue, true, false), long longValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, longValue, true, false), - float floatValue => NumericRangeQuery.NewSingleRange(path, float.NegativeInfinity, floatValue, true, false), + float floatValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)floatValue, true, false), double doubleValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, doubleValue, true, false), + decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)decimalValue, true, false), _ => throw new NotImplementedException($"Less than comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -105,8 +135,9 @@ private static Query HandleLessThanOrEqualComparison(string path, LiteralToken l { int intValue => NumericRangeQuery.NewInt32Range(path, int.MinValue, intValue, true, true), long longValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, longValue, true, true), - float floatValue => NumericRangeQuery.NewSingleRange(path, float.NegativeInfinity, floatValue, true, true), + float floatValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)floatValue, true, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, doubleValue, true, true), + decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)decimalValue, true, true), _ => throw new NotImplementedException($"Less than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -117,8 +148,9 @@ private static Query HandleGreaterThanComparison(string path, LiteralToken liter { int intValue => NumericRangeQuery.NewInt32Range(path, intValue, int.MaxValue, false, true), long longValue => NumericRangeQuery.NewInt64Range(path, longValue, long.MaxValue, false, true), - float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, float.PositiveInfinity, false, true), + float floatValue => NumericRangeQuery.NewDoubleRange(path, (double)floatValue, double.PositiveInfinity, false, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, double.PositiveInfinity, false, true), + decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, (double)decimalValue, double.PositiveInfinity, false, true), _ => throw new NotImplementedException($"Greater than comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -129,8 +161,9 @@ private static Query HandleGreaterThanOrEqualComparison(string path, LiteralToke { int intValue => NumericRangeQuery.NewInt32Range(path, intValue, int.MaxValue, true, true), long longValue => NumericRangeQuery.NewInt64Range(path, longValue, long.MaxValue, true, true), - float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, float.PositiveInfinity, true, true), + float floatValue => NumericRangeQuery.NewDoubleRange(path, (double)floatValue, double.PositiveInfinity, true, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, double.PositiveInfinity, true, true), + decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, (double)decimalValue, double.PositiveInfinity, true, true), _ => throw new NotImplementedException($"Greater than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") }; }