Skip to content

Commit 78d753c

Browse files
committed
Add support for DateOnly/TimeOnly
1 parent c6338f7 commit 78d753c

File tree

8 files changed

+265
-2
lines changed

8 files changed

+265
-2
lines changed

src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ public static class RuntimeTypeConverter
8282
return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue;
8383
}
8484

85+
if (nonNullableType == typeof(DateOnly))
86+
{
87+
DateOnly convertedValue = DateOnly.Parse(stringValue, cultureInfo);
88+
return isNullableTypeRequested ? (DateOnly?)convertedValue : convertedValue;
89+
}
90+
91+
if (nonNullableType == typeof(TimeOnly))
92+
{
93+
TimeOnly convertedValue = TimeOnly.Parse(stringValue, cultureInfo);
94+
return isNullableTypeRequested ? (TimeOnly?)convertedValue : convertedValue;
95+
}
96+
8597
if (nonNullableType.IsEnum)
8698
{
8799
object convertedValue = Enum.Parse(nonNullableType, stringValue);

test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Globalization;
12
using Bogus;
23
using TestBuildingBlocks;
34

@@ -8,6 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState;
89

910
internal sealed class ModelStateFakers : FakerContainer
1011
{
12+
private static readonly DateOnly MinCreatedOn = DateOnly.Parse("2000-01-01", CultureInfo.InvariantCulture);
13+
private static readonly DateOnly MaxCreatedOn = DateOnly.Parse("2050-01-01", CultureInfo.InvariantCulture);
14+
15+
private static readonly TimeOnly MinCreatedAt = TimeOnly.Parse("09:00:00", CultureInfo.InvariantCulture);
16+
private static readonly TimeOnly MaxCreatedAt = TimeOnly.Parse("17:30:00", CultureInfo.InvariantCulture);
17+
1118
private readonly Lazy<Faker<SystemVolume>> _lazySystemVolumeFaker = new(() =>
1219
new Faker<SystemVolume>()
1320
.UseSeed(GetFakerSeed())
@@ -18,7 +25,9 @@ internal sealed class ModelStateFakers : FakerContainer
1825
.UseSeed(GetFakerSeed())
1926
.RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName())
2027
.RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly))
21-
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)));
28+
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))
29+
.RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn))
30+
.RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt)));
2231

2332
private readonly Lazy<Faker<SystemDirectory>> _lazySystemDirectoryFaker = new(() =>
2433
new Faker<SystemDirectory>()

test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using FluentAssertions;
33
using JsonApiDotNetCore.Serialization.Objects;
4+
using Microsoft.Extensions.DependencyInjection;
45
using TestBuildingBlocks;
56
using Xunit;
67

@@ -17,6 +18,12 @@ public ModelStateValidationTests(IntegrationTestContext<TestableStartup<ModelSta
1718

1819
testContext.UseController<SystemDirectoriesController>();
1920
testContext.UseController<SystemFilesController>();
21+
22+
testContext.ConfigureServicesBeforeStartup(services =>
23+
{
24+
// Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation.
25+
services.AddDateOnlyTimeOnlyStringConverters();
26+
});
2027
}
2128

2229
[Fact]
@@ -123,6 +130,53 @@ public async Task Cannot_create_resource_with_invalid_attribute_value()
123130
error.Source.Pointer.Should().Be("/data/attributes/directoryName");
124131
}
125132

133+
[Fact]
134+
public async Task Cannot_create_resource_with_invalid_DateOnly_TimeOnly_attribute_value()
135+
{
136+
// Arrange
137+
SystemFile newFile = _fakers.SystemFile.Generate();
138+
139+
var requestBody = new
140+
{
141+
data = new
142+
{
143+
type = "systemFiles",
144+
attributes = new
145+
{
146+
fileName = newFile.FileName,
147+
attributes = newFile.Attributes,
148+
sizeInBytes = newFile.SizeInBytes,
149+
createdOn = DateOnly.MinValue,
150+
createdAt = TimeOnly.MinValue
151+
}
152+
}
153+
};
154+
155+
const string route = "/systemFiles";
156+
157+
// Act
158+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody);
159+
160+
// Assert
161+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
162+
163+
responseDocument.Errors.ShouldHaveCount(2);
164+
165+
ErrorObject error1 = responseDocument.Errors[0];
166+
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
167+
error1.Title.Should().Be("Input validation failed.");
168+
error1.Detail.Should().StartWith("The field CreatedAt must be between ");
169+
error1.Source.ShouldNotBeNull();
170+
error1.Source.Pointer.Should().Be("/data/attributes/createdAt");
171+
172+
ErrorObject error2 = responseDocument.Errors[1];
173+
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
174+
error2.Title.Should().Be("Input validation failed.");
175+
error2.Detail.Should().StartWith("The field CreatedOn must be between ");
176+
error2.Source.ShouldNotBeNull();
177+
error2.Source.Pointer.Should().Be("/data/attributes/createdOn");
178+
}
179+
126180
[Fact]
127181
public async Task Can_create_resource_with_valid_attribute_value()
128182
{

test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,12 @@ public sealed class SystemFile : Identifiable<int>
2020
[Attr]
2121
[Range(typeof(long), "1", "9223372036854775807")]
2222
public long SizeInBytes { get; set; }
23+
24+
[Attr]
25+
[Range(typeof(DateOnly), "2000-01-01", "2050-01-01")]
26+
public DateOnly CreatedOn { get; set; }
27+
28+
[Attr]
29+
[Range(typeof(TimeOnly), "09:00:00", "17:30:00")]
30+
public TimeOnly CreatedAt { get; set; }
2331
}

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,62 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
247247
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
248248
}
249249

