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}")
};
}