Skip to content

Commit

Permalink
Fix to #30604 - Implement JSON serialization/deserialization via Utf8…
Browse files Browse the repository at this point in the history
…JsonReader/Utf8JsonWriter

Using Utf8JsonReader to read JSON data rather than caching it using DOM. This should reduce allocations significantly. Tricky part is that entity materializers are build in a way that assumes we have random access to all the data we need. This is not the case here.
We read JSON data sequentially and can only do it once, and we don't know the order in which we get the data. This is somewhat problematic in case where entity takes argument in the constructor. Those could be at the very end of the JSON string, so we must read all the data before we can instantiate the object, and populate it's properties and do navigation fixup.
This requires us reading all the JSON data, store them in local variables, and only when we are done reading we instantiate the entity and populate all the properties with data stored in those variables. This adds some allocations (specifically navigations).

We also have to disable de-duplication logic - we can't always safely re-read the JSON string, and definitely can't start reading it from arbitrary position, so now we have to add JSON string for every aggregate projected, even if we already project it's parent.

Serialization implementation (i.e. Utf8JsonWriter) is pretty straighforward.

Also fix to #30993 - Query/Json: data corruption for tracking queries with nested json entities, then updating nested entities outside EF and re-querying

Fix is to recognize and modify shaper in case of tracking query, so that nav expansions are not skipped when parent entity is found in Change Tracker. This is necessary to fix alongside streaming, because now we throw exception from reader (unexpected token) if we don't process the entire stream correctly. Before it would be silently ignored apart from the edge case described in the bug.

Fixes #30604
Fixes #30993
  • Loading branch information
maumar committed Jul 10, 2023
1 parent 1bb18b9 commit 90c38c6
Show file tree
Hide file tree
Showing 22 changed files with 1,966 additions and 654 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/EFCore.Relational/Properties/RelationalStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,9 @@
<data name="JsonPropertyNameShouldBeConfiguredOnNestedNavigation" xml:space="preserve">
<value>The JSON property name should only be configured on nested owned navigations.</value>
</data>
<data name="JsonReaderInvalidTokenType" xml:space="preserve">
<value>Invalid token type: '{tokenType}'.</value>
</data>
<data name="JsonRequiredEntityWithNullJson" xml:space="preserve">
<value>Entity {entity} is required but the JSON element containing it is null.</value>
</data>
Expand Down
16 changes: 1 addition & 15 deletions src/EFCore.Relational/Query/Internal/JsonProjectionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,10 @@ public readonly struct JsonProjectionInfo
/// </summary>
public JsonProjectionInfo(
int jsonColumnIndex,
List<(IProperty?, int?, int?)> keyAccessInfo,
(string?, int?, int?)[] additionalPath)
List<(IProperty?, int?, int?)> keyAccessInfo)
{
JsonColumnIndex = jsonColumnIndex;
KeyAccessInfo = keyAccessInfo;
AdditionalPath = additionalPath;
}

