Skip to content

Commit 8071148

Browse files
committed
Preserve nullable metadata when validating native contracts
1 parent a693f7c commit 8071148

3 files changed

Lines changed: 136 additions & 5 deletions

File tree

‎src/KeyLoad.Abstractions/Features/InternalSerialization/NativeContractValidation.cs‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,14 @@ internal sealed record NativeValueValidation(bool Required, NativeValueValidatio
159159
internal static NativeValueValidation Create(NullabilityInfo info)
160160
{
161161
var required = !info.Type.IsValueType && info.ReadState == NullabilityState.NotNull;
162-
if (Nullable.GetUnderlyingType(info.Type) is not null)
163-
{
164-
return Create(info.GenericTypeArguments[0]);
165-
}
166162
if (info.ElementType is { } element)
167163
{
168164
return new(required, Create(element));
169165
}
166+
// NullabilityInfo keeps Nullable<T> as Type, but its generic metadata already describes T.
170167
var arguments = info.GenericTypeArguments;
171-
if (arguments.Length == 2 && (IsDictionary(info.Type) || info.Type.GetInterfaces().Any(IsDictionary)))
168+
var type = Nullable.GetUnderlyingType(info.Type) ?? info.Type;
169+
if (arguments.Length == 2 && (IsDictionary(type) || type.GetInterfaces().Any(IsDictionary)))
172170
{
173171
return new(required, null, Create(arguments[0]), Create(arguments[1]));
174172
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System.Collections.Immutable;
2+
using KeyLoad.Features.InternalSerialization;
3+
4+
namespace KeyLoad.UnitTests.Features.InternalSerialization;
5+
6+
internal static class NativeNullableMetadataFixtures
7+
{
8+
internal const string ScalarsAlias = "keyload.tests.native-nullable.scalars.v1";
9+
internal const string CollectionsAlias = "keyload.tests.native-nullable.collections.v1";
10+
internal const string PrincipalId = "nullable-principal";
11+
internal const string TenantId = "nullable-tenant";
12+
internal const string ApiKeyId = "nullable-api-key";
13+
internal const string Verifier = "nullable-verifier";
14+
internal const string Text = "required-element";
15+
internal const string BootstrapPrincipalId = "root";
16+
internal const long Number = 123;
17+
internal static DateTimeOffset At => DateTimeOffset.UnixEpoch;
18+
19+
internal static NativeNullableMetadataScalars Scalars(bool present)
20+
=> new(present ? Number : null, present ? At : null, present ? ErrorCode.Validation : null);
21+
22+
internal static NativeNullableMetadataCollections Collections(bool present)
23+
=> present ? new([Text], [null, ImmutableArray.Create(Text)]) : new(null, null);
24+
25+
internal static byte[] EncodeUnchecked<T>(T value)
26+
=> NativeSerializerProviders.Get(typeof(T)).Serializer.SerializeToArray(
27+
new NativePayload { Version = NativePayloadVersion.Current, Value = value });
28+
}
29+
30+
[global::Orleans.GenerateSerializer, global::Orleans.Alias(NativeNullableMetadataFixtures.ScalarsAlias)]
31+
internal sealed record NativeNullableMetadataScalars(
32+
[property: global::Orleans.Id(0)] long? Number,
33+
[property: global::Orleans.Id(1)] DateTimeOffset? Date,
34+
[property: global::Orleans.Id(2)] ErrorCode? Error);
35+
36+
[global::Orleans.GenerateSerializer, global::Orleans.Alias(NativeNullableMetadataFixtures.CollectionsAlias)]
37+
internal sealed record NativeNullableMetadataCollections(
38+
[property: global::Orleans.Id(0)] ImmutableArray<string>? Values,
39+
[property: global::Orleans.Id(1)] ImmutableArray<ImmutableArray<string>?>? Nested);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System.Collections.Immutable;
2+
using System.Reflection;
3+
using KeyLoad.Features.InternalSerialization;
4+
5+
namespace KeyLoad.UnitTests.Features.InternalSerialization;
6+
7+
internal sealed class NativeNullableMetadataTests
8+
{
9+
[Test]
10+
public async Task AcIs002RuntimeMetadataAlreadyDescribesTheUnderlyingNullableTypeArguments()
11+
{
12+
var context = new NullabilityInfoContext();
13+
var scalar = context.Create(typeof(NativeNullableMetadataScalars).GetProperty(nameof(NativeNullableMetadataScalars.Number))!);
14+
await Assert.That(scalar.Type).IsEqualTo(typeof(long?));
15+
await Assert.That(scalar.GenericTypeArguments.Length).IsEqualTo(0);
16+
var collection = context.Create(typeof(NativeNullableMetadataCollections).GetProperty(nameof(NativeNullableMetadataCollections.Values))!);
17+
await Assert.That(collection.Type).IsEqualTo(typeof(ImmutableArray<string>?));
18+
await Assert.That(collection.GenericTypeArguments.Length).IsEqualTo(1);
19+
await Assert.That(collection.GenericTypeArguments[0].Type).IsEqualTo(typeof(string));
20+
await Assert.That(collection.GenericTypeArguments[0].ReadState).IsEqualTo(NullabilityState.NotNull);
21+
var validation = NativeValueValidation.Create(collection);
22+
await Assert.That(validation.Required).IsFalse();
23+
await Assert.That(validation.Element!.Required).IsTrue();
24+
}
25+
26+
[Test, Arguments(false), Arguments(true)]
27+
public async Task AcIs002NullableScalarFieldsRoundtripAbsentAndPresentValues(bool present)
28+
{
29+
var expected = NativeNullableMetadataFixtures.Scalars(present);
30+
var actual = NativeSerialization.Deserialize<NativeNullableMetadataScalars>(NativeSerialization.Serialize(expected));
31+
await Assert.That(actual).IsEqualTo(expected);
32+
}
33+
34+
[Test, Arguments(false), Arguments(true)]
35+
public async Task AcIs002PersistedPrincipalAndApiKeyOptionalDatesRoundtrip(bool present)
36+
{
37+
DateTimeOffset? date = present ? NativeNullableMetadataFixtures.At : null;
38+
var principal = new PrincipalRecord(NativeNullableMetadataFixtures.PrincipalId,
39+
NativeNullableMetadataFixtures.TenantId, [], []) { ExpiresAt = date };
40+
var key = new ApiKeyRecord(NativeNullableMetadataFixtures.ApiKeyId, principal.Id,
41+
NativeNullableMetadataFixtures.Verifier, date);
42+
var restoredPrincipal = NativeSerialization.Deserialize<PrincipalRecord>(NativeSerialization.Serialize(principal));
43+
var restoredKey = NativeSerialization.Deserialize<ApiKeyRecord>(NativeSerialization.Serialize(key));
44+
await Assert.That(restoredPrincipal.Id).IsEqualTo(principal.Id);
45+
await Assert.That(restoredPrincipal.ExpiresAt).IsEqualTo(date);
46+
await Assert.That(restoredKey).IsEqualTo(key);
47+
}
48+
49+
[Test, Arguments(false), Arguments(true)]
50+
public async Task AcIs002NullableCollectionsRetainAbsentAndNestedPresentElementPolicies(bool present)
51+
{
52+
var expected = NativeNullableMetadataFixtures.Collections(present);
53+
var actual = NativeSerialization.Deserialize<NativeNullableMetadataCollections>(NativeSerialization.Serialize(expected));
54+
await Assert.That(actual.Values.HasValue).IsEqualTo(present);
55+
await Assert.That(actual.Nested.HasValue).IsEqualTo(present);
56+
if (present)
57+
{
58+
await Assert.That(actual.Values!.Value[0]).IsEqualTo(NativeNullableMetadataFixtures.Text);
59+
await Assert.That(actual.Nested!.Value[0].HasValue).IsFalse();
60+
await Assert.That(actual.Nested.Value[1]!.Value[0]).IsEqualTo(NativeNullableMetadataFixtures.Text);
61+
}
62+
}
63+
64+
[Test]
65+
public async Task AcIs002PresentNullableCollectionsRejectDefaultAndRequiredNullElements()
66+
{
67+
NativeNullableMetadataCollections[] malformed =
68+
[
69+
new(default(ImmutableArray<string>), null),
70+
new(ImmutableArray.Create<string>([null!]), null),
71+
new(null, ImmutableArray.Create<ImmutableArray<string>?>(default(ImmutableArray<string>))),
72+
new(null, ImmutableArray.Create<ImmutableArray<string>?>(ImmutableArray.Create<string>([null!])))
73+
];
74+
foreach (var value in malformed)
75+
{
76+
await Assert.That(Assert.ThrowsExactly<KeyLoadException>(() => NativeSerialization.Serialize(value)).Code)
77+
.IsEqualTo(ErrorCode.Corruption);
78+
var bytes = NativeNullableMetadataFixtures.EncodeUnchecked(value);
79+
await Assert.That(Assert.ThrowsExactly<KeyLoadException>(() => NativeSerialization.Deserialize<NativeNullableMetadataCollections>(bytes)).Code)
80+
.IsEqualTo(ErrorCode.Corruption);
81+
}
82+
}
83+
84+
[Test]
85+
public async Task AcIs002RealDatabaseBootstrapPersistsNullablePrincipalAndCredentialRecords()
86+
{
87+
using var database = new TestDatabase();
88+
var principal = database.Store.Read(view => database.Database.Principal(view,
89+
NativeNullableMetadataFixtures.BootstrapPrincipalId, NativeNullableMetadataFixtures.At));
90+
await Assert.That(principal.ClusterAdministrator).IsTrue();
91+
await Assert.That(principal.ExpiresAt).IsNull();
92+
await Assert.That(database.Store.Position > 0).IsTrue();
93+
}
94+
}

0 commit comments

Comments
 (0)