diff --git a/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs b/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs index 1e61d8a00a..271e6394d3 100644 --- a/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs +++ b/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs @@ -161,6 +161,8 @@ private class CustomJsonSerializer : ISerializer public Casing Casing => throw new NotImplementedException(); + public CollectionStorage CollectionStorage => throw new NotImplementedException(); + public T FromJson(TextReader reader) { throw new NotImplementedException(); diff --git a/src/Marten.Testing/Linq/query_with_inner_query_with_CollectionToArrayJsonConverter_onProperty.cs b/src/Marten.Testing/Linq/query_with_inner_query_with_CollectionToArrayJsonConverter_onProperty.cs new file mode 100644 index 0000000000..79ea5e257d --- /dev/null +++ b/src/Marten.Testing/Linq/query_with_inner_query_with_CollectionToArrayJsonConverter_onProperty.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Marten.Services.Json; +using Newtonsoft.Json; +using Shouldly; +using Xunit; + +namespace Marten.Testing.Linq +{ + public class TypeWithInnerCollectionsWithJsonConverterAttribute + { + public Guid Id { get; set; } + public string Flatten { get; set; } + public string[] Array { get; set; } + public List List { get; set; } + public IList IList { get; set; } + + [JsonConverter(typeof(JsonNetCollectionToArrayJsonConverter))] + public IEnumerable Enumerable { get; set; } + + [JsonConverter(typeof(JsonNetCollectionToArrayJsonConverter))] + public IEnumerable IEnumerableFromArray { get; set; } + + [JsonConverter(typeof(JsonNetCollectionToArrayJsonConverter))] + public IEnumerable IEnumerbaleFromList { get; set; } + + [JsonConverter(typeof(JsonNetCollectionToArrayJsonConverter))] + public ICollection ICollection { get; set; } + + [JsonConverter(typeof(JsonNetCollectionToArrayJsonConverter))] + public IReadOnlyCollection IReadonlyCollection { get; set; } + + [JsonConverter(typeof(JsonNetCollectionToArrayJsonConverter))] + public IReadOnlyCollection IReadonlyCollectionOfInnerClasses { get; set; } + + public static TypeWithInnerCollectionsWithJsonConverterAttribute Create(params string[] array) + { + return new TypeWithInnerCollectionsWithJsonConverterAttribute() + { + Id = Guid.NewGuid(), + Flatten = array.Aggregate((i, j) => i + j), + Array = array, + List = array.ToList(), + IList = array.ToList(), + Enumerable = array.AsEnumerable(), + IEnumerableFromArray = array, + IEnumerbaleFromList = array.ToList(), + ICollection = array.ToList(), + IReadonlyCollection = array.ToList(), + IReadonlyCollectionOfInnerClasses = new List + { + new TypeWithInnerCollectionsWithJsonConverterAttribute() + { + Id = Guid.NewGuid(), + Flatten = array.Aggregate((i, j) => i + j), + Array = array, + List = array.ToList(), + IList = array.ToList(), + Enumerable = array.AsEnumerable(), + IEnumerableFromArray = array, + ICollection = array.ToList(), + IEnumerbaleFromList = array.ToList(), + IReadonlyCollection = array.ToList(), + } + } + }; + } + }; + + [ControlledQueryStoryteller] + public class query_with_inner_query_with_CollectionToArrayJsonConverter_onProperty : IntegratedFixture + { + private static readonly TypeWithInnerCollectionsWithJsonConverterAttribute[] TestData = new TypeWithInnerCollectionsWithJsonConverterAttribute[] + { + TypeWithInnerCollectionsWithJsonConverterAttribute.Create("one", "two"), + TypeWithInnerCollectionsWithJsonConverterAttribute.Create("two", "three"), + TypeWithInnerCollectionsWithJsonConverterAttribute.Create("four", "five"), + }; + + private const string SearchPhrase = "two"; + + public static readonly TheoryData>> Predicates = new TheoryData>> + { + x => x.Array.Contains(SearchPhrase), + x => x.Enumerable.Contains(SearchPhrase), + x => x.IEnumerableFromArray.Contains(SearchPhrase), + x => x.IEnumerbaleFromList.Contains(SearchPhrase), + x => x.List.Contains(SearchPhrase), + x => x.IList.Contains(SearchPhrase), + x => x.ICollection.Contains(SearchPhrase), + x => x.IReadonlyCollection.Contains(SearchPhrase), + x => x.IReadonlyCollection.Where(e => e == SearchPhrase).Any(), + x => x.IReadonlyCollectionOfInnerClasses.Where(e => e.Flatten == "onetwo").Any() || x.IReadonlyCollectionOfInnerClasses.Where(e => e.Flatten == "twothree").Any() + }; + + [Theory] + [MemberData(nameof(Predicates))] + public async Task having_type_with_CollectionToArrayJsonConverter_can_query_against_array_of_string(Expression> predicate) + { + SetupTestData(); + + using (var query = theStore.QuerySession()) + { + var results = await query.Query() + .Where(predicate) + .ToListAsync(); + + results.Count.ShouldBe(2); + results.All(e => e.Enumerable.Contains(SearchPhrase)).ShouldBeTrue(); + } + } + + private void SetupTestData() + { + using (var session = theStore.OpenSession()) + { + session.Store(TestData); + session.SaveChanges(); + } + } + } +} \ No newline at end of file diff --git a/src/Marten.Testing/Linq/query_with_inner_query_with_global_CollectionStorage_WithArray.cs b/src/Marten.Testing/Linq/query_with_inner_query_with_global_CollectionStorage_WithArray.cs new file mode 100644 index 0000000000..0cc3de80d1 --- /dev/null +++ b/src/Marten.Testing/Linq/query_with_inner_query_with_global_CollectionStorage_WithArray.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Marten.Testing.Linq +{ + public class TypeWithInnerCollections + { + public Guid Id { get; set; } + public string Flatten { get; set; } + public string[] Array { get; set; } + public List List { get; set; } + public IList IList { get; set; } + + public IEnumerable Enumerable { get; set; } + + public IEnumerable IEnumerableFromArray { get; set; } + + public IEnumerable IEnumerbaleFromList { get; set; } + + public ICollection ICollection { get; set; } + + public IReadOnlyCollection IReadonlyCollection { get; set; } + + public IReadOnlyCollection IReadonlyCollectionOfInnerClasses { get; set; } + + public static TypeWithInnerCollections Create(params string[] array) + { + return new TypeWithInnerCollections() + { + Id = Guid.NewGuid(), + Flatten = array.Aggregate((i, j) => i + j), + Array = array, + List = array.ToList(), + IList = array.ToList(), + Enumerable = array.AsEnumerable(), + IEnumerableFromArray = array, + IEnumerbaleFromList = array.ToList(), + ICollection = array.ToList(), + IReadonlyCollection = array.ToList(), + IReadonlyCollectionOfInnerClasses = new List + { + new TypeWithInnerCollections() + { + Id = Guid.NewGuid(), + Flatten = array.Aggregate((i, j) => i + j), + Array = array, + List = array.ToList(), + IList = array.ToList(), + Enumerable = array.AsEnumerable(), + IEnumerableFromArray = array, + ICollection = array.ToList(), + IEnumerbaleFromList = array.ToList(), + IReadonlyCollection = array.ToList(), + } + } + }; + } + } + + [ControlledQueryStoryteller] + public class query_with_inner_query_with_global_CollectionStorage_WithArray : IntegratedFixture + { + private static readonly TypeWithInnerCollections[] TestData = new TypeWithInnerCollections[] + { + TypeWithInnerCollections.Create("one", "two"), + TypeWithInnerCollections.Create("two", "three"), + TypeWithInnerCollections.Create("four", "five"), + }; + + private const string SearchPhrase = "two"; + + public static readonly TheoryData>> Predicates = new TheoryData>> + { + x => x.Array.Contains(SearchPhrase), + x => x.Enumerable.Contains(SearchPhrase), + x => x.IEnumerableFromArray.Contains(SearchPhrase), + x => x.IEnumerbaleFromList.Contains(SearchPhrase), + x => x.List.Contains(SearchPhrase), + x => x.IList.Contains(SearchPhrase), + x => x.ICollection.Contains(SearchPhrase), + x => x.IReadonlyCollection.Contains(SearchPhrase), + x => x.IReadonlyCollection.Where(e => e == SearchPhrase).Any(), + x => x.IReadonlyCollectionOfInnerClasses.Where(e => e.Flatten == "onetwo").Any() || x.IReadonlyCollectionOfInnerClasses.Where(e => e.Flatten == "twothree").Any() + }; + + [Theory] + [MemberData(nameof(Predicates))] + public async Task having_store_options_with_CollectionStorage_AsArray_can_query_against_array_of_string(Expression> predicate) + { + StoreOptions(options => + { + options.UseDefaultSerialization(collectionStorage: CollectionStorage.AsArray); + }); + SetupTestData(); + + using (var query = theStore.QuerySession()) + { + var results = await query.Query() + .Where(predicate) + .ToListAsync(); + + results.Count.ShouldBe(2); + results.All(e => e.Enumerable.Contains(SearchPhrase)).ShouldBeTrue(); + } + } + + private void SetupTestData() + { + using (var session = theStore.OpenSession()) + { + session.Store(TestData); + session.SaveChanges(); + } + } + } +} \ No newline at end of file diff --git a/src/Marten/ISerializer.cs b/src/Marten/ISerializer.cs index 19fb0a50a2..65c89e58a4 100644 --- a/src/Marten/ISerializer.cs +++ b/src/Marten/ISerializer.cs @@ -20,7 +20,6 @@ public interface ISerializer /// string ToJson(object document); - /// /// Deserialize a JSON string into an object of type T /// @@ -52,7 +51,10 @@ public interface ISerializer EnumStorage EnumStorage { get; } Casing Casing { get; } + + CollectionStorage CollectionStorage { get; } } + // ENDSAMPLE public enum EnumStorage @@ -67,4 +69,10 @@ public enum Casing CamelCase, SnakeCase } + + public enum CollectionStorage + { + Default, + AsArray + } } \ No newline at end of file diff --git a/src/Marten/Services/Json/JsonNetCollectionToArrayJsonConverter.cs b/src/Marten/Services/Json/JsonNetCollectionToArrayJsonConverter.cs new file mode 100644 index 0000000000..0bbe6bae46 --- /dev/null +++ b/src/Marten/Services/Json/JsonNetCollectionToArrayJsonConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace Marten.Services.Json +{ + /// + /// Serialize collection type property to JSON array using a custom Newtonsoft.Json JsonConverter + /// Note that without using custom `JsonConverter`, `Newtonsoft.Json` stores it as $type and $value. + /// Or you may need to resort to `Newtonsoft.Json.TypeNameHandling.None` which has its own side-effects + /// + public class JsonNetCollectionToArrayJsonConverter : JsonConverter + { + public static JsonNetCollectionToArrayJsonConverter Instance = new JsonNetCollectionToArrayJsonConverter(); + + private readonly static List _types = new List + { + typeof(ICollection<>), + typeof(IList<>), + typeof(IReadOnlyCollection<>), + typeof(IEnumerable<>) + }; + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var o = value as IEnumerable; + serializer.Serialize(writer, o?.ToArray()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return serializer.Deserialize(reader, objectType); + } + + public override bool CanConvert(Type objectType) + { + return _types.Contains(objectType) + || objectType.IsArray + || (objectType.IsGenericType && _types.Contains(objectType.GetGenericTypeDefinition())); + } + } +} \ No newline at end of file diff --git a/src/Marten/Services/Json/JsonNetContractResolver.cs b/src/Marten/Services/Json/JsonNetContractResolver.cs new file mode 100644 index 0000000000..ea02b23211 --- /dev/null +++ b/src/Marten/Services/Json/JsonNetContractResolver.cs @@ -0,0 +1,49 @@ +using System.Reflection; +using Marten.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Marten.Services.Json +{ + public class JsonNetContractResolver : DefaultContractResolver + { + public Casing Casing { get; } + + public CollectionStorage CollectionStorage { get; } + + public JsonNetContractResolver() + { + } + + public JsonNetContractResolver(Casing casing, CollectionStorage collectionStorage) + { + Casing = casing; + CollectionStorage = collectionStorage; + + SetNamingStrategy(casing); + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + + if (CollectionStorage == CollectionStorage.AsArray && JsonNetCollectionToArrayJsonConverter.Instance.CanConvert(property.PropertyType)) + { + property.Converter = JsonNetCollectionToArrayJsonConverter.Instance; + } + return property; + } + + private void SetNamingStrategy(Casing casing) + { + if (casing == Casing.CamelCase) + { + NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = true, OverrideSpecifiedNames = true }; + } + else if (casing == Casing.SnakeCase) + { + NamingStrategy = new SnakeCaseNamingStrategy { ProcessDictionaryKeys = true, OverrideSpecifiedNames = true }; + }; + } + } +} \ No newline at end of file diff --git a/src/Marten/Services/JsonNetSerializer.cs b/src/Marten/Services/JsonNetSerializer.cs index 9b1f4d2c92..6934b1a4ee 100644 --- a/src/Marten/Services/JsonNetSerializer.cs +++ b/src/Marten/Services/JsonNetSerializer.cs @@ -1,9 +1,9 @@ using System; using System.IO; using Baseline; +using Marten.Services.Json; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Serialization; namespace Marten.Services { @@ -11,8 +11,9 @@ public class JsonNetSerializer : ISerializer { private readonly JsonSerializer _clean = new JsonSerializer { - TypeNameHandling = TypeNameHandling.None, - DateFormatHandling = DateFormatHandling.IsoDateFormat + TypeNameHandling = TypeNameHandling.None, + DateFormatHandling = DateFormatHandling.IsoDateFormat, + ContractResolver = new JsonNetContractResolver() }; // SAMPLE: newtonsoft-configuration @@ -22,12 +23,14 @@ public class JsonNetSerializer : ISerializer // ISO 8601 formatting of DateTime's is mandatory DateFormatHandling = DateFormatHandling.IsoDateFormat, - MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead + MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead, + ContractResolver = new JsonNetContractResolver() }; + // ENDSAMPLE /// - /// Customize the inner Newtonsoft formatter. + /// Customize the inner Newtonsoft formatter. /// /// public void Customize(Action configure) @@ -77,7 +80,8 @@ public string ToCleanJson(object document) private EnumStorage _enumStorage = EnumStorage.AsInteger; private Casing _casing = Casing.Default; - + private CollectionStorage _collectionStorage = CollectionStorage.Default; + /// /// Specify whether .Net Enum values should be stored as integers or strings /// within the Json document. Default is AsInteger. @@ -119,22 +123,27 @@ public Casing Casing { _casing = value; - if (value == Casing.Default) - { - _serializer.ContractResolver = new DefaultContractResolver(); - _clean.ContractResolver = new DefaultContractResolver(); - } - else if (value == Casing.CamelCase) - { - _serializer.ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() }; - _clean.ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = true, OverrideSpecifiedNames = true } }; - } - else - { - _serializer.ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() }; - _clean.ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy { ProcessDictionaryKeys = true, OverrideSpecifiedNames = true } }; - } + _serializer.ContractResolver = new JsonNetContractResolver(_casing, CollectionStorage); + _clean.ContractResolver = new JsonNetContractResolver(_casing, CollectionStorage); + } + } + + /// + /// Specify whether collections should be stored as json arrays (without type names) + /// + public CollectionStorage CollectionStorage + { + get + { + return _collectionStorage; + } + set + { + _collectionStorage = value; + + _serializer.ContractResolver = new JsonNetContractResolver(Casing, _collectionStorage); + _clean.ContractResolver = new JsonNetContractResolver(Casing, _collectionStorage); } } - } + } } \ No newline at end of file diff --git a/src/Marten/StoreOptions.cs b/src/Marten/StoreOptions.cs index 284a50edfa..1b9c58cdc2 100644 --- a/src/Marten/StoreOptions.cs +++ b/src/Marten/StoreOptions.cs @@ -218,9 +218,10 @@ public void Serializer(ISerializer serializer) /// /// /// Casing style to be used in serialization - public void UseDefaultSerialization(EnumStorage enumStyle = EnumStorage.AsInteger, Casing casing = Casing.Default) + /// Allow to set collection storage as raw arrays (without explicit types) + public void UseDefaultSerialization(EnumStorage enumStyle = EnumStorage.AsInteger, Casing casing = Casing.Default, CollectionStorage collectionStorage = CollectionStorage.Default) { - Serializer(new JsonNetSerializer { EnumStorage = enumStyle, Casing = casing }); + Serializer(new JsonNetSerializer { EnumStorage = enumStyle, Casing = casing, CollectionStorage = collectionStorage }); } ///