/// <summary>
Expand Down Expand Up @@ -55,16 +53,4 @@ public JsonProjectionInfo(
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public List<(IProperty? KeyProperty, int? ConstantKeyValue, int? KeyProjectionIndex)> KeyAccessInfo { get; }

/// <summary>
/// List of additional path elements, only one of the values in the tuple is non-null
/// this information is used to access the correct sub-element of a JsonElement that we materialized
/// </summary>
/// <remarks>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </remarks>
public (string? JsonPropertyName, int? ConstantArrayIndex, int? NonConstantArrayIndex)[] AdditionalPath { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ private static readonly MethodInfo GetParameterValueMethodInfo

private bool _indexBasedBinding;
private Dictionary<EntityProjectionExpression, ProjectionBindingExpression>? _entityProjectionCache;
private Dictionary<JsonQueryExpression, ProjectionBindingExpression>? _jsonQueryCache;
private List<Expression>? _clientProjections;

private readonly Dictionary<ProjectionMember, Expression> _projectionMapping = new();
Expand Down Expand Up @@ -66,7 +65,6 @@ public virtual Expression Translate(SelectExpression selectExpression, Expressio
{
_indexBasedBinding = true;
_entityProjectionCache = new Dictionary<EntityProjectionExpression, ProjectionBindingExpression>();
_jsonQueryCache = new Dictionary<JsonQueryExpression, ProjectionBindingExpression>();
_projectionMapping.Clear();
_clientProjections = new List<Expression>();

Expand Down Expand Up @@ -307,11 +305,8 @@ protected override Expression VisitExtension(Expression extensionExpression)
{
if (_indexBasedBinding)
{
if (!_jsonQueryCache!.TryGetValue(jsonQueryExpression, out var jsonProjectionBinding))
{
jsonProjectionBinding = AddClientProjection(jsonQueryExpression, typeof(ValueBuffer));
_jsonQueryCache[jsonQueryExpression] = jsonProjectionBinding;
}
_clientProjections!.Add(jsonQueryExpression);
var jsonProjectionBinding = new ProjectionBindingExpression(_selectExpression, _clientProjections.Count - 1, typeof(ValueBuffer));

return entityShaperExpression.Update(jsonProjectionBinding);
}
Expand Down Expand Up @@ -654,7 +649,6 @@ private ProjectionBindingExpression AddClientProjection(Expression expression, T
return new ProjectionBindingExpression(_selectExpression, existingIndex, type);
}

#pragma warning disable IDE0052 // Remove unread private members
private static T GetParameterValue<T>(QueryContext queryContext, string parameterName)
#pragma warning restore IDE0052 // Remove unread private members
=> (T)queryContext.ParameterValues[parameterName]!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.EntityFrameworkCore.Query.Internal;
using Microsoft.EntityFrameworkCore.Storage.Json;

namespace Microsoft.EntityFrameworkCore.Query;

Expand Down Expand Up @@ -67,8 +68,8 @@ private static readonly MethodInfo MaterializeJsonEntityMethodInfo
private static readonly MethodInfo MaterializeJsonEntityCollectionMethodInfo
= typeof(ShaperProcessingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(MaterializeJsonEntityCollection))!;

private static readonly MethodInfo ExtractJsonPropertyMethodInfo
= typeof(ShaperProcessingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ExtractJsonProperty))!;
private static readonly MethodInfo InverseCollectionFixupMethod
= typeof(ShaperProcessingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(InverseCollectionFixup))!;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TValue ThrowReadValueException<TValue>(
Expand Down Expand Up @@ -123,13 +124,6 @@ private static TValue ThrowExtractJsonPropertyException<TValue>(
exception);
}

private static T? ExtractJsonProperty<T>(JsonElement element, string propertyName, bool nullable)
=> nullable
? element.TryGetProperty(propertyName, out var jsonValue)
? jsonValue.Deserialize<T>()
: default
: element.GetProperty(propertyName).Deserialize<T>();

private static void IncludeReference<TEntity, TIncludingEntity, TIncludedEntity>(
QueryContext queryContext,
TEntity entity,
Expand Down Expand Up @@ -869,105 +863,189 @@ static async Task<RelationalDataReader> InitializeReaderAsync(
dataReaderContext.HasNext = false;
}

private static void IncludeJsonEntityReference<TIncludingEntity, TIncludedEntity>(
private static TEntity? MaterializeJsonEntity<TEntity>(
QueryContext queryContext,
JsonElement? jsonElement,
object[] keyPropertyValues,
TIncludingEntity entity,
Func<QueryContext, object[], JsonElement, TIncludedEntity> innerShaper,
Action<TIncludingEntity, TIncludedEntity> fixup)
where TIncludingEntity : class
where TIncludedEntity : class
JsonReaderData? jsonReaderData,
bool nullable,
Func<QueryContext, object[], JsonReaderData, TEntity> shaper)
where TEntity : class
{
if (jsonElement.HasValue && jsonElement.Value.ValueKind != JsonValueKind.Null)
if (jsonReaderData == null)
{
var included = innerShaper(queryContext, keyPropertyValues, jsonElement.Value);
fixup(entity, included);
return nullable
? null
: throw new InvalidOperationException(
RelationalStrings.JsonRequiredEntityWithNullJson(typeof(TEntity).Name));
}

var manager = new Utf8JsonReaderManager(jsonReaderData);
var tokenType = manager.CurrentReader.TokenType;

if (tokenType == JsonTokenType.Null)
{
return nullable
? null
: throw new InvalidOperationException(
RelationalStrings.JsonRequiredEntityWithNullJson(typeof(TEntity).Name));
}

if (tokenType != JsonTokenType.StartObject)
{
throw new InvalidOperationException(
RelationalStrings.JsonReaderInvalidTokenType(tokenType.ToString()));
}

manager.CaptureState();
var result = shaper(queryContext, keyPropertyValues, jsonReaderData);

return result;
}

private static void IncludeJsonEntityCollection<TIncludingEntity, TIncludedCollectionElement>(
private static TResult? MaterializeJsonEntityCollection<TEntity, TResult>(
QueryContext queryContext,
JsonElement? jsonElement,
object[] keyPropertyValues,
TIncludingEntity entity,
Func<QueryContext, object[], JsonElement, TIncludedCollectionElement> innerShaper,
Action<TIncludingEntity, TIncludedCollectionElement> fixup)
where TIncludingEntity : class
where TIncludedCollectionElement : class
JsonReaderData? jsonReaderData,
INavigationBase navigation,
Func<QueryContext, object[], JsonReaderData, TEntity> innerShaper)
where TEntity : class
where TResult : ICollection<TEntity>
{
if (jsonElement.HasValue && jsonElement.Value.ValueKind != JsonValueKind.Null)
if (jsonReaderData == null)
{
var newKeyPropertyValues = new object[keyPropertyValues.Length + 1];
Array.Copy(keyPropertyValues, newKeyPropertyValues, keyPropertyValues.Length);
return default;
}

var manager = new Utf8JsonReaderManager(jsonReaderData);
var tokenType = manager.CurrentReader.TokenType;

if (tokenType == JsonTokenType.Null)
{
return default;
}

var i = 0;
foreach (var jsonArrayElement in jsonElement.Value.EnumerateArray())
if (tokenType != JsonTokenType.StartArray)
{
throw new InvalidOperationException(
RelationalStrings.JsonReaderInvalidTokenType(tokenType.ToString()));
}

var collectionAccessor = navigation.GetCollectionAccessor();
var result = (TResult)collectionAccessor!.Create();

var newKeyPropertyValues = new object[keyPropertyValues.Length + 1];
Array.Copy(keyPropertyValues, newKeyPropertyValues, keyPropertyValues.Length);

tokenType = manager.MoveNext();

var i = 0;
while (tokenType != JsonTokenType.EndArray)
{
newKeyPropertyValues[^1] = ++i;

if (tokenType == JsonTokenType.StartObject)
{
newKeyPropertyValues[^1] = ++i;
manager.CaptureState();
var entity = innerShaper(queryContext, newKeyPropertyValues, jsonReaderData);
result.Add(entity);
manager = new Utf8JsonReaderManager(manager.Data);

var resultElement = innerShaper(queryContext, newKeyPropertyValues, jsonArrayElement);
if (manager.CurrentReader.TokenType != JsonTokenType.EndObject)
{
throw new InvalidOperationException(
RelationalStrings.JsonReaderInvalidTokenType(tokenType.ToString()));
}

fixup(entity, resultElement);
tokenType = manager.MoveNext();
}
}

manager.CaptureState();

return result;
}

private static TEntity? MaterializeJsonEntity<TEntity>(
private static void IncludeJsonEntityReference<TIncludingEntity, TIncludedEntity>(
QueryContext queryContext,
JsonElement? jsonElement,
object[] keyPropertyValues,
bool nullable,
Func<QueryContext, object[], JsonElement, TEntity> shaper)
where TEntity : class
JsonReaderData? jsonReaderData,
TIncludingEntity entity,
Func<QueryContext, object[], JsonReaderData, TIncludedEntity> innerShaper,
Action<TIncludingEntity, TIncludedEntity> fixup,
bool trackingQuery)
where TIncludingEntity : class
where TIncludedEntity : class
{
if (jsonElement.HasValue && jsonElement.Value.ValueKind != JsonValueKind.Null)
if (jsonReaderData == null)
{
var result = shaper(queryContext, keyPropertyValues, jsonElement.Value);

return result;
return;
}

if (nullable)
var included = innerShaper(queryContext, keyPropertyValues, jsonReaderData);

if (!trackingQuery)
{
return default;
fixup(entity, included);
}

throw new InvalidOperationException(
RelationalStrings.JsonRequiredEntityWithNullJson(typeof(TEntity).Name));
}

private static TResult? MaterializeJsonEntityCollection<TEntity, TResult>(
private static void IncludeJsonEntityCollection<TIncludingEntity, TIncludedCollectionElement>(
QueryContext queryContext,
JsonElement? jsonElement,
object[] keyPropertyValues,
INavigationBase navigation,
Func<QueryContext, object[], JsonElement, TEntity> innerShaper)
where TEntity : class
where TResult : ICollection<TEntity>
JsonReaderData? jsonReaderData,
TIncludingEntity entity,
Func<QueryContext, object[], JsonReaderData, TIncludedCollectionElement> innerShaper,
Action<TIncludingEntity, TIncludedCollectionElement> fixup,
bool trackingQuery)
where TIncludingEntity : class
where TIncludedCollectionElement : class
{
if (jsonElement.HasValue && jsonElement.Value.ValueKind != JsonValueKind.Null)
if (jsonReaderData == null)
{
var collectionAccessor = navigation.GetCollectionAccessor();
var result = (TResult)collectionAccessor!.Create();
return;
}

var manager = new Utf8JsonReaderManager(jsonReaderData);
var tokenType = manager.CurrentReader.TokenType;

if (tokenType != JsonTokenType.StartArray)
{
throw new InvalidOperationException(
RelationalStrings.JsonReaderInvalidTokenType(tokenType.ToString()));
}

var newKeyPropertyValues = new object[keyPropertyValues.Length + 1];
Array.Copy(keyPropertyValues, newKeyPropertyValues, keyPropertyValues.Length);
var newKeyPropertyValues = new object[keyPropertyValues.Length + 1];
Array.Copy(keyPropertyValues, newKeyPropertyValues, keyPropertyValues.Length);

var i = 0;
foreach (var jsonArrayElement in jsonElement.Value.EnumerateArray())
tokenType = manager.MoveNext();

var i = 0;
while (tokenType != JsonTokenType.EndArray)
{
newKeyPropertyValues[^1] = ++i;

if (tokenType == JsonTokenType.StartObject)
{
newKeyPropertyValues[^1] = ++i;
manager.CaptureState();
var resultElement = innerShaper(queryContext, newKeyPropertyValues, jsonReaderData);

if (!trackingQuery)
{
fixup(entity, resultElement);
}

var resultElement = innerShaper(queryContext, newKeyPropertyValues, jsonArrayElement);
manager = new Utf8JsonReaderManager(manager.Data);
if (manager.CurrentReader.TokenType != JsonTokenType.EndObject)
{
throw new InvalidOperationException(
RelationalStrings.JsonReaderInvalidTokenType(tokenType.ToString()));
}

result.Add(resultElement);
tokenType = manager.MoveNext();
}

return result;
}

return default;
manager.CaptureState();
}

private static async Task TaskAwaiter(Func<Task>[] taskFactories)
Expand Down
Loading

0 comments on commit 90c38c6

Please sign in to comment.