From e515fb467439cb64d51703abc533f2b693c171e2 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 23 Apr 2026 10:58:10 -0600 Subject: [PATCH] Support negation and decimal literals in OData filter queries Adds handling for "not " expressions, which OData parses as "(not ) ", by wrapping the underlying comparison in a MUST_NOT boolean clause. Also adds decimal literal support and switches float range queries to double ranges to match how numeric fields are indexed. Introduces an xUnit test project covering the visitor and LuceneNetIndexSearcher. Co-Authored-By: Claude Opus 4.7 --- .../AzureSearchEmulator.UnitTests.csproj | 25 + .../LuceneNetIndexSearcherTests.cs | 930 ++++++++++++++++++ .../LuceneTestHelper.cs | 98 ++ .../ODataQueryVisitorTests.cs | 699 +++++++++++++ AzureSearchEmulator.sln | 66 ++ .../Searching/ODataQueryVisitor.cs | 43 +- 6 files changed, 1856 insertions(+), 5 deletions(-) create mode 100644 AzureSearchEmulator.UnitTests/AzureSearchEmulator.UnitTests.csproj create mode 100644 AzureSearchEmulator.UnitTests/LuceneNetIndexSearcherTests.cs create mode 100644 AzureSearchEmulator.UnitTests/LuceneTestHelper.cs create mode 100644 AzureSearchEmulator.UnitTests/ODataQueryVisitorTests.cs 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}") }; }