250+
[Fact]
251+
public async Task Can_filter_equality_on_type_DateOnly()
252+
{
253+
// Arrange
254+
var resource = new FilterableResource
255+
{
256+
SomeDateOnly = DateOnly.FromDateTime(27.January(2003))
257+
};
258+
259+
await _testContext.RunOnDatabaseAsync(async dbContext =>
260+
{
261+
await dbContext.ClearTableAsync<FilterableResource>();
262+
dbContext.FilterableResources.AddRange(resource, new FilterableResource());
263+
await dbContext.SaveChangesAsync();
264+
});
265+
266+
string route = $"/filterableResources?filter=equals(someDateOnly,'{resource.SomeDateOnly:O}')";
267+
268+
// Act
269+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
270+
271+
// Assert
272+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
273+
274+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
275+
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly));
276+
}
277+
278+
[Fact]
279+
public async Task Can_filter_equality_on_type_TimeOnly()
280+
{
281+
// Arrange
282+
var resource = new FilterableResource
283+
{
284+
SomeTimeOnly = new TimeOnly(23, 59, 59, 999)
285+
};
286+
287+
await _testContext.RunOnDatabaseAsync(async dbContext =>
288+
{
289+
await dbContext.ClearTableAsync<FilterableResource>();
290+
dbContext.FilterableResources.AddRange(resource, new FilterableResource());
291+
await dbContext.SaveChangesAsync();
292+
});
293+
294+
string route = $"/filterableResources?filter=equals(someTimeOnly,'{resource.SomeTimeOnly:O}')";
295+
296+
// Act
297+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
298+
299+
// Assert
300+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
301+
302+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
303+
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly));
304+
}
305+
250306
[Fact]
251307
public async Task Cannot_filter_equality_on_incompatible_value()
252308
{
@@ -291,6 +347,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
291347
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
292348
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
293349
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
350+
[InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
351+
[InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
294352
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
295353
public async Task Can_filter_is_null_on_type(string propertyName)
296354
{
@@ -311,6 +369,8 @@ public async Task Can_filter_is_null_on_type(string propertyName)
311369
SomeNullableDateTime = 1.January(2001).AsUtc(),
312370
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
313371
SomeNullableTimeSpan = TimeSpan.FromHours(1),
372+
SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
373+
SomeNullableTimeOnly = new TimeOnly(1, 0),
314374
SomeNullableEnum = DayOfWeek.Friday
315375
};
316376

@@ -345,6 +405,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
345405
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
346406
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
347407
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
408+
[InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
409+
[InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
348410
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
349411
public async Task Can_filter_is_not_null_on_type(string propertyName)
350412
{
@@ -361,6 +423,8 @@ public async Task Can_filter_is_not_null_on_type(string propertyName)
361423
SomeNullableDateTime = 1.January(2001).AsUtc(),
362424
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
363425
SomeNullableTimeSpan = TimeSpan.FromHours(1),
426+
SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
427+
SomeNullableTimeOnly = new TimeOnly(1, 0),
364428
SomeNullableEnum = DayOfWeek.Friday
365429
};
366430

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ public sealed class FilterOperatorTests : IClassFixture<IntegrationTestContext<T
3535
private const string TimeSpanInTheRange = "2:15:51:42.397";
3636
private const string TimeSpanUpperBound = "2:16:22:41.736";
3737

38+
private const string IsoDateOnlyLowerBound = "2000-10-22";
39+
private const string IsoDateOnlyInTheRange = "2000-11-22";
40+
private const string IsoDateOnlyUpperBound = "2000-12-22";
41+
42+
private const string InvariantDateOnlyLowerBound = "10/22/2000";
43+
private const string InvariantDateOnlyInTheRange = "11/22/2000";
44+
private const string InvariantDateOnlyUpperBound = "12/22/2000";
45+
46+
private const string TimeOnlyLowerBound = "15:28:54.997";
47+
private const string TimeOnlyInTheRange = "15:51:42.397";
48+
private const string TimeOnlyUpperBound = "16:22:41.736";
49+
3850
private readonly IntegrationTestContext<TestableStartup<FilterDbContext>, FilterDbContext> _testContext;
3951

4052
public FilterOperatorTests(IntegrationTestContext<TestableStartup<FilterDbContext>, FilterDbContext> testContext)
@@ -556,6 +568,96 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
556568
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
557569
}
558570

571+
[Theory]
572+
[InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyInTheRange)]
573+
[InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyUpperBound)]
574+
[InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyInTheRange)]
575+
[InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyLowerBound)]
576+
[InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyInTheRange)]
577+
[InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyLowerBound)]
578+
[InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyInTheRange)]
579+
[InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyUpperBound)]
580+
[InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyInTheRange)]
581+
[InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyUpperBound)]
582+
[InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyInTheRange)]
583+
[InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyLowerBound)]
584+
[InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyInTheRange)]
585+
[InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyLowerBound)]
586+
[InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyInTheRange)]
587+
[InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyUpperBound)]
588+
public async Task Can_filter_comparison_on_DateOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue)
589+
{
590+
// Arrange
591+
var resource = new FilterableResource
592+
{
593+
SomeDateOnly = DateOnly.Parse(matchingValue, CultureInfo.InvariantCulture)
594+
};
595+
596+
var otherResource = new FilterableResource
597+
{
598+
SomeDateOnly = DateOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
599+
};
600+
601+
await _testContext.RunOnDatabaseAsync(async dbContext =>
602+
{
603+
await dbContext.ClearTableAsync<FilterableResource>();
604+
dbContext.FilterableResources.AddRange(resource, otherResource);
605+
await dbContext.SaveChangesAsync();
606+
});
607+
608+
string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateOnly,'{filterValue}')";
609+
610+
// Act
611+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
612+
613+
// Assert
614+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
615+
616+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
617+
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly));
618+
}
619+
620+
[Theory]
621+
[InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyInTheRange)]
622+
[InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyUpperBound)]
623+
[InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyInTheRange)]
624+
[InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyLowerBound)]
625+
[InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyInTheRange)]
626+
[InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyLowerBound)]
627+
[InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyInTheRange)]
628+
[InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyUpperBound)]
629+
public async Task Can_filter_comparison_on_TimeOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue)
630+
{
631+
// Arrange
632+
var resource = new FilterableResource
633+
{
634+
SomeTimeOnly = TimeOnly.Parse(matchingValue, CultureInfo.InvariantCulture)
635+
};
636+
637+
var otherResource = new FilterableResource
638+
{
639+
SomeTimeOnly = TimeOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
640+
};
641+
642+
await _testContext.RunOnDatabaseAsync(async dbContext =>
643+
{
644+
await dbContext.ClearTableAsync<FilterableResource>();
645+
dbContext.FilterableResources.AddRange(resource, otherResource);
646+
await dbContext.SaveChangesAsync();
647+
});
648+
649+
string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeOnly,'{filterValue}')";
650+
651+
// Act
652+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
653+
654+
// Assert
655+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
656+
657+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
658+
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly));
659+
}
660+
559661
[Theory]
560662
[InlineData("The fox jumped over the lazy dog", "Other", TextMatchKind.Contains, "jumped")]
561663
[InlineData("The fox jumped over the lazy dog", "the fox...", TextMatchKind.Contains, "The")]

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ public sealed class FilterableResource : Identifiable<int>
7777
[Attr]
7878
public TimeSpan? SomeNullableTimeSpan { get; set; }
7979

80+
[Attr]
81+
public DateOnly SomeDateOnly { get; set; }
82+
83+
[Attr]
84+
public DateOnly? SomeNullableDateOnly { get; set; }
85+
86+
[Attr]
87+
public TimeOnly SomeTimeOnly { get; set; }
88+
89+
[Attr]
90+
public TimeOnly? SomeNullableTimeOnly { get; set; }
91+
8092
[Attr]
8193
public DayOfWeek SomeEnum { get; set; }
8294

test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
33
<TargetFramework>$(TargetFrameworkName)</TargetFramework>
44
</PropertyGroup>
@@ -11,9 +11,11 @@
1111

1212
<ItemGroup>
1313
<PackageReference Include="coverlet.collector" Version="$(CoverletVersion)" PrivateAssets="All" />
14+
<PackageReference Include="DateOnlyTimeOnly.AspNet" Version="2.0.*" />
1415
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(AspNetVersion)" />
1516
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EFCoreVersion)" />
1617
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="$(EFCoreVersion)" />
1718
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(TestSdkVersion)" />
19+
<PackageReference Include="System.Text.Json" Version="7.0.*" />
1820
</ItemGroup>
1921
</Project>

0 commit comments

Comments
 (0)