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