Enhance JSON handling and add hybrid reference support

- Updated all projects to use `Newtonsoft.Json` v13.0.3 for consistency.
- Introduced `HybridReferenceResolver` for semantic and numeric ID handling.
- Refactored `SerializeObjectExtensions` to support deep JSON merging.
- Simplified `IId<T>` interface by removing `IEquatable<T>` constraint.
- Improved `AcSignalRDataSource` with robust `AddRange` and `CopyTo` methods.
- Added `JsonExtensionTests` for deep hierarchy, reference, and edge cases.
- Implemented `UnifiedMergeContractResolver` for custom JSON behavior.
- Optimized type/property caching with `TypeCache` and `CachedPropertyInfo`.
- Enhanced SignalR integration to fix primitive array deserialization issues.
- Introduced `JsonNoMergeCollection` attribute for replace-only collections.
- Added test DTOs and `TestDataFactory` for real-world scenario simulations.
- Improved performance with `ConcurrentDictionary` and `ObjectPool`.
- Fixed `$id`/`$ref` handling for non-semantic references and arrays.
This commit is contained in:
Loretta 2025-12-08 15:50:48 +01:00
parent f3ec941774
commit 166d97106d
11 changed files with 2323 additions and 53 deletions

View File

@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -17,7 +17,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -17,6 +17,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,991 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace AyCode.Core.Extensions
{
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class JsonNoMergeCollectionAttribute : Attribute { }
/// <summary>
/// Thread-safe object pool for reducing allocations
/// </summary>
internal sealed class ObjectPool<T> where T : class, new()
{
private readonly ConcurrentBag<T> _pool = new();
private readonly int _maxPoolSize;
public ObjectPool(int maxPoolSize = 32)
{
_maxPoolSize = maxPoolSize;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Rent() => _pool.TryTake(out var item) ? item : new T();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Return(T item)
{
if (_pool.Count < _maxPoolSize)
{
_pool.Add(item);
}
}
}
/// <summary>
/// Cached property metadata for faster JSON processing
/// </summary>
internal sealed class CachedPropertyInfo
{
public PropertyInfo Property { get; }
public string Name { get; }
public Type PropertyType { get; }
public bool IsIId { get; }
public Type? IdType { get; }
public bool IsIIdCollection { get; }
public Type? CollectionElementType { get; }
public Type? CollectionElementIdType { get; }
public bool ShouldSkip { get; }
public bool CanRead { get; }
public bool HasIndexParameters { get; }
public CachedPropertyInfo(PropertyInfo prop)
{
Property = prop;
Name = prop.Name;
PropertyType = prop.PropertyType;
CanRead = prop.CanRead;
HasIndexParameters = prop.GetIndexParameters().Length > 0;
// Pre-compute skip condition
ShouldSkip = !CanRead || HasIndexParameters || TypeCache.HasJsonIgnoreAttribute(prop);
if (!ShouldSkip)
{
var (isId, idType) = TypeCache.GetIdInfo(PropertyType);
IsIId = isId;
IdType = idType;
if (!IsIId && typeof(IEnumerable).IsAssignableFrom(PropertyType) && PropertyType != typeof(string))
{
CollectionElementType = TypeCache.GetElementType(PropertyType);
if (CollectionElementType != null)
{
var (elemIsId, elemIdType) = TypeCache.GetIdInfo(CollectionElementType);
IsIIdCollection = elemIsId;
CollectionElementIdType = elemIdType;
}
}
}
}
}
static class TypeCache
{
// 🔑 OPTIMIZATION: Use ConcurrentDictionary for lock-free reads
private static readonly ConcurrentDictionary<Type, (bool IsId, Type? IdType)> _idCache = new();
private static readonly ConcurrentDictionary<Type, Type?> _collectionElemCache = new();
// 🔑 OPTIMIZATION: Cache type names for semantic key generation
private static readonly ConcurrentDictionary<Type, string> _typeNameCache = new();
// 🔑 OPTIMIZATION: Cache fully processed property info for types
private static readonly ConcurrentDictionary<Type, CachedPropertyInfo[]> _cachedPropertyInfoCache = new();
// 🔑 OPTIMIZATION: Cache JsonIgnore attribute check results per property
private static readonly ConcurrentDictionary<PropertyInfo, bool> _jsonIgnoreCache = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string GetTypeName(Type t)
{
return _typeNameCache.GetOrAdd(t, static type => type.Name);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (bool IsId, Type? IdType) GetIdInfo(Type t)
{
return _idCache.GetOrAdd(t, static type =>
{
Type? foundInterface = null;
var interfaces = type.GetInterfaces();
for (var i = 0; i < interfaces.Length; i++)
{
var iface = interfaces[i];
if (!iface.IsGenericType || iface.GetGenericTypeDefinition() != typeof(IId<>)) continue;
foundInterface = iface;
break;
}
var idType = foundInterface?.GetGenericArguments()[0];
return (foundInterface != null && idType != null && idType.IsValueType, idType);
});
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Type? GetElementType(Type t)
{
return _collectionElemCache.GetOrAdd(t, static type =>
{
if (type.IsArray) return type.GetElementType();
var interfaces = type.GetInterfaces();
Type? ienum = null;
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
{
ienum = type;
}
else
{
for (var i = 0; i < interfaces.Length; i++)
{
var iface = interfaces[i];
if (!iface.IsGenericType || iface.GetGenericTypeDefinition() != typeof(IEnumerable<>)) continue;
ienum = iface;
break;
}
}
return ienum?.GetGenericArguments()[0];
});
}
// 🔑 OPTIMIZATION: Get fully cached property info with all computed values
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static CachedPropertyInfo[] GetCachedProperties(Type t)
{
return _cachedPropertyInfoCache.GetOrAdd(t, static type =>
{
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var cached = new CachedPropertyInfo[props.Length];
for (var i = 0; i < props.Length; i++)
{
cached[i] = new CachedPropertyInfo(props[i]);
}
return cached;
});
}
// 🔑 OPTIMIZATION: Cache JsonIgnore attribute check
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool HasJsonIgnoreAttribute(PropertyInfo prop)
{
return _jsonIgnoreCache.GetOrAdd(prop, static p =>
p.GetCustomAttribute<JsonIgnoreAttribute>() != null ||
p.GetCustomAttribute<System.Text.Json.Serialization.JsonIgnoreAttribute>() != null);
}
}
public static class ReferenceRegistry
{
private const string ContextKey = "SemanticReferenceRegistry";
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Dictionary<string, object> GetRegistry(JsonSerializer serializer)
{
if (serializer.Context.Context is not Dictionary<object, object> globalMap)
{
globalMap = new Dictionary<object, object>(4);
serializer.Context = new StreamingContext(StreamingContextStates.All, globalMap);
}
if (globalMap.TryGetValue(ContextKey, out var registry) && registry is Dictionary<string, object> typedRegistry)
{
return typedRegistry;
}
var newRegistry = new Dictionary<string, object>(64, StringComparer.Ordinal);
globalMap[ContextKey] = newRegistry;
return newRegistry;
}
}
public static class IdExtractor
{
// 🔑 OPTIMIZATION: Cache the "Id" property name
private static readonly string IdPropertyName = nameof(IId<int>.Id);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TId GetIdFromJToken<TId>(JObject obj) where TId : struct
{
var idPropToken = obj.GetValue(IdPropertyName, StringComparison.OrdinalIgnoreCase);
if (idPropToken == null || idPropToken.Type == JTokenType.Null)
{
return default;
}
// 🔑 OPTIMIZATION: Fast path for common types with direct type checks
if (typeof(TId) == typeof(int))
{
return (TId)(object)idPropToken.Value<int>();
}
if (typeof(TId) == typeof(Guid))
{
var stringValue = idPropToken.Value<string>();
if (string.IsNullOrEmpty(stringValue))
return default;
return Guid.TryParse(stringValue, out var guidValue) ? (TId)(object)guidValue : default;
}
if (typeof(TId) == typeof(long))
{
return (TId)(object)idPropToken.Value<long>();
}
try
{
return idPropToken.Value<TId>();
}
catch
{
return default;
}
}
}
public class IdAwareObjectConverter<TItem, TId> : JsonConverter
where TItem : class, IId<TId>, new() where TId : struct
{
private const string SemanticIdKey = "$id";
private const string SemanticRefKey = "$ref";
// 🔑 OPTIMIZATION: Cache type name prefix (computed once per generic instantiation)
private static readonly string TypeNamePrefix = TypeCache.GetTypeName(typeof(TItem)) + "_";
private static readonly EqualityComparer<TId> IdComparer = EqualityComparer<TId>.Default;
// 🔑 OPTIMIZATION: Shared DefaultContractResolver instance
private static readonly DefaultContractResolver SharedDefaultResolver = new();
// 🔑 OPTIMIZATION: Cache converter instances for nested types
private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> NestedConverterCache = new();
// 🔑 OPTIMIZATION: Cache JsonSerializerSettings template to clone from
private static JsonSerializerSettings? _cachedSettingsTemplate;
// 🔑 OPTIMIZATION: Cache the CachedPropertyInfo array for TItem
private static readonly CachedPropertyInfo[] CachedProperties = TypeCache.GetCachedProperties(typeof(TItem));
public override bool CanRead => true;
public override bool CanConvert(Type objectType) => typeof(TItem).IsAssignableFrom(objectType);
public override bool CanWrite => true;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string GetSemanticKey(TId id) => string.Concat(TypeNamePrefix, id.ToString());
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsSemanticKey(string key) => key.Contains('_');
public override void WriteJson(JsonWriter writer, [NotNull] object? value, JsonSerializer serializer)
{
if (value is not TItem item || IdComparer.Equals(item.Id, default))
{
serializer.Serialize(writer, value);
return;
}
var registry = ReferenceRegistry.GetRegistry(serializer);
var semanticKey = GetSemanticKey(item.Id);
if (!registry.TryAdd(semanticKey, item))
{
writer.WriteStartObject();
writer.WritePropertyName(SemanticRefKey);
writer.WriteValue(semanticKey);
writer.WriteEndObject();
return;
}
JObject jsonObject;
using (var subWriter = new JTokenWriter())
{
var tempSerializer = JsonSerializer.CreateDefault(GetOrCreateSettingsTemplate(serializer));
tempSerializer.Context = serializer.Context;
tempSerializer.Serialize(subWriter, value);
jsonObject = (JObject)subWriter.Token!;
}
jsonObject.Remove(SemanticIdKey);
jsonObject.Remove(SemanticRefKey);
jsonObject.AddFirst(new JProperty(SemanticIdKey, semanticKey));
ProcessNestedIIdProperties(jsonObject, value, serializer);
// ✅ FIX: Use StringWriter to avoid version compatibility issues with JToken.ToString(Formatting)
writer.WriteRawValue(JTokenToString(jsonObject));
}
/// <summary>
/// Converts JToken to string using StringWriter to avoid Newtonsoft.Json version compatibility issues.
/// The JToken.ToString(Formatting) method signature may differ between versions.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string JTokenToString(JToken token)
{
using var sw = new StringWriter();
using var jw = new JsonTextWriter(sw);
jw.Formatting = Formatting.None;
token.WriteTo(jw);
return sw.ToString();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static JsonSerializerSettings GetOrCreateSettingsTemplate(JsonSerializer serializer)
{
// 🔑 OPTIMIZATION: Reuse settings template (note: this is safe because we only read from it)
if (_cachedSettingsTemplate != null)
{
return _cachedSettingsTemplate;
}
_cachedSettingsTemplate = new JsonSerializerSettings
{
ReferenceLoopHandling = serializer.ReferenceLoopHandling,
NullValueHandling = serializer.NullValueHandling,
ObjectCreationHandling = serializer.ObjectCreationHandling,
PreserveReferencesHandling = serializer.PreserveReferencesHandling,
ContractResolver = SharedDefaultResolver
};
return _cachedSettingsTemplate;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static JsonConverter GetOrCreateConverter(Type propType, Type idType)
{
var key = (propType, idType);
return NestedConverterCache.GetOrAdd(key, static k =>
(JsonConverter)Activator.CreateInstance(
typeof(IdAwareObjectConverter<,>).MakeGenericType(k.Item1, k.Item2))!);
}
/// <summary>
/// Recursively removes non-semantic (numeric) $id and $ref tokens from a JToken hierarchy.
/// Semantic keys (containing '_') are preserved for the custom IId reference system.
/// </summary>
private static void RemoveReferenceTokens(JToken token)
{
if (token is JObject obj)
{
// Only remove $id if it's a numeric reference (not semantic)
var idProp = obj.Property(SemanticIdKey);
if (idProp != null)
{
var idValue = idProp.Value?.ToString();
if (idValue != null && !idValue.Contains('_'))
{
idProp.Remove();
}
}
// Only remove $ref if it's a numeric reference (not semantic)
var refProp = obj.Property(SemanticRefKey);
if (refProp != null)
{
var refValue = refProp.Value?.ToString();
if (refValue != null && !refValue.Contains('_'))
{
refProp.Remove();
}
}
foreach (var prop in obj.Properties().ToList())
{
RemoveReferenceTokens(prop.Value);
}
}
else if (token is JArray arr)
{
foreach (var item in arr)
{
RemoveReferenceTokens(item);
}
}
}
private static void ProcessNestedIIdProperties(JObject jsonObject, object value, JsonSerializer serializer)
{
var type = value.GetType();
// 🔑 OPTIMIZATION: Use fully cached property info
var properties = TypeCache.GetCachedProperties(type);
// 🔑 OPTIMIZATION: Build property lookup dictionary once for fast access
Dictionary<string, JProperty>? propLookup = null;
for (var i = 0; i < properties.Length; i++)
{
var cachedProp = properties[i];
// 🔑 OPTIMIZATION: Use pre-computed skip flag
if (cachedProp.ShouldSkip) continue;
// 🔑 OPTIMIZATION: Skip properties that aren't IId or IId collections
if (!cachedProp.IsIId && !cachedProp.IsIIdCollection) continue;
// Safely get property value
object? propValue;
try
{
propValue = cachedProp.Property.GetValue(value);
}
catch
{
continue;
}
if (propValue == null) continue;
// 🔑 OPTIMIZATION: Lazy-initialize property lookup only when needed
propLookup ??= BuildPropertyLookup(jsonObject);
if (!propLookup.TryGetValue(cachedProp.Name, out var jsonProp)) continue;
// Handle IId property
if (cachedProp.IsIId && cachedProp.IdType != null)
{
if (jsonProp.Value is not JObject) continue;
var converter = GetOrCreateConverter(cachedProp.PropertyType, cachedProp.IdType);
using var tokenWriter = new JTokenWriter();
converter.WriteJson(tokenWriter, propValue, serializer);
if (tokenWriter.Token != null)
{
jsonProp.Value = tokenWriter.Token;
}
}
// Handle IId collection
else if (cachedProp.IsIIdCollection && cachedProp.CollectionElementType != null && cachedProp.CollectionElementIdType != null)
{
if (jsonProp.Value is not JArray || propValue is not IEnumerable enumerable) continue;
var converter = GetOrCreateConverter(cachedProp.CollectionElementType, cachedProp.CollectionElementIdType);
var newArray = new JArray();
foreach (var item in enumerable)
{
if (item == null) continue;
using var tokenWriter = new JTokenWriter();
converter.WriteJson(tokenWriter, item, serializer);
if (tokenWriter.Token != null)
{
newArray.Add(tokenWriter.Token);
}
}
jsonProp.Value = newArray;
}
}
}
// 🔑 OPTIMIZATION: Build a dictionary for O(1) property lookups instead of O(n) JObject.Property() calls
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Dictionary<string, JProperty> BuildPropertyLookup(JObject jsonObject)
{
var lookup = new Dictionary<string, JProperty>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in jsonObject.Properties())
{
lookup[prop.Name] = prop;
}
return lookup;
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null) return null;
var jsonObject = JObject.Load(reader);
var registry = ReferenceRegistry.GetRegistry(serializer);
var refToken = jsonObject.GetValue(SemanticRefKey);
if (refToken != null)
{
var refKey = refToken.ToString();
if (!IsSemanticKey(refKey)) return null;
if (registry.TryGetValue(refKey, out var registeredObject) && registeredObject is TItem existingRef)
{
return existingRef;
}
return null;
}
var incomingId = IdExtractor.GetIdFromJToken<TId>(jsonObject);
var isIdentifiable = !IdComparer.Equals(incomingId, default);
var semanticIdKey = GetSemanticKey(incomingId);
TItem finalItem;
if (existingValue is TItem existing)
{
finalItem = existing;
}
else if (isIdentifiable && registry.TryGetValue(semanticIdKey, out var foundObject) && foundObject is TItem foundInRegistry)
{
finalItem = foundInRegistry;
}
else
{
finalItem = new TItem();
}
if (isIdentifiable)
{
registry[semanticIdKey] = finalItem;
}
// Remove all $id and $ref tokens recursively to prevent conflicts
// with Newtonsoft's built-in reference resolver
RemoveReferenceTokens(jsonObject);
using var subReader = jsonObject.CreateReader();
serializer.Populate(subReader, finalItem);
return finalItem;
}
}
public class IdAwareCollectionMergeConverter<TItem, TId> : JsonConverter
where TItem : class, IId<TId>, new() where TId : struct
{
private const string SemanticIdKey = "$id";
private const string SemanticRefKey = "$ref";
private static readonly string TypeNamePrefix = TypeCache.GetTypeName(typeof(TItem)) + "_";
private static readonly EqualityComparer<TId> IdComparer = EqualityComparer<TId>.Default;
public override bool CanRead => true;
public override bool CanConvert(Type objectType) =>
typeof(ICollection<TItem>).IsAssignableFrom(objectType) || typeof(IEnumerable<TItem>).IsAssignableFrom(objectType);
public override bool CanWrite => false;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string GetSemanticKey(TId id) => string.Concat(TypeNamePrefix, id.ToString());
/// <summary>
/// Recursively removes all $id and $ref tokens from a JToken hierarchy
/// to prevent conflicts with Newtonsoft's built-in reference resolver.
/// </summary>
private static void RemoveReferenceTokens(JToken token)
{
if (token is JObject obj)
{
// Only remove $id if it's a numeric reference (not semantic)
var idProp = obj.Property(SemanticIdKey);
if (idProp != null)
{
var idValue = idProp.Value?.ToString();
if (idValue != null && !idValue.Contains('_'))
{
idProp.Remove();
}
}
// Only remove $ref if it's a numeric reference (not semantic)
var refProp = obj.Property(SemanticRefKey);
if (refProp != null)
{
var refValue = refProp.Value?.ToString();
if (refValue != null && !refValue.Contains('_'))
{
refProp.Remove();
}
}
foreach (var prop in obj.Properties().ToList())
{
RemoveReferenceTokens(prop.Value);
}
}
else if (token is JArray arr)
{
foreach (var item in arr)
{
RemoveReferenceTokens(item);
}
}
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null) return existingValue;
if (existingValue is not IList targetList)
{
var jsonArrayFallback = JArray.Load(reader);
return jsonArrayFallback.ToObject(objectType, serializer);
}
// 🔑 FIX: Check if collection is fixed-size (e.g., array)
var isFixedSize = targetList.IsFixedSize;
var jsonArray = JArray.Load(reader);
var registry = ReferenceRegistry.GetRegistry(serializer);
// 🔑 OPTIMIZATION: Pre-size dictionary based on existing list count
var existingItemsMap = new Dictionary<TId, TItem>(targetList.Count);
// 🔑 OPTIMIZATION: Direct iteration
for (var index = 0; index < targetList.Count; index++)
{
var targetItem = targetList[index];
if (targetItem is TItem item && !IdComparer.Equals(item.Id, default))
{
existingItemsMap[item.Id] = item;
}
}
// Register existing items in registry
foreach (var kvp in existingItemsMap)
{
registry[GetSemanticKey(kvp.Key)] = kvp.Value;
}
// 🔑 OPTIMIZATION: Pre-size collections
var jsonCount = jsonArray.Count;
var finalItems = new List<TItem>(jsonCount + existingItemsMap.Count);
var processedIds = new HashSet<TId>(jsonCount);
// 🔑 OPTIMIZATION: Process JSON array with direct indexing
for (var i = 0; i < jsonCount; i++)
{
var itemToken = jsonArray[i];
TItem? itemResult = null;
if (itemToken is JObject jObj)
{
var incomingId = IdExtractor.GetIdFromJToken<TId>(jObj);
var hasId = !IdComparer.Equals(incomingId, default);
TItem? existingItem = null;
if (hasId && existingItemsMap.TryGetValue(incomingId, out var found))
{
existingItem = found;
}
if (existingItem != null)
{
// Remove all $id and $ref tokens recursively to prevent conflicts
RemoveReferenceTokens(jObj);
using var subReader = jObj.CreateReader();
serializer.Populate(subReader, existingItem);
itemResult = existingItem;
}
else
{
// Remove all $id and $ref tokens recursively to prevent conflicts
RemoveReferenceTokens(jObj);
itemResult = jObj.ToObject<TItem>(serializer);
}
}
else
{
itemResult = itemToken.ToObject<TItem>(serializer);
}
if (itemResult == null) continue;
var currentId = itemResult.Id;
var isIdentifiable = !IdComparer.Equals(currentId, default);
if (isIdentifiable)
{
if (processedIds.Add(currentId))
{
finalItems.Add(itemResult);
}
}
else
{
finalItems.Add(itemResult);
}
}
// KEEP logic
foreach (var kvp in existingItemsMap)
{
if (processedIds.Add(kvp.Key))
{
finalItems.Add(kvp.Value);
}
}
// 🔑 FIX: Handle fixed-size collections (arrays) by returning a new array
if (isFixedSize)
{
var resultArray = new TItem[finalItems.Count];
for (var i = 0; i < finalItems.Count; i++)
{
resultArray[i] = finalItems[i];
}
return resultArray;
}
// 🔑 OPTIMIZATION: Use AddRange for List<T>
targetList.Clear();
if (targetList is List<TItem> typedList)
{
typedList.AddRange(finalItems);
}
else
{
for (var i = 0; i < finalItems.Count; i++)
{
targetList.Add(finalItems[i]);
}
}
return targetList;
}
public override void WriteJson(JsonWriter writer, [NotNull] object? value, JsonSerializer serializer)
{
throw new InvalidOperationException("IdAwareCollectionMergeConverter is read-only.");
}
}
public class UnifiedMergeContractResolver : DefaultContractResolver
{
private static readonly HashSet<Type> PrimitiveTypes =
[
typeof(string), typeof(decimal), typeof(DateTime),
typeof(DateTimeOffset), typeof(Guid), typeof(TimeSpan),
typeof(bool), typeof(byte), typeof(sbyte), typeof(short),
typeof(ushort), typeof(int), typeof(uint), typeof(long),
typeof(ulong), typeof(float), typeof(double), typeof(char)
];
// 🔑 OPTIMIZATION: Cache converter instances per type pair
private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> ObjectConverterCache = new();
private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> CollectionConverterCache = new();
// 🔑 OPTIMIZATION: Cache JsonNoMergeCollection attribute check per member
private static readonly ConcurrentDictionary<MemberInfo, bool> NoMergeAttributeCache = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsPrimitive(Type t)
{
if (t.IsPrimitive || PrimitiveTypes.Contains(t))
{
return true;
}
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>))
{
return IsPrimitive(t.GetGenericArguments()[0]);
}
return false;
}
/// <summary>
/// 🔑 FIX: Check if type is a primitive element array/collection.
/// These types should NOT have custom reference handling applied.
/// This fixes the SignalR loadRelations=true becoming false issue.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsPrimitiveElementCollection(Type type)
{
if (type == typeof(string)) return false;
Type? elementType = null;
if (type.IsArray)
{
elementType = type.GetElementType();
}
else if (type.IsGenericType && typeof(IEnumerable).IsAssignableFrom(type))
{
var genericArgs = type.GetGenericArguments();
if (genericArgs.Length == 1)
{
elementType = genericArgs[0];
}
}
if (elementType == null) return false;
return IsPrimitive(elementType) || elementType.IsEnum;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsCollectionType(Type type)
{
if (type == typeof(string) || type.IsPrimitive) return false;
return type.IsArray || typeof(IEnumerable).IsAssignableFrom(type);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool HasNoMergeAttribute(MemberInfo member)
{
return NoMergeAttributeCache.GetOrAdd(member, static m =>
m.GetCustomAttribute<JsonNoMergeCollectionAttribute>() != null);
}
/// <summary>
/// 🔑 FIX: Override CreateArrayContract to disable reference handling for primitive arrays.
/// This prevents issues where [true] becomes [false] due to $id/$ref handling on primitives.
/// </summary>
protected override JsonArrayContract CreateArrayContract(Type objectType)
{
var contract = base.CreateArrayContract(objectType);
// Disable reference handling for primitive element arrays
if (IsPrimitiveElementCollection(objectType))
{
contract.ItemIsReference = false;
contract.IsReference = false;
}
return contract;
}
protected override JsonObjectContract CreateObjectContract(Type objectType)
{
var contract = base.CreateObjectContract(objectType);
var (isId, idType) = TypeCache.GetIdInfo(objectType);
if (isId && idType != null && !IsPrimitive(objectType))
{
var key = (objectType, idType);
contract.Converter = ObjectConverterCache.GetOrAdd(key, static k =>
(JsonConverter)Activator.CreateInstance(
typeof(IdAwareObjectConverter<,>).MakeGenericType(k.Item1, k.Item2))!);
}
return contract;
}
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var property = base.CreateProperty(member, memberSerialization);
var t = property.PropertyType;
if (t == null) return property;
// 🔑 FIX: Skip custom handling for primitive element collections
// Let Newtonsoft handle these with default behavior
if (IsPrimitiveElementCollection(t))
{
property.ItemIsReference = false;
property.IsReference = false;
return property;
}
// 🔑 OPTIMIZATION: Use cached attribute check
var isExcludedFromMerge = HasNoMergeAttribute(member);
Type? elemType = null;
Type? idType = null;
var isCollection = IsCollectionType(t);
var isIdCollection = false;
if (isCollection)
{
elemType = TypeCache.GetElementType(t);
if (elemType != null)
{
var (hasId, elemIdType) = TypeCache.GetIdInfo(elemType);
if (hasId && elemIdType != null)
{
isIdCollection = true;
idType = elemIdType;
}
}
}
// Non-ID or excluded collections: Replace
if (isCollection && (!isIdCollection || isExcludedFromMerge))
{
property.ObjectCreationHandling = ObjectCreationHandling.Replace;
return property;
}
// ID collections: Merge Converter
if (isIdCollection && idType != null && elemType != null && !IsPrimitive(elemType))
{
var key = (elemType, idType);
property.Converter = CollectionConverterCache.GetOrAdd(key, static k =>
(JsonConverter)Activator.CreateInstance(
typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!);
property.ObjectCreationHandling = ObjectCreationHandling.Reuse;
return property;
}
return property;
}
}
public static class JsonPopulateExtensions
{
// 🔑 OPTIMIZATION: Cache converter instances for root-level list merging
private static readonly ConcurrentDictionary<(Type, Type), JsonConverter> RootConverterCache = new();
// 🔑 OPTIMIZATION: Cache UnifiedMergeContractResolver instance
private static readonly UnifiedMergeContractResolver SharedContractResolver = new();
public static void DeepPopulateWithMerge<T>(this T target, string json, JsonSerializerSettings? settings = null) where T : notnull
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(json);
settings ??= new JsonSerializerSettings();
if (settings.Context.Context is not Dictionary<object, object>)
{
settings.Context = new StreamingContext(StreamingContextStates.All, new Dictionary<object, object>(4));
}
// 🔑 OPTIMIZATION: Use shared contract resolver
settings.ContractResolver ??= SharedContractResolver;
var serializer = JsonSerializer.Create(settings);
var token = JToken.Parse(json);
// Handle root-level list merge
if (target is IList targetList)
{
var type = target.GetType();
var elemType = TypeCache.GetElementType(type);
if (elemType != null)
{
var (isId, idType) = TypeCache.GetIdInfo(elemType);
if (isId && idType != null)
{
var key = (elemType, idType);
var converterInstance = RootConverterCache.GetOrAdd(key, static k =>
(JsonConverter)Activator.CreateInstance(
typeof(IdAwareCollectionMergeConverter<,>).MakeGenericType(k.Item1, k.Item2))!);
using var reader = token.CreateReader();
converterInstance.ReadJson(reader, target.GetType(), target, serializer);
return;
}
}
}
// Normal object-level merge
using (var reader = token.CreateReader())
{
serializer.Populate(reader, target);
}
}
}
}

View File

@ -7,25 +7,110 @@ using Newtonsoft.Json.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text.RegularExpressions;
namespace AyCode.Core.Extensions;
/// <summary>
/// Hybrid reference resolver that uses semantic IDs for IId&lt;T&gt; types
/// and standard numeric IDs for other types.
/// </summary>
public class HybridReferenceResolver : IReferenceResolver
{
private readonly Dictionary<string, object> _idToObject = new(StringComparer.Ordinal);
private readonly Dictionary<object, string> _objectToId = new(ReferenceEqualityComparer.Instance);
private int _nextNumericId = 1;
public void AddReference(object context, string reference, object value)
{
_idToObject[reference] = value;
_objectToId[value] = reference;
}
public string GetReference(object context, object value)
{
if (_objectToId.TryGetValue(value, out var existingRef))
{
return existingRef;
}
// Check if value implements IId<T>
var type = value.GetType();
var (isId, idType) = TypeCache.GetIdInfo(type);
string newRef;
if (isId && idType != null)
{
// Use semantic ID for IId<T> types
var idProperty = type.GetProperty("Id");
var idValue = idProperty?.GetValue(value);
if (idValue != null && !idValue.Equals(GetDefault(idType)))
{
newRef = $"{type.Name}_{idValue}";
}
else
{
// Fallback to numeric for IId types with default Id
newRef = (_nextNumericId++).ToString();
}
}
else
{
// Use numeric ID for non-IId types
newRef = (_nextNumericId++).ToString();
}
_idToObject[newRef] = value;
_objectToId[value] = newRef;
return newRef;
}
public bool IsReferenced(object context, object value)
{
return _objectToId.ContainsKey(value);
}
public object ResolveReference(object context, string reference)
{
_idToObject.TryGetValue(reference, out var value);
return value!;
}
private static object? GetDefault(Type type)
{
return type.IsValueType ? Activator.CreateInstance(type) : null;
}
}
/// <summary>
/// Reference equality comparer for proper object identity comparison
/// </summary>
internal class ReferenceEqualityComparer : IEqualityComparer<object>
{
public static readonly ReferenceEqualityComparer Instance = new();
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
}
public static class SerializeObjectExtensions
{
public static readonly JsonSerializerSettings Options = new()
// Hybrid settings that support both semantic IDs for IId<T> types
// and standard reference handling for other types
public static JsonSerializerSettings Options => new()
{
//TypeNameHandling = TypeNameHandling.All,
ContractResolver = new UnifiedMergeContractResolver(),
Context = new StreamingContext(StreamingContextStates.All, new Dictionary<object, object>()),
// Enable reference handling with our hybrid resolver
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
ReferenceLoopHandling = ReferenceLoopHandling.Serialize,
ReferenceResolverProvider = () => new HybridReferenceResolver(),
NullValueHandling = NullValueHandling.Ignore,
////System.Text.Json
//ReferenceHandler.Preserve
//ReferenceHandler.IgnoreCycles
};
public static string ToJson<T>(this T source, JsonSerializerSettings? options = null) => JsonConvert.SerializeObject(source, options ?? Options);
public static string ToJson<T>(this IQueryable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options);
public static string ToJson<T>(this IEnumerable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson => JsonConvert.SerializeObject(source, options ?? Options);
@ -34,7 +119,6 @@ public static class SerializeObjectExtensions
{
if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"');
//JsonConvert.PopulateObject(json, existingObject);
return JsonConvert.DeserializeObject<T>(json, options ?? Options);
}
@ -49,15 +133,12 @@ public static class SerializeObjectExtensions
{
if (json.StartsWith("\"") && json.EndsWith("\"")) json = Regex.Unescape(json).TrimStart('"').TrimEnd('"');
JsonConvert.PopulateObject(json, target, options ?? Options);
target.DeepPopulateWithMerge(json, options ?? Options);
}
/// <summary>
/// Using JSON
/// </summary>
/// <typeparam name="TDestination"></typeparam>
/// <param name="src"></param>
/// <param name="options"></param>
/// <returns></returns>
[return: NotNullIfNotNull(nameof(src))]
public static TDestination? CloneTo<TDestination>(this object? src, JsonSerializerSettings? options = null) where TDestination : class
=> src?.ToJson(options).JsonTo<TDestination>(options);
@ -65,14 +146,8 @@ public static class SerializeObjectExtensions
/// <summary>
/// Using JSON
/// </summary>
/// <param name="src"></param>
/// <param name="target"></param>
/// <param name="options"></param>
/// <returns></returns>
public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) => src?.ToJson(options).JsonTo(target, options);
//public static string ToJson(this Expression source) => JsonConvert.SerializeObject(source, Options);
public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message);
public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options);

