diff --git a/Fauna.Test/Helpers/Fixtures.cs b/Fauna.Test/Helpers/Fixtures.cs index 37ca39cf..e9e096aa 100644 --- a/Fauna.Test/Helpers/Fixtures.cs +++ b/Fauna.Test/Helpers/Fixtures.cs @@ -1,4 +1,5 @@ using Fauna.Mapping.Attributes; +using Fauna.Types; using static Fauna.Query; namespace Fauna.Test; diff --git a/Fauna.Test/Serialization/Deserializer.Tests.cs b/Fauna.Test/Serialization/Deserializer.Tests.cs index 91ccc54e..3c78136a 100644 --- a/Fauna.Test/Serialization/Deserializer.Tests.cs +++ b/Fauna.Test/Serialization/Deserializer.Tests.cs @@ -293,7 +293,7 @@ public void DeserializeRef() } [Test] - public void DeserializeNullRef() + public void DeserializeNullAsNullableDocument() { const string given = @" { @@ -305,12 +305,22 @@ public void DeserializeNullRef() } }"; - var actual = Deserialize(given); - Assert.AreEqual("123", actual.Id); - Assert.AreEqual(new Module("MyColl"), actual.Collection); - Assert.AreEqual("not found", actual.Cause); + var actual = Deserialize>(given); + + switch (actual) + { + case NullDocument d: + Assert.AreEqual("123", d.Id); + Assert.AreEqual("MyColl", d.Collection.Name); + Assert.AreEqual("not found", d.Cause); + break; + default: + Assert.Fail($"result is type: {actual.GetType()}"); + break; + } } + [Test] public void DeserializeNamedRef() { @@ -328,7 +338,7 @@ public void DeserializeNamedRef() } [Test] - public void DeserializeNullNamedRef() + public void DeserializeNullAsNullableNamedDocument() { const string given = @" { @@ -340,10 +350,19 @@ public void DeserializeNullNamedRef() } }"; - var actual = Deserialize(given); - Assert.AreEqual("RefName", actual.Name); - Assert.AreEqual(new Module("MyColl"), actual.Collection); - Assert.AreEqual("not found", actual.Cause); + var actual = Deserialize>(given); + + switch (actual) + { + case NullDocument d: + Assert.AreEqual("RefName", d.Id); + Assert.AreEqual("MyColl", d.Collection.Name); + Assert.AreEqual("not found", d.Cause); + break; + default: + Assert.Fail($"result is type: {actual.GetType()}"); + break; + } } [Test] diff --git a/Fauna/Exceptions/NullDocumentException.cs b/Fauna/Exceptions/NullDocumentException.cs new file mode 100644 index 00000000..690a467f --- /dev/null +++ b/Fauna/Exceptions/NullDocumentException.cs @@ -0,0 +1,19 @@ +using Fauna.Types; + +namespace Fauna.Exceptions; + +internal class NullDocumentException : Exception +{ + public string Id { get; } + + public Module Collection { get; } + + public string Cause { get; } + + public NullDocumentException(string message, string id, Module collection, string cause) : base(message) + { + Id = id; + Collection = collection; + Cause = cause; + } +} diff --git a/Fauna/Serialization/CheckedDeserializer.cs b/Fauna/Serialization/CheckedDeserializer.cs index f3d78738..f53fde49 100644 --- a/Fauna/Serialization/CheckedDeserializer.cs +++ b/Fauna/Serialization/CheckedDeserializer.cs @@ -9,10 +9,9 @@ public override T Deserialize(MappingContext context, ref Utf8FaunaReader reader var tokenType = reader.CurrentTokenType; var obj = DynamicDeserializer.Singleton.Deserialize(context, ref reader); - if (obj is T v) - return v; - else - throw new SerializationException( - $"Unexpected token while deserializing: {tokenType}"); + if (obj is T v) return v; + + throw new SerializationException( + $"Expected type {typeof(T)} but received {obj?.GetType()}"); } } diff --git a/Fauna/Serialization/Deserializer.cs b/Fauna/Serialization/Deserializer.cs index 053cd457..0106f7bf 100644 --- a/Fauna/Serialization/Deserializer.cs +++ b/Fauna/Serialization/Deserializer.cs @@ -24,10 +24,10 @@ public static class Deserializer private static readonly CheckedDeserializer _module = new(); private static readonly CheckedDeserializer _doc = new(); private static readonly CheckedDeserializer _namedDoc = new(); + private static readonly DocumentDeserializer> _nullableDoc = new(); + private static readonly DocumentDeserializer> _nullableNamedDoc = new(); private static readonly CheckedDeserializer _docRef = new(); - private static readonly CheckedDeserializer _nullDocRef = new(); private static readonly CheckedDeserializer _namedDocRef = new(); - private static readonly CheckedDeserializer _nullNamedDocRef = new(); /// /// Generates a deserializer for the specified non-nullable .NET type. @@ -62,9 +62,28 @@ public static IDeserializer Generate(MappingContext context, Type targetType) if (targetType == typeof(Document)) return _doc; if (targetType == typeof(NamedDocument)) return _namedDoc; if (targetType == typeof(DocumentRef)) return _docRef; - if (targetType == typeof(NullDocumentRef)) return _nullDocRef; if (targetType == typeof(NamedDocumentRef)) return _namedDocRef; - if (targetType == typeof(NullNamedDocumentRef)) return _nullNamedDocRef; + + if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(NullableDocument<>)) + { + var argTypes = targetType.GetGenericArguments(); + var valueType = argTypes[0]; + + if (valueType == typeof(Document)) + { + var deserType = typeof(NullableDocumentDeserializer<>).MakeGenericType(new[] { valueType }); + var deser = Activator.CreateInstance(deserType, new[] { _nullableDoc }); + + return (IDeserializer)deser!; + } + if (valueType == typeof(NamedDocument)) + { + var deserType = typeof(NullableDocumentDeserializer<>).MakeGenericType(new[] { valueType }); + var deser = Activator.CreateInstance(deserType, new[] { _nullableNamedDoc }); + + return (IDeserializer)deser!; + } + } if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) { diff --git a/Fauna/Serialization/DocumentDeserializer.cs b/Fauna/Serialization/DocumentDeserializer.cs new file mode 100644 index 00000000..16b57862 --- /dev/null +++ b/Fauna/Serialization/DocumentDeserializer.cs @@ -0,0 +1,144 @@ +using Fauna.Mapping; +using Fauna.Types; + +namespace Fauna.Serialization; + +internal class DocumentDeserializer : BaseDeserializer +{ + public override T Deserialize(MappingContext context, ref Utf8FaunaReader reader) + { + return reader.CurrentTokenType switch + { + TokenType.StartObject => DeserializeDocument(context, ref reader), + TokenType.StartRef => DeserializeRef(context, ref reader), + _ => throw new SerializationException( + $"Unexpected token while deserializing into {typeof(NullableDocument)}: {reader.CurrentTokenType}") + }; + } + + private T DeserializeDocument(MappingContext context, ref Utf8FaunaReader reader) + { + var data = new Dictionary(); + string? id = null; + object? name = null; + DateTime? ts = null; + Module? coll = null; + + while (reader.Read() && reader.CurrentTokenType != TokenType.EndDocument) + { + if (reader.CurrentTokenType != TokenType.FieldName) + throw new SerializationException( + $"Unexpected token while deserializing into Document: {reader.CurrentTokenType}"); + + var fieldName = reader.GetString()!; + reader.Read(); + switch (fieldName) + { + case "id": + id = reader.GetString(); + break; + case "name": + name = DynamicDeserializer.Singleton.Deserialize(context, ref reader); + break; + case "coll": + coll = reader.GetModule(); + + // if we encounter a mapped collection, jump to the class deserializer. + // NB this relies on the fact that docs on the wire always + // start with id and coll. + if (context.TryGetCollection(coll.Name, out var collInfo)) + { + // This assumes ordering on the wire. If name is not null and we're here, then it's a named document so name is a string. + var doc = collInfo.Deserializer.DeserializeDocument(context, id, name != null ? (string)name : null, ref reader); + if (doc is T v) return v; + throw new SerializationException($"Expected type {typeof(T)} but received {doc.GetType()}"); + } + + break; + case "ts": + ts = reader.GetTime(); + break; + default: + data[fieldName] = DynamicDeserializer.Singleton.Deserialize(context, ref reader); + break; + } + } + + if (id != null && coll != null && ts != null) + { + if (name != null) data["name"] = name; + var r = new Document(id, coll, ts.GetValueOrDefault(), data); + if (r is T d) return d; + var nr = (NullableDocument)new NonNullDocument(r); + if (nr is T nnd) return nnd; + } + + if (name != null && coll != null && ts != null) + { + // If we're here, name is a string. + var r = new NamedDocument((string)name, coll, ts.GetValueOrDefault(), data); + if (r is T d) return d; + var nr = (NullableDocument)new NonNullDocument(r); + if (nr is T nnd) return nnd; + } + + throw new SerializationException("Unsupported document type."); + } + + private T DeserializeRef(MappingContext context, ref Utf8FaunaReader reader) + { + string? id = null; + string? name = null; + Module? coll = null; + string? cause = null; + var exists = true; + + while (reader.Read() && reader.CurrentTokenType != TokenType.EndRef) + { + if (reader.CurrentTokenType != TokenType.FieldName) + throw new SerializationException( + $"Unexpected token while deserializing into DocumentRef: {reader.CurrentTokenType}"); + + var fieldName = reader.GetString()!; + reader.Read(); + switch (fieldName) + { + case "id": + id = reader.GetString(); + break; + case "name": + name = reader.GetString(); + break; + case "coll": + coll = reader.GetModule(); + break; + case "cause": + cause = reader.GetString(); + break; + case "exists": + exists = reader.GetBoolean(); + break; + } + } + + if ((id != null || name != null) && coll != null && exists) + { + throw new SerializationException($"Expected a document but received a ref: {id ?? name} in {coll.Name}"); + } + + if ((id != null || name != null) && coll != null && !exists) + { + var ty = typeof(T); + if (ty.IsGenericType && ty.GetGenericTypeDefinition() == typeof(NullableDocument<>)) + { + var inner = ty.GetGenericArguments()[0]; + var nty = typeof(NullDocument<>).MakeGenericType(inner); + var r = Activator.CreateInstance(nty, (id ?? name)!, coll, cause!); + return (T)r!; + } + } + + throw new SerializationException("Unsupported reference type"); + } + +} diff --git a/Fauna/Serialization/DynamicDeserializer.cs b/Fauna/Serialization/DynamicDeserializer.cs index 94864075..81e66318 100644 --- a/Fauna/Serialization/DynamicDeserializer.cs +++ b/Fauna/Serialization/DynamicDeserializer.cs @@ -1,3 +1,4 @@ +using Fauna.Exceptions; using Fauna.Mapping; using Fauna.Types; @@ -44,9 +45,8 @@ private object DeserializeRef(MappingContext context, ref Utf8FaunaReader reader string? id = null; string? name = null; Module? coll = null; - var exists = true; string? cause = null; - var allProps = new Dictionary(); + var exists = true; while (reader.Read() && reader.CurrentTokenType != TokenType.EndRef) { @@ -60,59 +60,49 @@ private object DeserializeRef(MappingContext context, ref Utf8FaunaReader reader { case "id": id = reader.GetString(); - allProps["id"] = id; break; case "name": name = reader.GetString(); - allProps["name"] = name; break; case "coll": coll = reader.GetModule(); - allProps["coll"] = coll; - break; - case "exists": - exists = reader.GetBoolean(); - allProps["exists"] = exists; break; case "cause": cause = reader.GetString(); - allProps["cause"] = cause; break; - default: - allProps[fieldName] = DynamicDeserializer.Singleton.Deserialize(context, ref reader); + case "exists": + exists = reader.GetBoolean(); break; } } - if (id != null && coll != null) + if (id != null && coll != null && exists) { - if (exists) - { - return new DocumentRef(id, coll); - } - - return new NullDocumentRef(id, coll, cause!); + return new DocumentRef(id, coll); } - if (name != null && coll != null) + if (name != null && coll != null && exists) { - if (exists) - { - return new NamedDocumentRef(name, coll); - } + return new NamedDocumentRef(name, coll); + } - return new NullNamedDocumentRef(name, coll, cause!); + if ((id != null || name != null) && coll != null && !exists) + { + throw new NullDocumentException( + $"Document {id ?? name} in collection {coll.Name} is null: {cause}", + (id ?? name)!, + coll, + cause!); } - // Unsupported ref type, but don't fail for forward compatibility. - return allProps; + throw new SerializationException("Unsupported reference type"); } private object DeserializeDocument(MappingContext context, ref Utf8FaunaReader reader) { var data = new Dictionary(); string? id = null; - string? name = null; + object? name = null; DateTime? ts = null; Module? coll = null; @@ -130,7 +120,7 @@ private object DeserializeDocument(MappingContext context, ref Utf8FaunaReader r id = reader.GetString(); break; case "name": - name = reader.GetString(); + name = Singleton.Deserialize(context, ref reader); break; case "coll": coll = reader.GetModule(); @@ -140,7 +130,8 @@ private object DeserializeDocument(MappingContext context, ref Utf8FaunaReader r // start with id and coll. if (context.TryGetCollection(coll.Name, out var collInfo)) { - return collInfo.Deserializer.DeserializeDocument(context, id, name, ref reader); + // This assumes ordering on the wire. If name is not null and we're here, then it's a named document so name is a string. + return collInfo.Deserializer.DeserializeDocument(context, id, name != null ? (string)name : null, ref reader); } break; @@ -148,7 +139,7 @@ private object DeserializeDocument(MappingContext context, ref Utf8FaunaReader r ts = reader.GetTime(); break; default: - data[fieldName] = DynamicDeserializer.Singleton.Deserialize(context, ref reader); + data[fieldName] = Singleton.Deserialize(context, ref reader); break; } } @@ -161,7 +152,8 @@ private object DeserializeDocument(MappingContext context, ref Utf8FaunaReader r if (name != null && coll != null && ts != null) { - return new NamedDocument(name, coll, ts.GetValueOrDefault(), data); + // If we're here, name is a string. + return new NamedDocument((string)name, coll, ts.GetValueOrDefault(), data); } // Unsupported document type, but don't fail for forward compatibility. diff --git a/Fauna/Serialization/NullableDocumentDeserializer.cs b/Fauna/Serialization/NullableDocumentDeserializer.cs new file mode 100644 index 00000000..0a70b8c0 --- /dev/null +++ b/Fauna/Serialization/NullableDocumentDeserializer.cs @@ -0,0 +1,24 @@ +using Fauna.Exceptions; +using Fauna.Mapping; +using Fauna.Types; + +namespace Fauna.Serialization; + +internal class NullableDocumentDeserializer : BaseDeserializer> +{ + private readonly IDeserializer> _valueDeserializer; + + public NullableDocumentDeserializer(IDeserializer> valueDeserializer) + { + _valueDeserializer = valueDeserializer; + } + + public override NullableDocument Deserialize(MappingContext context, ref Utf8FaunaReader reader) + { + if (reader.CurrentTokenType is not (TokenType.StartObject or TokenType.StartRef)) + throw new SerializationException( + $"Unexpected token while deserializing into {typeof(NullableDocument)}: {reader.CurrentTokenType}"); + + return _valueDeserializer.Deserialize(context, ref reader); + } +} diff --git a/Fauna/Types/NullDocumentRef.cs b/Fauna/Types/NullDocumentRef.cs deleted file mode 100644 index 7fb3b98e..00000000 --- a/Fauna/Types/NullDocumentRef.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Fauna.Types; - -/// -/// Represents a null reference to a document, including a reason for its null state. -/// -public class NullDocumentRef : DocumentRef -{ - public NullDocumentRef(string id, Module collection, string cause) : base(id, collection) - { - Cause = cause; - } - /// - /// Gets or sets the cause that the document is null. - /// - /// - /// A string representing the cause that the document is null. - /// - public string Cause { get; } -} diff --git a/Fauna/Types/NullNamedDocumentRef.cs b/Fauna/Types/NullNamedDocumentRef.cs deleted file mode 100644 index 43e43756..00000000 --- a/Fauna/Types/NullNamedDocumentRef.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Fauna.Types; - -/// -/// Represents a reference to a named document that is null, including a reason for its null state. -/// This class extends NamedDocumentRef to provide additional context for null references in the database. -/// -public class NullNamedDocumentRef : NamedDocumentRef -{ - public NullNamedDocumentRef(string name, Module collection, string cause) : base(name, collection) - { - Cause = cause; - } - - /// - /// Gets or sets the cause that the document is null. - /// - /// - /// A string representing the cause that the document is null. - /// - public string Cause { get; } -} diff --git a/Fauna/Types/NullableDocument.cs b/Fauna/Types/NullableDocument.cs new file mode 100644 index 00000000..e62844c1 --- /dev/null +++ b/Fauna/Types/NullableDocument.cs @@ -0,0 +1,60 @@ +namespace Fauna.Types; + +/// +/// A wrapper class that allows and user-defined classes +/// to be null references. +/// +/// +public abstract class NullableDocument +{ + /// + /// The wrapped value. + /// + public T? Value { get; } + + public NullableDocument(T? value) + { + Value = value; + } +} + +/// +/// A class representing a null document returned by Fauna. +/// +/// +public class NullDocument : NullableDocument +{ + /// + /// The ID of the null document. + /// + public string Id { get; } + + /// + /// The Collection. + /// + public Module Collection { get; } + + /// + /// The Cause for the null document. + /// + public string Cause { get; } + + public NullDocument(string id, Module collection, string cause) : base(default) + { + Id = id; + Collection = collection; + Cause = cause; + } +} + + +/// +/// A class wrapping a non-null document returned by Fauna. +/// +/// +public class NonNullDocument : NullableDocument +{ + public NonNullDocument(T value) : base(value) + { + } +} \ No newline at end of file