diff --git a/AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs b/AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs new file mode 100644 index 0000000..de503cd --- /dev/null +++ b/AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json.Nodes; +using AzureSearchEmulator.Indexing; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Searching; +using Lucene.Net.Documents; +using Lucene.Net.Search; +using Microsoft.OData.UriParser; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Covers gt, ge, lt and le on an Edm.DateTimeOffset field, +/// which previously failed with "comparison not supported for type DateTimeOffset". +/// +/// +/// Document 2 is dated exactly on the boundary used by every test, so each test also shows +/// whether its operator includes or excludes the boundary. +/// +public class DateRangeFilterTests : IDisposable +{ + private const string Boundary = "2024-06-01T00:00:00Z"; + + private readonly LuceneTestHelper _helper; + private readonly IndexSearcher _searcher; + + public DateRangeFilterTests() + { + _helper = new LuceneTestHelper(CreateIndex(), CreateDocuments()); + _searcher = _helper.CreateSearcher(); + } + + public void Dispose() => _helper.Dispose(); + + private static SearchIndex CreateIndex() => new() + { + Name = "dated", + Fields = + [ + new SearchField { Name = "Id", Type = "Edm.String", Key = true, Searchable = true }, + new SearchField { Name = "Updated", Type = "Edm.DateTimeOffset", Filterable = true }, + ] + }; + + private static List CreateDocuments() => + [ + Doc("1", "2024-01-01T00:00:00Z"), + Doc("2", Boundary), + Doc("3", "2025-01-01T00:00:00Z"), + Doc("4", null), + ]; + + private static Document Doc(string id, string? updated) + { + var json = new JsonObject { ["Id"] = id }; + + if (updated != null) + { + json["Updated"] = DateTimeOffset.Parse(updated); + } + + var doc = new Document(); + + foreach (var field in CreateIndex().Fields) + { + if (json[field.Name] is not { } value) + { + continue; + } + + foreach (var luceneField in field.CreateFields(value)) + { + doc.Add(luceneField); + } + } + + return doc; + } + + private List Search(string filter) + { + var query = new UriQueryExpressionParser(100) + .ParseFilter(filter) + .Accept(new ODataQueryVisitor(_helper.Index)); + + return _searcher.Search(query, 100).ScoreDocs + .Select(sd => _searcher.Doc(sd.Doc).Get("Id")) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + } + + [Theory] + [InlineData($"Updated gt {Boundary}", new[] { "3" })] + [InlineData($"Updated ge {Boundary}", new[] { "2", "3" })] + [InlineData($"Updated lt {Boundary}", new[] { "1" })] + [InlineData($"Updated le {Boundary}", new[] { "1", "2" })] + public void RangeOnDateField_MatchesDocumentsOnTheRightSideOfTheBoundary(string filter, string[] expected) + { + Assert.Equal(expected, Search(filter)); + } + + /// + /// The same instant written with a different offset must give the same result, since the + /// comparison is between instants, not between the written forms. + /// + [Fact] + public void RangeOnDateField_ComparesInstantsWhateverTheOffset() + { + Assert.Equal(["2", "3"], Search("Updated ge 2024-06-01T02:00:00+02:00")); + } + + [Fact] + public void ClosedDateRange_MatchesDocumentsBetweenTheBounds() + { + Assert.Equal(["2"], Search("Updated gt 2024-01-01T00:00:00Z and Updated lt 2025-01-01T00:00:00Z")); + } +} diff --git a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs index 9115442..add4979 100644 --- a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs +++ b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs @@ -612,6 +612,7 @@ private static Query HandleLessThanComparison(string path, LiteralToken literalT 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), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, dateValue.ToUnixTimeMilliseconds(), true, false), _ => throw new NotImplementedException($"Less than comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -626,6 +627,7 @@ private static Query HandleLessThanOrEqualComparison(string path, LiteralToken l 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), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, dateValue.ToUnixTimeMilliseconds(), true, true), _ => throw new NotImplementedException($"Less than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -640,6 +642,7 @@ private static Query HandleGreaterThanComparison(string path, LiteralToken liter 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), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, dateValue.ToUnixTimeMilliseconds(), long.MaxValue, false, true), _ => throw new NotImplementedException($"Greater than comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -654,6 +657,7 @@ private static Query HandleGreaterThanOrEqualComparison(string path, LiteralToke 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), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, dateValue.ToUnixTimeMilliseconds(), long.MaxValue, true, true), _ => throw new NotImplementedException($"Greater than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") }; }