View File

@ -1,12 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace AyCode.Core.Interfaces
{
public interface IId<T>
public interface IId<T>// : IEquatable<T>
{
T Id { get; set; }
}

View File

@ -12,7 +12,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="SendGrid" Version="9.29.3" />
</ItemGroup>

View File

@ -265,27 +265,6 @@ namespace AyCode.Services.Server.SignalRs
}, GetContextParams());
}
protected void AddRange(IEnumerable<TDataItem> source, TIList destination)
{
switch (destination)
{
case IAcObservableCollection dest:
dest.AddRange(source);
break;
case List<TDataItem> dest:
dest.AddRange(source);
break;
default:
{
foreach (var dataItem in source)
destination.Add(dataItem);
break;
}
}
}
public void AddRange(IEnumerable<TDataItem> source) => AddRange(source, InnerList);
public async Task LoadDataSource(TIList fromSource, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true)
{
Monitor.Enter(_syncRoot);
@ -293,12 +272,19 @@ namespace AyCode.Services.Server.SignalRs
try
{
if (!ReferenceEquals(InnerList, fromSource))
{
if (!setSourceToWorkingReferenceList)
{
fromSource.CopyTo(InnerList);
}
else
{
Clear(clearChangeTracking);
if (setSourceToWorkingReferenceList) SetWorkingReferenceList(fromSource);
else AddRange(fromSource);
}
}
else if (clearChangeTracking) TrackingItems.Clear();
//TODO: Átgondolni, OnDataSourceLoaded meghívódik mielőtt az adatok betöltődnének a .Forget() miatt! - J.
@ -329,7 +315,7 @@ namespace AyCode.Services.Server.SignalRs
resultitem = await SignalRClient.GetByIdAsync<TDataItem>(SignalRCrudTags.GetItemMessageTag, id);
if (resultitem == null) return null;
if (TryGetIndex(id, out var index)) InnerList[index] = resultitem;
if (TryGetIndex(id, out var index)) resultitem.CopyTo(InnerList[index]);//InnerList[index] = resultitem);
else InnerList.Add(resultitem);
var eventArgs = new ItemChangedEventArgs<TDataItem>(resultitem, TrackingState.Get);
@ -460,6 +446,27 @@ namespace AyCode.Services.Server.SignalRs
InnerList.Add(newValue);
}
public void AddRange(IEnumerable<TDataItem> source) => AddRange(source, InnerList);
protected void AddRange(IEnumerable<TDataItem> source, TIList destination)
{
//TODO: CHANGETRACKINGITEM - J.
switch (destination)
{
case IAcObservableCollection dest:
dest.AddRange(source);
break;
case List<TDataItem> dest:
dest.AddRange(source);
break;
default:
{
foreach (var dataItem in source)
destination.Add(dataItem);
break;
}
}
}
/// <summary>
/// AddMessageTag
/// </summary>
@ -905,7 +912,10 @@ namespace AyCode.Services.Server.SignalRs
TrackingItems.Remove(trackingItem);
if (TryGetIndex(originalId, out var index))
InnerList[index] = resultItem;
{
//InnerList[index] = resultItem;
resultItem.CopyTo(InnerList[index]);
}
var eventArgs = new ItemChangedEventArgs<TDataItem>(resultItem, trackingState);
if (OnDataSourceItemChanged != null) return OnDataSourceItemChanged.Invoke(eventArgs);
@ -918,7 +928,7 @@ namespace AyCode.Services.Server.SignalRs
if (TryGetIndex(trackingItem.CurrentValue.Id, out var index))
{
if (trackingItem.TrackingState == TrackingState.Add) InnerList.RemoveAt(index);
else InnerList[index] = trackingItem.OriginalValue!;
else trackingItem.OriginalValue!.CopyTo(InnerList[index]);//InnerList[index] = trackingItem.OriginalValue!);
}
else if (trackingItem.TrackingState != TrackingState.Add)
InnerList.Add(trackingItem.OriginalValue!);