Skip to content

Commit eb02b95

Browse files
committed
Significant improvement to AddRangeAsync for deserializing expando objects.
Added new custom serialization process to centralize serialization with wrappers and to enforce the not mapped attribute to be respected during serialization. Created multiple caching dictionaries. This allows after the first discovery of an attribute, mapped location, or more, it will be cached and not require rediscovery on follow up use. Removed all use case of Newtonsoft JSON serialization.
1 parent e222c7a commit eb02b95

File tree

7 files changed

+239
-45
lines changed

7 files changed

+239
-45
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Dynamic;
2+
using System.Linq.Expressions;
3+
4+
namespace Magic.IndexedDb.Helpers
5+
{
6+
public static class ExpandoToTypeConverter<T>
7+
{
8+
private static readonly Dictionary<string, Action<T, object?>> PropertySetters = new();
9+
private static readonly Dictionary<Type, bool> NonConcreteTypeCache = new();
10+
11+
private static readonly bool IsConcrete;
12+
private static readonly bool HasParameterlessConstructor;
13+
14+
static ExpandoToTypeConverter()
15+
{
16+
Type type = typeof(T);
17+
IsConcrete = !(type.IsAbstract || type.IsInterface);
18+
HasParameterlessConstructor = type.GetConstructor(Type.EmptyTypes) != null;
19+
20+
if (IsConcrete && HasParameterlessConstructor)
21+
{
22+
PrecomputePropertySetters(type);
23+
}
24+
}
25+
26+
private static void PrecomputePropertySetters(Type type)
27+
{
28+
foreach (var prop in type.GetProperties().Where(p => p.CanWrite))
29+
{
30+
var targetExp = Expression.Parameter(type);
31+
var valueExp = Expression.Parameter(typeof(object));
32+
33+
var convertedValueExp = Expression.Convert(valueExp, prop.PropertyType);
34+
35+
var propertySetterExp = Expression.Lambda<Action<T, object?>>(
36+
Expression.Assign(Expression.Property(targetExp, prop), convertedValueExp),
37+
targetExp, valueExp
38+
);
39+
40+
PropertySetters[prop.Name] = propertySetterExp.Compile();
41+
}
42+
}
43+
44+
public static T ConvertExpando(ExpandoObject expando)
45+
{
46+
Type type = typeof(T);
47+
48+
if (IsConcrete && HasParameterlessConstructor)
49+
{
50+
// Use the fastest method: Precomputed property setters
51+
var instance = Activator.CreateInstance<T>();
52+
var expandoDict = (IDictionary<string, object?>)expando;
53+
54+
foreach (var kvp in expandoDict)
55+
{
56+
if (PropertySetters.TryGetValue(kvp.Key, out var setter))
57+
{
58+
setter(instance, kvp.Value);
59+
}
60+
}
61+
62+
return instance;
63+
}
64+
else if (IsConcrete) // Concrete class without a parameterless constructor
65+
{
66+
var instance = Activator.CreateInstance(type);
67+
MagicSerializationHelper.PopulateObject(MagicSerializationHelper.SerializeObject(expando), instance);
68+
return (T)instance!;
69+
}
70+
else
71+
{
72+
// Last resort: If `T` is an interface or abstract class, fall back to full JSON deserialization
73+
var instance = MagicSerializationHelper.DeserializeObject<T>(MagicSerializationHelper.SerializeObject(expando))!;
74+
75+
// Check if we can cache this as a known type
76+
if (!NonConcreteTypeCache.ContainsKey(type))
77+
{
78+
NonConcreteTypeCache[type] = true;
79+
80+
// Dynamically compute property setters for this type
81+
PrecomputePropertySetters(type);
82+
}
83+
84+
return instance;
85+
}
86+
}
87+
}
88+
89+
90+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using Magic.IndexedDb.Models;
2+
using System.Text.Json;
3+
4+
namespace Magic.IndexedDb.Helpers
5+
{
6+
/// <summary>
7+
/// Helper to serialize between the Magic Library content to the JS. To communicate with Dexie.JS -
8+
/// Note I left this public only to allow it to be targeted by external projects for testing.
9+
/// </summary>
10+
public static class MagicSerializationHelper
11+
{
12+
public static string SerializeObject<T>(T value, MagicJsonSerializationSettings? settings = null)
13+
{
14+
if (settings == null)
15+
settings = new MagicJsonSerializationSettings();
16+
17+
if (value == null)
18+
throw new ArgumentNullException(nameof(value), "Object cannot be null");
19+
20+
return JsonSerializer.Serialize(value, settings.Options);
21+
}
22+
23+
public static T? DeserializeObject<T>(string json)
24+
{
25+
if (string.IsNullOrWhiteSpace(json))
26+
throw new ArgumentException("JSON cannot be null or empty.", nameof(json));
27+
28+
return JsonSerializer.Deserialize<T>(json);
29+
}
30+
31+
public static void PopulateObject<T>(T source, T target)
32+
{
33+
if (source == null || target == null)
34+
throw new ArgumentNullException("Source and target cannot be null");
35+
36+
var json = JsonSerializer.Serialize(source);
37+
var deserialized = JsonSerializer.Deserialize<T>(json);
38+
39+
foreach (var prop in typeof(T).GetProperties())
40+
{
41+
var value = prop.GetValue(deserialized);
42+
prop.SetValue(target, value);
43+
}
44+
}
45+
}
46+
47+
48+
}

Magic.IndexedDb/IndexDbManager.cs

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77
using Magic.IndexedDb.Models;
88
using Magic.IndexedDb.SchemaAnnotations;
99
using Microsoft.JSInterop;
10-
using Newtonsoft.Json;
11-
using Newtonsoft.Json.Linq;
12-
using Newtonsoft.Json.Serialization;
10+
using System.Text.Json.Nodes;
1311

1412
namespace Magic.IndexedDb
1513
{
@@ -233,9 +231,9 @@ public async Task AddRangeAsync<T>(
233231
T? myClass = null;
234232

235233
object? processedRecord = await ProcessRecordAsync(record, cancellationToken);
236-
if (processedRecord is ExpandoObject)
234+
if (processedRecord is ExpandoObject expando)
237235
{
238-
myClass = JsonConvert.DeserializeObject<T>(JsonConvert.SerializeObject(processedRecord));
236+
myClass = ExpandoToTypeConverter<T>.ConvertExpando(expando);
239237
IsExpando = true;
240238
}
241239
else
@@ -397,7 +395,7 @@ private Expression<Func<T, bool>> PreprocessPredicate<T>(Expression<Func<T, bool
397395
string? jsonQueryAdditions = null;
398396
if (query != null && query.storedMagicQueries != null && query.storedMagicQueries.Count > 0)
399397
{
400-
jsonQueryAdditions = Newtonsoft.Json.JsonConvert.SerializeObject(query.storedMagicQueries.ToArray());
398+
jsonQueryAdditions = MagicSerializationHelper.SerializeObject(query.storedMagicQueries.ToArray());
401399
}
402400
var propertyMappings = ManagerHelper.GeneratePropertyMapping<T>();
403401
IList<Dictionary<string, object>>? ListToConvert =
@@ -510,9 +508,13 @@ private TRecord ConvertIndexedDbRecordToCRecord<TRecord>(Dictionary<string, obje
510508

511509
private string GetJsonQueryFromExpression<T>(Expression<Func<T, bool>> predicate) where T : class
512510
{
513-
var serializerSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() };
514-
var conditions = new List<JObject>();
515-
var orConditions = new List<List<JObject>>();
511+
var serializerSettings = new MagicJsonSerializationSettings
512+
{
513+
UseCamelCase = true // Equivalent to setting CamelCasePropertyNamesContractResolver
514+
};
515+
516+
var conditions = new List<JsonObject>();
517+
var orConditions = new List<List<JsonObject>>();
516518

517519
void TraverseExpression(Expression expression, bool inOrBranch = false)
518520
{
@@ -656,14 +658,14 @@ void AddConditionInternal(MemberExpression? left, ConstantExpression? right, str
656658
columnName = propertyInfo.GetPropertyColumnName<MagicPrimaryKeyAttribute>();
657659

658660
bool _isString = false;
659-
JToken? valSend = null;
661+
JsonNode? valSend = null;
660662
if (right != null && right.Value != null)
661663
{
662-
valSend = JToken.FromObject(right.Value);
664+
valSend = JsonValue.Create(right.Value);
663665
_isString = right.Value is string;
664666
}
665667

666-
var jsonCondition = new JObject
668+
var jsonCondition = new JsonObject
667669
{
668670
{ "property", columnName },
669671
{ "operation", operation },
@@ -677,7 +679,7 @@ void AddConditionInternal(MemberExpression? left, ConstantExpression? right, str
677679
var currentOrConditions = orConditions.LastOrDefault();
678680
if (currentOrConditions == null)
679681
{
680-
currentOrConditions = new List<JObject>();
682+
currentOrConditions = new List<JsonObject>();
681683
orConditions.Add(currentOrConditions);
682684
}
683685
currentOrConditions.Add(jsonCondition);
@@ -697,7 +699,7 @@ void AddConditionInternal(MemberExpression? left, ConstantExpression? right, str
697699
orConditions.Add(conditions);
698700
}
699701

700-
return JsonConvert.SerializeObject(orConditions, serializerSettings);
702+
return MagicSerializationHelper.SerializeObject(orConditions, serializerSettings);
701703
}
702704

703705
/// <summary>

Magic.IndexedDb/Magic.IndexedDb.csproj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Razor">
1+
<Project Sdk="Microsoft.NET.Sdk.Razor">
22

33
<PropertyGroup>
44
<TargetFramework>net8.0</TargetFramework>
@@ -43,7 +43,6 @@
4343

4444
<ItemGroup>
4545
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.11" />
46-
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
4746
</ItemGroup>
4847

4948
</Project>

Magic.IndexedDb/Models/CustomContractResolver.cs

Lines changed: 0 additions & 29 deletions
This file was deleted.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Magic.IndexedDb.SchemaAnnotations;
2+
using System;
3+
using System.Collections.Concurrent;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Reflection;
7+
using System.Text;
8+
using System.Text.Json.Serialization;
9+
using System.Text.Json;
10+
using System.Threading.Tasks;
11+
12+
namespace Magic.IndexedDb.Models
13+
{
14+
internal class MagicContractResolver<T> : JsonConverter<T>
15+
{
16+
private static readonly ConcurrentDictionary<MemberInfo, bool> _cachedIgnoredProperties = new();
17+
18+
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
19+
{
20+
return JsonSerializer.Deserialize<T>(ref reader, options);
21+
}
22+
23+
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
24+
{
25+
if (value == null) return;
26+
27+
writer.WriteStartObject();
28+
29+
foreach (var property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance))
30+
{
31+
// Check cache first
32+
if (!_cachedIgnoredProperties.TryGetValue(property, out bool shouldIgnore))
33+
{
34+
shouldIgnore = property.GetCustomAttributes(inherit: true)
35+
.Any(a => a.GetType().FullName == typeof(MagicNotMappedAttribute).FullName);
36+
_cachedIgnoredProperties[property] = shouldIgnore;
37+
}
38+
39+
if (!shouldIgnore)
40+
{
41+
var propValue = property.GetValue(value);
42+
var propName = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
43+
writer.WritePropertyName(propName);
44+
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
45+
}
46+
}
47+
48+
writer.WriteEndObject();
49+
}
50+
}
51+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Text.Json.Serialization;
6+
using System.Text.Json;
7+
using System.Threading.Tasks;
8+
9+
namespace Magic.IndexedDb.Models
10+
{
11+
public class MagicJsonSerializationSettings
12+
{
13+
private JsonSerializerOptions _options = new();
14+
15+
public JsonSerializerOptions Options
16+
{
17+
get => _options;
18+
set => _options = value ?? new JsonSerializerOptions(); // Ensure it never gets null
19+
}
20+
21+
public bool UseCamelCase
22+
{
23+
get => _options.PropertyNamingPolicy == JsonNamingPolicy.CamelCase;
24+
set
25+
{
26+
_options = new JsonSerializerOptions(_options) // Clone existing settings
27+
{
28+
PropertyNamingPolicy = value ? JsonNamingPolicy.CamelCase : null
29+
};
30+
}
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)