From 289db0265ef13d7b1be73a897741a95186a42ed6 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:26:40 +0100 Subject: [PATCH 01/29] Cosmos: Modernize JSON serialization - Update pipeline --- .../Internal/CosmosSerializationUtilities.cs | 2 +- .../Storage/Internal/CosmosClientWrapper.cs | 105 ++--- .../Storage/Internal/CosmosDatabaseWrapper.cs | 174 ++++---- .../CosmosJsonTimeOnlyReaderWriter.cs | 43 ++ .../CosmosJsonTimeSpanReaderWriter.cs | 43 ++ .../Internal/CosmosJsonVectorReaderWriter.cs | 122 ++++++ .../Internal/CosmosTimeOnlyTypeMapping.cs | 23 ++ .../Internal/CosmosTimeSpanTypeMapping.cs | 23 ++ .../Internal/CosmosTypeMappingSource.cs | 270 +++++++++++- .../Internal/CosmosVectorTypeMapping.cs | 7 +- .../Storage/Internal/ICosmosClientWrapper.cs | 5 +- .../Update/Internal/DocumentSource.cs | 391 +++++------------- .../Storage/Json/JsonValueReaderWriter.cs | 8 +- .../CosmosComplexTypesTrackingTest.cs | 20 +- .../CosmosTransactionalBatchTest.cs | 49 ++- .../EmbeddedDocumentsTest.cs | 18 +- .../EndToEndCosmosTest.cs | 109 +---- .../ReloadTest.cs | 6 - .../Basic_cosmos_model/DataEntityType.cs | 14 +- .../TestUtilities/CosmosTestStore.cs | 10 +- 20 files changed, 856 insertions(+), 586 deletions(-) create mode 100644 src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs create mode 100644 src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs create mode 100644 src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs create mode 100644 src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs create mode 100644 src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs b/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs index 82841f88162..98a7b309083 100644 --- a/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs +++ b/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs @@ -17,7 +17,7 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal; /// /// Inspired by RelationalJsonUtilities. /// -public static class CosmosSerializationUtilities +public static class CosmosSerializationUtilities // @TODO: Can this be removed? Use document source instead? { /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index 6b195ab5a14..d9c1f582cf8 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -5,7 +5,6 @@ using System.Collections.ObjectModel; using System.Net; using System.Runtime.CompilerServices; -using System.Text; using Microsoft.Azure.Cosmos.Scripts; using Microsoft.EntityFrameworkCore.Cosmos.Diagnostics.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal; @@ -45,7 +44,6 @@ public class CosmosClientWrapper : ICosmosClientWrapper private readonly string _databaseId; private readonly IExecutionStrategy _executionStrategy; private readonly IDiagnosticsLogger _commandLogger; - private readonly IDiagnosticsLogger _databaseLogger; private readonly bool? _enableContentResponseOnWrite; static CosmosClientWrapper() @@ -66,8 +64,7 @@ public CosmosClientWrapper( ISingletonCosmosClientWrapper singletonWrapper, IDbContextOptions dbContextOptions, IExecutionStrategy executionStrategy, - IDiagnosticsLogger commandLogger, - IDiagnosticsLogger databaseLogger) + IDiagnosticsLogger commandLogger) { var options = dbContextOptions.FindExtension(); @@ -75,27 +72,9 @@ public CosmosClientWrapper( _databaseId = options!.DatabaseName; _executionStrategy = executionStrategy; _commandLogger = commandLogger; - _databaseLogger = databaseLogger; _enableContentResponseOnWrite = options.EnableContentResponseOnWrite; } - /// - /// 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. - /// - public static Stream Serialize(JToken document) - { - var stream = new MemoryStream(); - using var writer = new StreamWriter(stream, new UTF8Encoding(), bufferSize: 1024, leaveOpen: true); - - using var jsonWriter = new JsonTextWriter(writer); - CosmosClientWrapper.Serializer.Serialize(jsonWriter, document); - jsonWriter.Flush(); - return stream; - } - /// /// 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 @@ -333,20 +312,20 @@ private static string GetPathFromRoot(IReadOnlyEntityType entityType) /// public virtual Task CreateItemAsync( string containerId, - JToken document, + string documentId, + Stream document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default) - => _executionStrategy.ExecuteAsync((containerId, document, updateEntry, sessionTokenStorage, this), CreateItemOnceAsync, null, cancellationToken); + => _executionStrategy.ExecuteAsync((containerId, documentId, document, updateEntry, sessionTokenStorage, this), CreateItemOnceAsync, null, cancellationToken); private static async Task CreateItemOnceAsync( DbContext _, - (string ContainerId, JToken Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, + (string ContainerId, string DocumentId, Stream Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, CancellationToken cancellationToken = default) { - using var stream = Serialize(parameters.Document); - var containerId = parameters.ContainerId; + var documentId = parameters.DocumentId; var entry = parameters.Entry; var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; @@ -369,7 +348,7 @@ private static async Task CreateItemOnceAsync( } using var response = await container.CreateItemStreamAsync( - stream, + parameters.Document, partitionKeyValue, itemRequestOptions, cancellationToken) @@ -379,7 +358,7 @@ private static async Task CreateItemOnceAsync( response.Diagnostics.GetClientElapsedTime(), response.Headers.RequestCharge, response.Headers.ActivityId, - parameters.Document["id"]!.ToString(), + documentId, containerId, partitionKeyValue); @@ -397,7 +376,7 @@ private static async Task CreateItemOnceAsync( public virtual Task ReplaceItemAsync( string collectionId, string documentId, - JObject document, + Stream document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default) @@ -406,11 +385,9 @@ public virtual Task ReplaceItemAsync( private static async Task ReplaceItemOnceAsync( DbContext _, - (string ContainerId, string ResourceId, JObject Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, + (string ContainerId, string ResourceId, Stream Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, CancellationToken cancellationToken = default) { - using var stream = Serialize(parameters.Document); - var containerId = parameters.ContainerId; var entry = parameters.Entry; var wrapper = parameters.Wrapper; @@ -434,7 +411,7 @@ private static async Task ReplaceItemOnceAsync( } using var response = await container.ReplaceItemStreamAsync( - stream, + parameters.Document, parameters.ResourceId, partitionKeyValue, itemRequestOptions, @@ -666,29 +643,65 @@ private static void ProcessResponse(string containerId, TransactionalBatchRespon var entry = entries[i]; var response = batchResponse[i]; - ProcessResponse(entry.Entry, response.ETag, response.ResourceStream); + ProcessResponse(entry.Entry, response.ETag, response.ResourceStream); // Batches can not run pre or post triggers. } } private static void ProcessResponse(IUpdateEntry entry, string eTag, Stream? content) { + if (entry.EntityState == EntityState.Deleted) + { + return; + } + var etagProperty = entry.EntityType.GetETagProperty(); - if (etagProperty != null && entry.EntityState != EntityState.Deleted) + if (etagProperty != null) { entry.SetStoreGeneratedValue(etagProperty, eTag); } - var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - if (jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } - && content != null) + // @TODO: If the entry is loaded from the database, has an etag and has no triggers, we know that nothing has changed in the meantime right? + // Could we optimize? + if (content != null && content.Length > 0) { - using var responseStream = content; - using var reader = new StreamReader(responseStream); - using var jsonReader = new JsonTextReader(reader); - - var createdDocument = Serializer.Deserialize(jsonReader); - - entry.SetStoreGeneratedValue(jObjectProperty, createdDocument); + // @TODO: Entries without etag or with a pre- or post- trigger could have an updated document returned. + // We should consider processing the returned document in that case as well + // Invoke tracking shaper labmda? + + // How did that work before? is appears to be only updaing the underlying jobject (removed now), but not the entry? Would setting the _jObject update the entry no right? + // But updating the underlying jobject would make it so that subsequent updates would not use old data, unless the user updated the property on the entity... + // Now that behaviour would be gone. + + // @TODO: Ask what to do here? + + // used to be this: + //var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); + //if (jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } + // && content != null) + //{ + // using var responseStream = content; + // using var reader = new StreamReader(responseStream); + // using var jsonReader = new JsonTextReader(reader); + + // var createdDocument = Serializer.Deserialize(jsonReader); + + // entry.SetStoreGeneratedValue(jObjectProperty, createdDocument); + //} + + // jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } is always false by default.. + // Can the user set this to true? Probably right. + // Interestingly, default for enableContentResponseOnRewrite is true, but jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } is always false. + // So enableContentResponseOnRewrite default or true does nothing without modifying the jObjectProperty's value generated. + // Does this mean anyone was really using this? + // Implementing this to work without setting jobject property value generated (because it will be removed) would be a theoratical breaking change? + // Or a bug fix? + + // No tests that don't use __jObject fail without this code + // @TODO: Add tests for this on main. + + // Suggestion: Make the new default enableContentResponseOnRewrite false? + // People who have set jObjectProperty ValueGenerated can migrate by manually setting EnableContentResponseOnRewrite to true. + // Others will notice no changes this way. } } diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs index 8a6ae3fee28..3aeeb145872 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs @@ -9,7 +9,6 @@ using Microsoft.EntityFrameworkCore.Cosmos.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Update.Internal; -using Newtonsoft.Json.Linq; using Database = Microsoft.EntityFrameworkCore.Storage.Database; namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; @@ -166,6 +165,17 @@ private SaveGroups CreateSaveGroups(IList entries) var entry = entries[i]; Check.DebugAssert(!entry.EntityType.IsAbstract(), $"{entry.EntityType} is abstract"); + if (entry.EntityState == EntityState.Modified) + { + // @TODO: Seems expensive. Can we move this to the change tracker? + // #14121 ? + if (entry.EntityType.GetFlattenedPropertiesInHierarchy().Where(entry.IsModified).All(prop => prop.GetJsonPropertyName() == "") && + !entry.EntityType.GetFlattenedComplexProperties().Any(entry.IsModified)) + { + continue; + } + } + if (!entry.EntityType.IsDocumentRoot()) { var root = GetRootDocument((InternalEntityEntry)entry); @@ -309,8 +319,6 @@ private SaveGroups CreateSaveGroups(IList entries) return null; } - JObject? document = null; - if (entry.SharedIdentityEntry != null) { if (entry.EntityState == EntityState.Deleted) @@ -324,123 +332,82 @@ private SaveGroups CreateSaveGroups(IList entries) } } - switch (operation) - { - case CosmosCudOperation.Create: - var primaryKey = entityType.FindPrimaryKey(); - if (primaryKey != null) + if (operation == CosmosCudOperation.Create) + { + var primaryKey = entityType.FindPrimaryKey(); + if (primaryKey != null) + { + // The code below checks for primary key properties that are not configured for value generation but have not + // had a non-sentinel (effectively, non-CLR default) value set. For composite keys, we only check if at least + // one property has value generation or a value set, since it is normal to have non-value generated parts of composite + // keys where one part is the CLR default. However, on Cosmos, we exclude the partition key properties from this + // check to ensure that, even if partition key properties have been set, at least one other primary key property is + // also set. + var partitionPropertyNeedsValue = true; + var propertyNeedsValue = true; + var allPkPropertiesAreFk = true; + IProperty? firstNonPartitionKeyProperty = null; + + var partitionKeyProperties = entityType.GetPartitionKeyProperties(); + foreach (var property in primaryKey.Properties) { - // The code below checks for primary key properties that are not configured for value generation but have not - // had a non-sentinel (effectively, non-CLR default) value set. For composite keys, we only check if at least - // one property has value generation or a value set, since it is normal to have non-value generated parts of composite - // keys where one part is the CLR default. However, on Cosmos, we exclude the partition key properties from this - // check to ensure that, even if partition key properties have been set, at least one other primary key property is - // also set. - var partitionPropertyNeedsValue = true; - var propertyNeedsValue = true; - var allPkPropertiesAreFk = true; - IProperty? firstNonPartitionKeyProperty = null; - - var partitionKeyProperties = entityType.GetPartitionKeyProperties(); - foreach (var property in primaryKey.Properties) + if (property.IsForeignKey()) { - if (property.IsForeignKey()) - { - // FK properties conceptually get their value from the associated principal key, which can be handled - // automatically by the update pipeline in some cases, so exclude from this check. - continue; - } + // FK properties conceptually get their value from the associated principal key, which can be handled + // automatically by the update pipeline in some cases, so exclude from this check. + continue; + } + + allPkPropertiesAreFk = false; - allPkPropertiesAreFk = false; + var isPartitionKeyProperty = partitionKeyProperties.Contains(property); + if (!isPartitionKeyProperty) + { + firstNonPartitionKeyProperty = property; + } - var isPartitionKeyProperty = partitionKeyProperties.Contains(property); + if (property.ValueGenerated != ValueGenerated.Never + || entry.HasExplicitValue(property)) + { if (!isPartitionKeyProperty) { - firstNonPartitionKeyProperty = property; + propertyNeedsValue = false; + break; } - if (property.ValueGenerated != ValueGenerated.Never - || entry.HasExplicitValue(property)) - { - if (!isPartitionKeyProperty) - { - propertyNeedsValue = false; - break; - } - - partitionPropertyNeedsValue = false; - } + partitionPropertyNeedsValue = false; } + } - if (!allPkPropertiesAreFk) + if (!allPkPropertiesAreFk) + { + try { - try + if (firstNonPartitionKeyProperty != null + && propertyNeedsValue) { - if (firstNonPartitionKeyProperty != null - && propertyNeedsValue) - { - // There were non-partition key properties, so only throw if it is one of these that is not set, - // ignoring partition key properties. - Dependencies.Logger.PrimaryKeyValueNotSet(firstNonPartitionKeyProperty!); - } - else if (firstNonPartitionKeyProperty == null - && partitionPropertyNeedsValue) - { - // There were no non-partition key properties in the primary key, so in this case check if any of these is not set. - Dependencies.Logger.PrimaryKeyValueNotSet(primaryKey.Properties[0]); - } + // There were non-partition key properties, so only throw if it is one of these that is not set, + // ignoring partition key properties. + Dependencies.Logger.PrimaryKeyValueNotSet(firstNonPartitionKeyProperty!); } - catch (InvalidOperationException ex) + else if (firstNonPartitionKeyProperty == null + && partitionPropertyNeedsValue) { - throw WrapUpdateException(ex, [entry]); + // There were no non-partition key properties in the primary key, so in this case check if any of these is not set. + Dependencies.Logger.PrimaryKeyValueNotSet(primaryKey.Properties[0]); } } - } - - document = documentSource.GetCurrentDocument(entry); - if (document != null) - { - documentSource.UpdateDocument(document, entry); - } - else - { - document = documentSource.CreateDocument(entry); - } - break; - - case CosmosCudOperation.Update: - document = documentSource.GetCurrentDocument(entry); - if (document != null) - { - if (documentSource.UpdateDocument(document, entry) == null) - { - return null; - } - } - else - { - document = documentSource.CreateDocument(entry); - - var propertyName = entityType.FindDiscriminatorProperty()?.GetJsonPropertyName(); - if (propertyName != null) + catch (InvalidOperationException ex) { - document[propertyName] = - JToken.FromObject(entityType.GetDiscriminatorValue()!, CosmosClientWrapper.Serializer); + throw WrapUpdateException(ex, [entry]); } } - break; - - case CosmosCudOperation.Delete: - break; - - default: - throw new UnreachableException(); + } } return new CosmosUpdateEntry { CollectionId = collectionId, - Document = document, DocumentSource = documentSource, Entry = entry, Operation = operation.Value @@ -459,7 +426,7 @@ private IEnumerable CreateTransactions((Groupi foreach (var updateEntry in batch.UpdateEntries) { // Stream is disposed by Transaction.ExecuteAsync - var stream = updateEntry.Document != null ? CosmosClientWrapper.Serialize(updateEntry.Document) : null; + var stream = updateEntry.Operation != CosmosCudOperation.Delete ? updateEntry.DocumentSource.Serialize(updateEntry.Entry) : null; // With AutoTransactionBehavior.Always, AddToTransaction will always return true. if (!AddToTransaction(transaction, updateEntry, stream)) @@ -499,24 +466,26 @@ private async Task SaveAsync(CosmosUpdateEntry updateEntry, CancellationTo { try { + var id = updateEntry.DocumentSource.GetId(updateEntry.Entry.SharedIdentityEntry ?? updateEntry.Entry); return updateEntry.Operation switch { CosmosCudOperation.Create => await _cosmosClient.CreateItemAsync( updateEntry.CollectionId, - updateEntry.Document!, + id, + updateEntry.DocumentSource.Serialize(updateEntry.Entry), updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), CosmosCudOperation.Update => await _cosmosClient.ReplaceItemAsync( updateEntry.CollectionId, - updateEntry.DocumentSource.GetId(updateEntry.Entry.SharedIdentityEntry ?? updateEntry.Entry), - updateEntry.Document!, + id, + updateEntry.DocumentSource.Serialize(updateEntry.Entry), updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), CosmosCudOperation.Delete => await _cosmosClient.DeleteItemAsync( updateEntry.CollectionId, - updateEntry.DocumentSource.GetId(updateEntry.Entry), + id, updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), @@ -551,7 +520,7 @@ public virtual DocumentSource GetDocumentSource(IEntityType entityType) if (!_documentCollections.TryGetValue(entityType, out var documentSource)) { _documentCollections.Add( - entityType, documentSource = new DocumentSource(entityType, this)); + entityType, documentSource = new DocumentSource(entityType)); // @TODO: Make this singleton? } return documentSource; @@ -625,7 +594,6 @@ private sealed class CosmosUpdateEntry public required CosmosCudOperation Operation { get; init; } public required string CollectionId { get; init; } public required DocumentSource DocumentSource { get; init; } - public required JObject? Document { get; init; } } private sealed record Grouping(string ContainerId, PartitionKey PartitionKeyValue); diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs new file mode 100644 index 00000000000..72b61f3845a --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeOnlyReaderWriter.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.Json; + +namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; + +/// +/// 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. +/// +public sealed class CosmosJsonTimeOnlyReaderWriter : JsonValueReaderWriter +{ + private static readonly PropertyInfo InstanceProperty = typeof(CosmosJsonTimeOnlyReaderWriter).GetProperty(nameof(Instance))!; + + /// + /// 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. + /// + public static CosmosJsonTimeOnlyReaderWriter Instance { get; } = new(); + + private CosmosJsonTimeOnlyReaderWriter() + { + } + + /// + public override TimeOnly FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) + => TimeOnly.Parse(manager.CurrentReader.GetString()!, CultureInfo.InvariantCulture); + + /// + public override void ToJsonTyped(Utf8JsonWriter writer, TimeOnly value) + => writer.WriteStringValue(value.ToString("HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture)); + + /// + public override Expression ConstructorExpression + => Expression.Property(null, InstanceProperty); +} diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs new file mode 100644 index 00000000000..9b2de9f7ef1 --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonTimeSpanReaderWriter.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.Json; + +namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; + +/// +/// 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. +/// +public sealed class CosmosJsonTimeSpanReaderWriter : JsonValueReaderWriter +{ + private static readonly PropertyInfo InstanceProperty = typeof(CosmosJsonTimeSpanReaderWriter).GetProperty(nameof(Instance))!; + + /// + /// 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. + /// + public static CosmosJsonTimeSpanReaderWriter Instance { get; } = new(); + + private CosmosJsonTimeSpanReaderWriter() + { + } + + /// + public override TimeSpan FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) + => TimeSpan.Parse(manager.CurrentReader.GetString()!, CultureInfo.InvariantCulture); + + /// + public override void ToJsonTyped(Utf8JsonWriter writer, TimeSpan value) + => writer.WriteStringValue(value.ToString("c", CultureInfo.InvariantCulture)); + + /// + public override Expression ConstructorExpression + => Expression.Property(null, InstanceProperty); +} diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs new file mode 100644 index 00000000000..60a55d75818 --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.Json; + +namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; + +/// +/// 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. +/// +public sealed class CosmosJsonVectorReaderWriter : JsonValueReaderWriter +{ + private static readonly PropertyInfo InstanceProperty = typeof(CosmosJsonVectorReaderWriter).GetProperty(nameof(Instance))!; + + /// + /// 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. + /// + public static CosmosJsonVectorReaderWriter Instance { get; } = new(); + + /// + /// 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. + /// + public override object FromJson(ref Utf8JsonReaderManager manager, object? existingObject = null) + { + var tokenType = manager.CurrentReader.TokenType; + if (tokenType != JsonTokenType.StartArray) + { + throw new InvalidOperationException( + CoreStrings.JsonReaderInvalidTokenType(tokenType.ToString())); + } + + var result = new List(); + + while (tokenType != JsonTokenType.EndArray) + { + manager.MoveNext(); + tokenType = manager.CurrentReader.TokenType; + + if (tokenType != JsonTokenType.Number || !manager.CurrentReader.TryGetInt32(out var intValue)) + { + throw new InvalidOperationException( + CoreStrings.JsonReaderInvalidTokenType(tokenType.ToString())); + } + + result.Add((byte)intValue); + } + + return result.ToArray(); + } + + /// + /// 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. + /// + public override void ToJson(Utf8JsonWriter writer, object value) + { + writer.WriteStartArray(); + + switch (value) + { + case IEnumerable bytes: + foreach (var item in bytes) + { + writer.WriteNumberValue(item); + } + break; + case ReadOnlyMemory rom: + foreach (var item in rom.Span) + { + writer.WriteNumberValue(item); + } + break; + case IEnumerable bytes: + foreach (var item in bytes) + { + writer.WriteNumberValue(item); + } + break; + case ReadOnlyMemory rom: + foreach (var item in rom.Span) + { + writer.WriteNumberValue(item); + } + break; + case IEnumerable bytes: + foreach (var item in bytes) + { + writer.WriteNumberValue(item); + } + break; + case ReadOnlyMemory rom: + foreach (var item in rom.Span) + { + writer.WriteNumberValue(item); + } + break; + default: + throw new InvalidOperationException(); + } + + writer.WriteEndArray(); + } + + /// + public override Expression ConstructorExpression + => Expression.Property(null, InstanceProperty); + + /// + public override Type ValueType { get; } = typeof(byte[]); +} diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs new file mode 100644 index 00000000000..670456536e3 --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; + +/// +/// 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. +/// +public class CosmosTimeOnlyTypeMapping : CosmosTypeMapping +{ + /// + /// 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. + /// + public CosmosTimeOnlyTypeMapping() : base(typeof(TimeOnly), null, null, null, CosmosJsonTimeOnlyReaderWriter.Instance) + { + } +} diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs new file mode 100644 index 00000000000..effc8c3a5ba --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; + +/// +/// 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. +/// +public class CosmosTimeSpanTypeMapping : CosmosTypeMapping +{ + /// + /// 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. + /// + public CosmosTimeSpanTypeMapping() : base(typeof(TimeSpan), null, null, null, CosmosJsonTimeSpanReaderWriter.Instance) + { + } +} diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index 4464928a038..3781aa31fc4 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Frozen; using System.Text.Json; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal; @@ -19,7 +20,7 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class CosmosTypeMappingSource : TypeMappingSource { - private readonly Dictionary _clrTypeMappings; + private readonly FrozenDictionary _clrTypeMappings; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -32,11 +33,13 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) => _clrTypeMappings = new Dictionary { + { typeof(TimeOnly), new CosmosTimeOnlyTypeMapping() }, + { typeof(TimeSpan), new CosmosTimeSpanTypeMapping() }, { typeof(JObject), new CosmosTypeMapping( typeof(JObject), jsonValueReaderWriter: dependencies.JsonValueReaderWriterSource.FindReaderWriter(typeof(JObject))) } - }; + }.ToFrozenDictionary(); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -82,7 +85,10 @@ when property.GetVectorDistanceFunction() is { } distanceFunction var memoryType = clrType.TryGetElementType(typeof(ReadOnlyMemory<>)); if (memoryType != null) { - return new CosmosTypeMapping(clrType) + var elementMappingInfo = new TypeMappingInfo(memoryType); + CoreTypeMapping? typeMapping = null; + TryFindJsonCollectionMapping(elementMappingInfo, memoryType.MakeArrayType(), null, ref typeMapping, out var _, out var readerWriter); + return new CosmosTypeMapping(clrType, jsonValueReaderWriter: readerWriter) .WithComposedConverter( (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(memoryType))!, (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(memoryType))!); @@ -168,10 +174,37 @@ when property.GetVectorDistanceFunction() is { } distanceFunction if (jsonValueReaderWriter == null && elementMapping.JsonValueReaderWriter != null) { - jsonValueReaderWriter = (JsonValueReaderWriter?)Activator.CreateInstance( - typeof(PlaceholderJsonStringKeyedDictionaryReaderWriter<>) - .MakeGenericType(elementMapping.JsonValueReaderWriter.ValueType), - elementMapping.JsonValueReaderWriter); + if (elementType.IsNullableValueType()) + { + jsonValueReaderWriter = (JsonValueReaderWriter?)Activator.CreateInstance( + typeof(CosmosJsonStringKeyedDictionaryNullableValueReaderWriter<>) + .MakeGenericType(elementMapping.JsonValueReaderWriter.ValueType), + elementMapping.JsonValueReaderWriter); + } + else if (elementType != typeof(string) && elementType.TryGetElementType(typeof(IEnumerable<>)) is { } nestedElementType) + { + if (nestedElementType.IsClass) + { + jsonValueReaderWriter = (JsonValueReaderWriter?)Activator.CreateInstance( + typeof(CosmosJsonStringKeyedDictionaryReferenceCollectionValueReaderWriter<,>) + .MakeGenericType(elementType, nestedElementType), + elementMapping.JsonValueReaderWriter); + } + else + { + jsonValueReaderWriter = (JsonValueReaderWriter?)Activator.CreateInstance( + typeof(CosmosJsonStringKeyedDictionaryCollectionValueReaderWriter<,>) + .MakeGenericType(elementType, nestedElementType), + elementMapping.JsonValueReaderWriter); + } + } + else + { + jsonValueReaderWriter = (JsonValueReaderWriter?)Activator.CreateInstance( + typeof(CosmosJsonStringKeyedDictionaryReaderWriter<>) + .MakeGenericType(elementType), + elementMapping.JsonValueReaderWriter); + } } return new CosmosTypeMapping( @@ -200,8 +233,6 @@ private static ValueComparer CreateStringDictionaryComparer( #pragma warning restore EF1001 // Internal EF Core API usage. } - // This ensures that the element reader/writers are not null when using Cosmos dictionary type mappings, but - // is never actually used because Cosmos does not (yet) read and write JSON using this mechanism. /// /// 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 @@ -209,7 +240,7 @@ private static ValueComparer CreateStringDictionaryComparer( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// #pragma warning disable EF1001 - public sealed class PlaceholderJsonStringKeyedDictionaryReaderWriter(JsonValueReaderWriter elementReaderWriter) + public sealed class CosmosJsonStringKeyedDictionaryReaderWriter(JsonValueReaderWriter elementReaderWriter) : JsonValueReaderWriter>>, ICompositeJsonValueReaderWriter #pragma warning restore EF1001 { @@ -233,14 +264,229 @@ public override IEnumerable> FromJsonTyped( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public override void ToJsonTyped(Utf8JsonWriter writer, IEnumerable> value) + { + writer.WriteStartObject(); + foreach (var element in value) + { + writer.WritePropertyName(element.Key); + if (element.Value is not null) + { + _elementReaderWriter.ToJsonTyped(writer, element.Value); + } + else + { + writer.WriteNullValue(); + } + } + + writer.WriteEndObject(); + } + + JsonValueReaderWriter ICompositeJsonValueReaderWriter.InnerReaderWriter + => _elementReaderWriter; + + private readonly ConstructorInfo _constructorInfo + = typeof(CosmosJsonStringKeyedDictionaryReaderWriter) + .GetConstructor([typeof(JsonValueReaderWriter)])!; + + /// + public override Expression ConstructorExpression +#pragma warning disable EF9100 +#pragma warning disable EF1001 + => Expression.New(_constructorInfo, ((ICompositeJsonValueReaderWriter)this).InnerReaderWriter.ConstructorExpression); +#pragma warning restore EF1001 +#pragma warning restore EF9100 + } + + /// + /// 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. + /// +#pragma warning disable EF1001 + public sealed class CosmosJsonStringKeyedDictionaryNullableValueReaderWriter(JsonValueReaderWriter elementReaderWriter) + : JsonValueReaderWriter>>, ICompositeJsonValueReaderWriter + where TElement : struct +#pragma warning restore EF1001 + { + private readonly JsonValueReaderWriter _elementReaderWriter = (JsonValueReaderWriter)elementReaderWriter; + + /// + /// 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. + /// + public override IEnumerable> FromJsonTyped( + ref Utf8JsonReaderManager manager, + object? existingObject = null) => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + /// + /// 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. + /// + public override void ToJsonTyped(Utf8JsonWriter writer, IEnumerable> value) + { + writer.WriteStartObject(); + foreach (var element in value) + { + writer.WritePropertyName(element.Key); + if (element.Value.HasValue) + { + _elementReaderWriter.ToJsonTyped(writer, element.Value.Value); + } + else + { + writer.WriteNullValue(); + } + } + + writer.WriteEndObject(); + } + + JsonValueReaderWriter ICompositeJsonValueReaderWriter.InnerReaderWriter + => _elementReaderWriter; + + private readonly ConstructorInfo _constructorInfo + = typeof(CosmosJsonStringKeyedDictionaryNullableValueReaderWriter) + .GetConstructor([typeof(JsonValueReaderWriter)])!; + + /// + public override Expression ConstructorExpression +#pragma warning disable EF9100 +#pragma warning disable EF1001 + => Expression.New(_constructorInfo, ((ICompositeJsonValueReaderWriter)this).InnerReaderWriter.ConstructorExpression); +#pragma warning restore EF1001 +#pragma warning restore EF9100 + } + + /// + /// 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. + /// +#pragma warning disable EF1001 + public sealed class CosmosJsonStringKeyedDictionaryCollectionValueReaderWriter(JsonValueReaderWriter elementReaderWriter) + : JsonValueReaderWriter>>, ICompositeJsonValueReaderWriter + where TConcreteCollection : IEnumerable +#pragma warning restore EF1001 + { + private readonly JsonValueReaderWriter> _elementReaderWriter = (JsonValueReaderWriter>)elementReaderWriter; + + /// + /// 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. + /// + public override IEnumerable> FromJsonTyped( + ref Utf8JsonReaderManager manager, + object? existingObject = null) + => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + + /// + /// 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. + /// + public override void ToJsonTyped(Utf8JsonWriter writer, IEnumerable> value) + { + writer.WriteStartObject(); + foreach (var element in value) + { + writer.WritePropertyName(element.Key); + if (element.Value is not null) + { + _elementReaderWriter.ToJsonTyped(writer, element.Value); + } + else + { + writer.WriteNullValue(); + } + } + + writer.WriteEndObject(); + } + + JsonValueReaderWriter ICompositeJsonValueReaderWriter.InnerReaderWriter + => _elementReaderWriter; + + private readonly ConstructorInfo _constructorInfo + = typeof(CosmosJsonStringKeyedDictionaryCollectionValueReaderWriter) + .GetConstructor([typeof(JsonValueReaderWriter)])!; + + /// + public override Expression ConstructorExpression +#pragma warning disable EF9100 +#pragma warning disable EF1001 + => Expression.New(_constructorInfo, ((ICompositeJsonValueReaderWriter)this).InnerReaderWriter.ConstructorExpression); +#pragma warning restore EF1001 +#pragma warning restore EF9100 + } + + /// + /// 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. + /// +#pragma warning disable EF1001 + public sealed class CosmosJsonStringKeyedDictionaryReferenceCollectionValueReaderWriter(JsonValueReaderWriter elementReaderWriter) + : JsonValueReaderWriter>>, ICompositeJsonValueReaderWriter + where TConcreteCollection : IEnumerable + where TElement : class +#pragma warning restore EF1001 + { + private readonly JsonValueReaderWriter _elementReaderWriter = (JsonValueReaderWriter)elementReaderWriter; + + /// + /// 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. + /// + public override IEnumerable> FromJsonTyped( + ref Utf8JsonReaderManager manager, + object? existingObject = null) + => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + + /// + /// 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. + /// + public override void ToJsonTyped(Utf8JsonWriter writer, IEnumerable> value) + { + writer.WriteStartObject(); + foreach (var element in value) + { + writer.WritePropertyName(element.Key); + if (element.Value is not null) + { + _elementReaderWriter.ToJsonTyped(writer, element.Value); + } + else + { + writer.WriteNullValue(); + } + } + + writer.WriteEndObject(); + } + JsonValueReaderWriter ICompositeJsonValueReaderWriter.InnerReaderWriter => _elementReaderWriter; private readonly ConstructorInfo _constructorInfo - = typeof(PlaceholderJsonStringKeyedDictionaryReaderWriter) - .GetConstructor([typeof(JsonValueReaderWriter)])!; + = typeof(CosmosJsonStringKeyedDictionaryReferenceCollectionValueReaderWriter) + .GetConstructor([typeof(JsonValueReaderWriter)])!; /// public override Expression ConstructorExpression diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs index 1faa2b36db9..83b029e5d9b 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs @@ -38,8 +38,7 @@ public CosmosVectorTypeMapping( CosmosVectorType vectorType, ValueComparer? comparer = null, ValueComparer? keyComparer = null, - CoreTypeMapping? elementMapping = null, - JsonValueReaderWriter? jsonValueReaderWriter = null) + CoreTypeMapping? elementMapping = null) : this( new CoreTypeMappingParameters( clrType, @@ -47,7 +46,7 @@ public CosmosVectorTypeMapping( comparer, keyComparer, elementMapping: elementMapping, - jsonValueReaderWriter: jsonValueReaderWriter), + jsonValueReaderWriter: CosmosJsonVectorReaderWriter.Instance), vectorType) { } @@ -67,7 +66,7 @@ public CosmosVectorTypeMapping(CosmosTypeMapping mapping, CosmosVectorType vecto mapping.Comparer, mapping.KeyComparer, elementMapping: mapping.ElementTypeMapping, - jsonValueReaderWriter: mapping.JsonValueReaderWriter), + jsonValueReaderWriter: CosmosJsonVectorReaderWriter.Instance), vectorType) { } diff --git a/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs index 7c401a3c977..30f8b229734 100644 --- a/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs @@ -45,7 +45,8 @@ public interface ICosmosClientWrapper /// Task CreateItemAsync( string containerId, - JToken document, + string documentId, + Stream document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default); @@ -59,7 +60,7 @@ Task CreateItemAsync( Task ReplaceItemAsync( string collectionId, string documentId, - JObject document, + Stream document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default); diff --git a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs index 2f5f1cfdd6d..0432123be04 100644 --- a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs +++ b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs @@ -2,12 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections; +using System.Text.Encodings.Web; +using System.Text.Json; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; -using Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; +using Microsoft.EntityFrameworkCore.Storage.Internal; using Microsoft.EntityFrameworkCore.Update.Internal; -using Newtonsoft.Json.Linq; namespace Microsoft.EntityFrameworkCore.Cosmos.Update.Internal; @@ -23,10 +24,8 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Update.Internal; public class DocumentSource { private readonly string _containerId; - private readonly CosmosDatabaseWrapper _database; private readonly IEntityType _entityType; private readonly IProperty? _idProperty; - private readonly IProperty? _jObjectProperty; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -34,13 +33,11 @@ public class DocumentSource /// 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. /// - public DocumentSource(IEntityType entityType, CosmosDatabaseWrapper database) + public DocumentSource(IEntityType entityType) { _containerId = entityType.GetContainer()!; - _database = database; _entityType = entityType; _idProperty = entityType.GetProperties().FirstOrDefault(p => p.GetJsonPropertyName() == CosmosJsonIdConvention.IdPropertyJsonName); - _jObjectProperty = entityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); } /// @@ -69,332 +66,166 @@ public virtual string GetId(IUpdateEntry entry) /// 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. /// - public virtual JObject CreateDocument(IUpdateEntry entry) - => CreateDocument(entry, null); + public virtual Stream Serialize(IUpdateEntry entry) + { + var internalEntry = (IInternalEntry)entry; + var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + WriteJsonObject(writer, internalEntry, internalEntry.StructuralType, null); + } - /// - /// 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. - /// - public virtual JObject CreateDocument(IUpdateEntry entry, int? ordinal) - => CreateDocument((IInternalEntry)entry, entry.EntityType, ordinal); + stream.Position = 0; + return stream; + } - private JObject CreateDocument(IInternalEntry entry, ITypeBase structuralType, int? ordinal) + private void WriteJsonObject( + Utf8JsonWriter writer, + IInternalEntry entry, + ITypeBase structuralType, + int? ordinal) { - var document = new JObject(); + writer.WriteStartObject(); + foreach (var property in structuralType.GetProperties()) { - var storeName = property.GetJsonPropertyName(); - if (storeName.Length != 0) - { - document[storeName] = ConvertPropertyValue(property, entry); - } - else if (entry.HasTemporaryValue(property)) - { - if (ordinal != null - && property.IsOrdinalKeyProperty()) - { - entry.SetStoreGeneratedValue(property, ordinal.Value); - } - } - } + var jsonPropertyName = property.GetJsonPropertyName(); - if (structuralType is IEntityType entityType) - { - foreach (var embeddedNavigation in entityType.GetNavigations()) + if (jsonPropertyName == "") { - var fk = embeddedNavigation.ForeignKey; - if (!fk.IsOwnership - || embeddedNavigation.IsOnDependent - || fk.DeclaringEntityType.IsDocumentRoot()) + if (property.IsKey() && property.IsOrdinalKeyProperty()) { - continue; - } - - var embeddedValue = entry.GetCurrentValue(embeddedNavigation); - var embeddedPropertyName = fk.DeclaringEntityType.GetContainingPropertyName()!; - if (embeddedValue == null) - { - document[embeddedPropertyName] = null; - } - else if (fk.IsUnique) - { - var dependentEntry = ((InternalEntityEntry)entry).StateManager.TryGetEntry(embeddedValue, fk.DeclaringEntityType)!; - document[embeddedPropertyName] = _database.GetDocumentSource(dependentEntry.EntityType).CreateDocument(dependentEntry); - } - else - { - SetTemporaryOrdinals(entry, fk, embeddedValue); - - var stateManager = ((InternalEntityEntry)entry).StateManager; - - var embeddedOrdinal = 1; - var array = new JArray(); - foreach (var dependent in (IEnumerable)embeddedValue) - { - var dependentEntry = stateManager.TryGetEntry(dependent, fk.DeclaringEntityType)!; - array.Add(_database.GetDocumentSource(dependentEntry.EntityType).CreateDocument(dependentEntry, embeddedOrdinal)); - embeddedOrdinal++; - } - - document[embeddedPropertyName] = array; + entry.SetStoreGeneratedValue(property, ordinal!.Value + 1, setModified: false); } + continue; } - } - foreach (var complexProperty in structuralType.GetComplexProperties()) - { - var embeddedValue = entry.GetCurrentValue(complexProperty); - var embeddedPropertyName = complexProperty.GetJsonPropertyName(); - if (embeddedValue == null) - { - document[embeddedPropertyName] = null; - } - else if (!complexProperty.IsCollection) + var propertyValue = entry.GetCurrentValue(property); + writer.WritePropertyName(jsonPropertyName); + + var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; + if (propertyValue is not null || jsonValueReaderWriter is IJsonConvertedValueReaderWriter { Converter.ConvertsNulls: true }) { - document[embeddedPropertyName] = CreateDocument(entry, complexProperty.ComplexType, null); + Check.DebugAssert(jsonValueReaderWriter is not null, $"Missing JsonValueReaderWriter for property: {property}"); + jsonValueReaderWriter.ToJson(writer, propertyValue!); } else { - var internalEntry = (InternalEntryBase)entry; - - var embeddedOrdinal = 0; - var array = new JArray(); - foreach (var dependent in (IEnumerable)embeddedValue) - { - var dependentEntry = internalEntry.GetComplexCollectionEntry(complexProperty, embeddedOrdinal); - array.Add(CreateDocument(dependentEntry, complexProperty.ComplexType, null)); - embeddedOrdinal++; - } - - document[embeddedPropertyName] = array; + writer.WriteNullValue(); } } - return document; - } - - /// - /// 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. - /// - public virtual JObject? UpdateDocument(JObject document, IUpdateEntry entry) - => UpdateDocument(document, entry, null); - - /// - /// 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. - /// - public virtual JObject? UpdateDocument(JObject document, IUpdateEntry entry, int? ordinal) - => UpdateDocument(document, (IInternalEntry)entry, entry.EntityType, ordinal); - - private JObject? UpdateDocument(JObject document, IInternalEntry entry, ITypeBase structuralType, int? ordinal) - { - var anyPropertyUpdated = false; - foreach (var property in structuralType.GetProperties()) + foreach (var complexProperty in structuralType.GetComplexProperties()) { - if (ordinal != null - && entry.HasTemporaryValue(property) - && property.IsOrdinalKeyProperty()) - { - entry.SetStoreGeneratedValue(property, ordinal.Value); - } + var jsonPropertyName = complexProperty.GetJsonPropertyName()!; + writer.WritePropertyName(jsonPropertyName); - if (entry.EntityState == EntityState.Added - || (entry is IUpdateEntry updateEntry && updateEntry.SharedIdentityEntry != null) - || entry.IsModified(property)) - { - var storeName = property.GetJsonPropertyName(); - if (storeName.Length != 0) - { - document[storeName] = ConvertPropertyValue(property, entry); - anyPropertyUpdated = true; - } - } + WriteJsonStructuralPropertyValue(writer, entry, complexProperty, complexProperty.ComplexType, complexProperty.IsCollection); } if (structuralType is IEntityType entityType) { - foreach (var ownedNavigation in entityType.GetNavigations()) + foreach (var navigation in entityType.GetNavigations()) { - var fk = ownedNavigation.ForeignKey; - if (!fk.IsOwnership - || ownedNavigation.IsOnDependent - || fk.DeclaringEntityType.IsDocumentRoot()) + // skip back-references to the parent + var fk = navigation.ForeignKey; + if (!fk.IsOwnership || navigation.IsOnDependent) { continue; } - var embeddedDocumentSource = _database.GetDocumentSource(fk.DeclaringEntityType); - var embeddedValue = entry.GetCurrentValue(ownedNavigation); - var embeddedPropertyName = fk.DeclaringEntityType.GetContainingPropertyName()!; - if (embeddedValue == null) - { - if (document[embeddedPropertyName] != null) - { - document[embeddedPropertyName] = null; - anyPropertyUpdated = true; - } - } - else if (fk.IsUnique) - { - var embeddedEntry = ((InternalEntityEntry)entry).StateManager.TryGetEntry(embeddedValue, fk.DeclaringEntityType)!; - - var embeddedDocument = embeddedDocumentSource.GetCurrentDocument(embeddedEntry); - embeddedDocument = embeddedDocument != null - ? embeddedDocumentSource.UpdateDocument(embeddedDocument, embeddedEntry, null) - : embeddedDocumentSource.CreateDocument(embeddedEntry, null); - - if (embeddedDocument != null) - { - document[embeddedPropertyName] = embeddedDocument; - anyPropertyUpdated = true; - } - } - else - { - SetTemporaryOrdinals(entry, fk, embeddedValue); - - var stateManager = ((InternalEntityEntry)entry).StateManager; + var jsonPropertyName = navigation.TargetEntityType.GetContainingPropertyName(); - var embeddedOrdinal = 1; - var array = new JArray(); - foreach (var dependent in (IEnumerable)embeddedValue) - { - var embeddedEntry = stateManager.TryGetEntry(dependent, fk.DeclaringEntityType)!; + Debug.Assert(jsonPropertyName != null, "Containing property name should not be null on owned navigation."); - var embeddedDocument = embeddedDocumentSource.GetCurrentDocument(embeddedEntry); - embeddedDocument = embeddedDocument != null - ? embeddedDocumentSource.UpdateDocument(embeddedDocument, embeddedEntry, embeddedOrdinal) ?? embeddedDocument - : embeddedDocumentSource.CreateDocument(embeddedEntry, embeddedOrdinal); + writer.WritePropertyName(jsonPropertyName); - array.Add(embeddedDocument); - embeddedOrdinal++; - } - - document[embeddedPropertyName] = array; - anyPropertyUpdated = true; - } + WriteJsonStructuralPropertyValue(writer, entry, navigation, navigation.TargetEntityType, navigation.IsCollection); } } - foreach (var complexProperty in structuralType.GetComplexProperties()) + writer.WriteEndObject(); + } + + private void WriteJsonStructuralPropertyValue( + Utf8JsonWriter writer, + IInternalEntry parentEntry, + IPropertyBase property, + ITypeBase structuralType, + bool isCollection) + { + var value = parentEntry.GetCurrentValue(property); + if (isCollection) + { + WriteJsonArray(writer, parentEntry, property, structuralType, (IEnumerable?)value); + } + else { - var embeddedValue = entry.GetCurrentValue(complexProperty); - var embeddedPropertyName = complexProperty.GetJsonPropertyName(); - if (embeddedValue == null) + if (value is null) { - if (document[embeddedPropertyName] != null) - { - document[embeddedPropertyName] = null; - anyPropertyUpdated = true; - } + writer.WriteNullValue(); + return; } - else if (!complexProperty.IsCollection) - { - var embeddedDocument = document[embeddedPropertyName] as JObject; - embeddedDocument = embeddedDocument != null - ? UpdateDocument(embeddedDocument, entry, complexProperty.ComplexType, null) - : CreateDocument(entry, complexProperty.ComplexType, null); - if (embeddedDocument != null) - { - document[embeddedPropertyName] = embeddedDocument; - anyPropertyUpdated = true; - } - } - else - { - var embeddedCollection = document[embeddedPropertyName] as JArray; - var embeddedOrdinal = 0; - var array = new JArray(); - foreach (var dependent in (IEnumerable)embeddedValue) - { - var embeddedEntry = entry.GetComplexCollectionEntry(complexProperty, embeddedOrdinal); - - var embeddedDocument = embeddedEntry.OriginalOrdinal != -1 ? embeddedCollection?[embeddedEntry.OriginalOrdinal] as JObject : null; - embeddedDocument = embeddedDocument != null - ? UpdateDocument(embeddedDocument, embeddedEntry, complexProperty.ComplexType, null) ?? embeddedDocument - : CreateDocument(embeddedEntry, complexProperty.ComplexType, null); - - array.Add(embeddedDocument); - embeddedOrdinal++; - } + var entry = structuralType is IComplexType + ? parentEntry + : ((InternalEntityEntry)parentEntry).StateManager.TryGetEntry(value!, (IEntityType)structuralType)!; - document[embeddedPropertyName] = array; - anyPropertyUpdated = true; - } + WriteJsonObject(writer, entry, structuralType, null); } - - return anyPropertyUpdated ? document : null; } - private static void SetTemporaryOrdinals( - IInternalEntry entry, - IForeignKey fk, - object embeddedValue) + private void WriteJsonArray( + Utf8JsonWriter writer, + IInternalEntry parentEntry, + IPropertyBase property, + ITypeBase structuralType, + IEnumerable? value) { - var embeddedOrdinal = 1; - var ordinalKeyProperty = FindOrdinalKeyProperty(fk.DeclaringEntityType); - if (ordinalKeyProperty != null) + if (value is null) { - var stateManager = ((InternalEntityEntry)entry).StateManager; - var shouldSetTemporaryKeys = false; - foreach (var dependent in (IEnumerable)embeddedValue) - { - var embeddedEntry = stateManager.TryGetEntry(dependent, fk.DeclaringEntityType)!; - - if ((int)embeddedEntry.GetCurrentValue(ordinalKeyProperty)! != embeddedOrdinal - && !embeddedEntry.HasTemporaryValue(ordinalKeyProperty)) - { - shouldSetTemporaryKeys = true; - break; - } - - embeddedOrdinal++; - } + writer.WriteNullValue(); + return; + } - if (shouldSetTemporaryKeys) + // When items in an owned entity collection are reordered, assigning ordinal key values + // sequentially can cause identity map conflicts - e.g. assigning ordinal 2 to a new + // entry while another tracked entry still holds ordinal 2. + // To avoid this, first assign temporary negative ordinals to move all entries out of + // the way, then let WriteJsonObject assign the correct final ordinals. + if (property is INavigation { TargetEntityType: var entityType }) + { + var ordinalKeyProperty = entityType.FindPrimaryKey()?.Properties + .FirstOrDefault(p => p.IsOrdinalKeyProperty()); + if (ordinalKeyProperty != null) { - var temporaryOrdinal = -1; - foreach (var dependent in (IEnumerable)embeddedValue) + var stateManager = ((InternalEntityEntry)parentEntry).StateManager; + var tempOrdinal = -1; + foreach (var collectionElement in value) { - var embeddedEntry = stateManager.TryGetEntry(dependent, fk.DeclaringEntityType)!; - - embeddedEntry.SetTemporaryValue(ordinalKeyProperty, temporaryOrdinal, setModified: false); - - temporaryOrdinal--; + var tempEntry = stateManager.TryGetEntry(collectionElement, entityType); + tempEntry?.SetTemporaryValue(ordinalKeyProperty, tempOrdinal--, setModified: false); } } } - } - private static IProperty? FindOrdinalKeyProperty(IEntityType entityType) - => entityType.FindPrimaryKey()!.Properties.FirstOrDefault(p => p.GetJsonPropertyName().Length == 0 && p.IsOrdinalKeyProperty()); - - /// - /// 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. - /// - public virtual JObject? GetCurrentDocument(IUpdateEntry entry) - => _jObjectProperty != null - ? (JObject?)(entry.SharedIdentityEntry ?? entry).GetCurrentValue(_jObjectProperty) - : null; + var i = 0; + writer.WriteStartArray(); + foreach (var collectionElement in value) + { + var entry = structuralType is IComplexType complexType + ? (IInternalEntry)parentEntry.GetComplexCollectionEntry(complexType.ComplexProperty, i) + : ((InternalEntityEntry)parentEntry).StateManager.TryGetEntry(collectionElement, (IEntityType)structuralType)!; + + WriteJsonObject( + writer, + entry, + structuralType, + ordinal: i++); + } - private static JToken? ConvertPropertyValue(IProperty property, IInternalEntry entry) - { - var value = entry.GetCurrentProviderValue(property); - return value == null - ? null - : (value as JToken) ?? JToken.FromObject(value, CosmosClientWrapper.Serializer); + writer.WriteEndArray(); + return; } } diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs index f61f541659d..6e280e77a3e 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs @@ -15,9 +15,13 @@ namespace Microsoft.EntityFrameworkCore.Storage.Json; public abstract class JsonValueReaderWriter { /// - /// Ensures the external types extend from the generic + /// 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. /// - internal JsonValueReaderWriter() + [EntityFrameworkInternal] + public JsonValueReaderWriter() { } diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs index 46c51609e08..66d1b80a1b9 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Newtonsoft.Json.Linq; using Xunit.Sdk; namespace Microsoft.EntityFrameworkCore; @@ -44,41 +43,36 @@ public async Task Can_change_complex_collection_element() } [ConditionalFact] - public async Task Can_add_complex_collection_element() + public async Task Can_change_complex_collection_element_complex_collection() { await using var context = CreateContext(); var pub = CreatePubWithCollections(context); await context.AddAsync(pub); await context.SaveChangesAsync(); - pub.Activities.Add(new ActivityWithCollection { Name = "NewActivity" }); + pub.Activities[0].Teams.Add(new Team { Name = "NewTeam" }); await context.SaveChangesAsync(); await using var assertContext = CreateContext(); var dbPub = await assertContext.Set().FirstAsync(x => x.Id == pub.Id); - Assert.Equivalent("NewActivity", dbPub.Activities.Last().Name); - Assert.Equivalent(pub.Activities.Count, dbPub.Activities.Count); + Assert.Equal("NewTeam", dbPub.Activities[0].Teams.Last().Name); } [ConditionalFact] - public async Task Can_add_and_dynamically_update_complex_collection_element() + public async Task Can_add_complex_collection_element() { await using var context = CreateContext(); var pub = CreatePubWithCollections(context); await context.AddAsync(pub); await context.SaveChangesAsync(); - var pubJObject = context.Entry(pub).Property("__jObject").CurrentValue; - pubJObject["Activities"]![0]!["test"] = "test"; - pub.Activities.Insert(0, new ActivityWithCollection { Name = "NewActivity" }); - + pub.Activities.Add(new ActivityWithCollection { Name = "NewActivity" }); await context.SaveChangesAsync(); await using var assertContext = CreateContext(); var dbPub = await assertContext.Set().FirstAsync(x => x.Id == pub.Id); - var dbPubJObject = assertContext.Entry(dbPub).Property("__jObject").CurrentValue; - Assert.Equal("test", dbPubJObject["Activities"]![1]!["test"]); - Assert.Equal("NewActivity", dbPubJObject["Activities"]![0]!["Name"]); + Assert.Equivalent("NewActivity", dbPub.Activities.Last().Name); + Assert.Equivalent(pub.Activities.Count, dbPub.Activities.Count); } public override Task Can_save_null_second_level_complex_property_with_required_properties(bool async) diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs index 1831aff89ef..6aa986be2a8 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs @@ -281,6 +281,9 @@ public virtual async Task SaveChanges_entity_too_large_throws() [ConditionalTheory, InlineData(true), InlineData(false)] public virtual async Task SaveChanges_exactly_2_mib_does_not_split_and_one_byte_over_splits(bool oneByteOver) { + //for (var i = 350; i > 0; i--) + //{ + using var context = Fixture.CreateContext(); var customer1 = new Customer { Id = new string('x', 1023), PartitionKey = new string('x', 1023) }; @@ -292,8 +295,8 @@ public virtual async Task SaveChanges_exactly_2_mib_does_not_split_and_one_byte_ await context.SaveChangesAsync(); Fixture.ListLoggerFactory.Clear(); - customer1.Name = new string('x', 1044994); - customer2.Name = new string('x', 1044994); + customer1.Name = new string('x', 1045148); + customer2.Name = new string('x', 1045148); if (oneByteOver) { @@ -302,7 +305,7 @@ public virtual async Task SaveChanges_exactly_2_mib_does_not_split_and_one_byte_ await context.SaveChangesAsync(); using var assertContext = Fixture.CreateContext(); - Assert.Equal(2, (await context.Customers.ToListAsync()).Count); + Assert.Equal(2, (await assertContext.Customers.ToListAsync()).Count); if (oneByteOver) { @@ -310,8 +313,14 @@ public virtual async Task SaveChanges_exactly_2_mib_does_not_split_and_one_byte_ } else { + //Debug.Assert(2 == Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch), $"Failed on iteration {i}"); + //context.Remove(customer1); + //context.Remove(customer2); + //await context.SaveChangesAsync(); + //Fixture.ListLoggerFactory.Clear(); Assert.Equal(1, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); } + //} } [ConditionalFact] @@ -439,13 +448,26 @@ public virtual async Task SaveChanges_transaction_behavior_always_update_entitie await context.SaveChangesAsync(); - customer1.Name = new string('x', 1097582); - customer2.Name = new string('x', 1097583); + customer1.Name = new string('x', 1097736); + customer2.Name = new string('x', 1097737); if (oneByteOver) { customer1.Name += 'x'; customer2.Name += 'x'; + //for (var i = 1; i <= 1000; i++) + //{ + // customer1.Name += 'x'; + // customer2.Name += 'x'; + // try + // { + // await context.SaveChangesAsync(); + // } + // catch (Exception ex) + // { + // throw new Exception($"Off by 2 * {i} bytes", ex); + // } + //} await Assert.ThrowsAsync(() => context.SaveChangesAsync()); } else @@ -468,13 +490,26 @@ public virtual async Task SaveChanges_id_counts_double_toward_request_size_on_up await context.SaveChangesAsync(); - customer1.Name = new string('x', 1097581 + (1_024 - customer1.Id.Length) * 2); - customer2.Name = new string('x', 1097581 + (1_024 - customer2.Id.Length) * 2); + customer1.Name = new string('x', 1097735 + (1_024 - customer1.Id.Length) * 2); + customer2.Name = new string('x', 1097735 + (1_024 - customer2.Id.Length) * 2); if (oneByteOver) { customer1.Name += 'x'; customer2.Name += 'x'; + //for (var i = 1; i <= 1000; i++) + //{ + // customer1.Name += 'x'; + // customer2.Name += 'x'; + // try + // { + // await context.SaveChangesAsync(); + // } + // catch (Exception ex) + // { + // throw new Exception($"Off by 2 * {i} bytes", ex); + // } + //} await Assert.ThrowsAsync(() => context.SaveChangesAsync()); } else diff --git a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs index d552709df32..da40944ac15 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs @@ -162,8 +162,9 @@ await context.AddAsync( await context.AddAsync( new Person { Id = 3, Addresses = new List
{ existingAddress1Person3, existingAddress2Person3 } }); - await context.SaveChangesAsync(); + var entrys = context.ChangeTracker.Entries().ToList(); + await context.SaveChangesAsync(); var people = await context.Set().ToListAsync(); Assert.Empty(people[0].Addresses); @@ -239,12 +240,8 @@ await context.AddAsync( var existingFirstAddressEntry = context.Entry(people[2].Addresses.First()); - var addressJson = existingFirstAddressEntry.Property("__jObject").CurrentValue; - - Assert.Equal("First", addressJson[nameof(Address.Street)]); - addressJson["unmappedId"] = 2; - - existingFirstAddressEntry.Property("__jObject").IsModified = true; + Assert.Equal("First", people[2].Addresses.First().Street); + people[2].Addresses.First(); existingAddress1Person3 = people[2].Addresses.First(); existingAddress2Person3 = people[2].Addresses.Last(); @@ -269,7 +266,6 @@ await context.AddAsync( { existingAddress2Person3.Notes.Add(new Note { Content = "City note" }); } - await context.SaveChangesAsync(); await AssertState(context, useIds); @@ -348,11 +344,7 @@ async Task AssertState(EmbeddedTransportationContext context, bool useIds) var existingAddressEntry = context.Entry(addresses[0]); - var addressJson = existingAddressEntry.Property("__jObject").CurrentValue; - - Assert.Equal("First", addressJson[nameof(Address.Street)]); - Assert.Equal(6, addressJson.Count); - Assert.Equal(2, addressJson["unmappedId"]); + Assert.Equal("First", addresses[0].Street); Assert.Equal("Another", addresses[1].Street); Assert.Equal("City", addresses[1].City); diff --git a/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs index 0f81ad16f9a..31ee5399a0d 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs @@ -177,92 +177,6 @@ public async Task Can_add_update_delete_detached_entity_end_to_end(bool transact } } - [ConditionalTheory, InlineData(false), InlineData(true)] - public async Task Can_add_update_untracked_properties(bool transactionalBatch) - { - var contextFactory = await InitializeNonSharedTest( - b => b.Entity(), - shouldLogCategory: _ => true, - onConfiguring: o => o.ConfigureWarnings(w => w.Log(CosmosEventId.NoPartitionKeyDefined))); - - var customer = new Customer { Id = 42, Name = "Theon" }; - - using (var context = CreateContext(contextFactory, transactionalBatch)) - { - var entry = await context.AddAsync(customer); - - await context.SaveChangesAsync(); - - var document = entry.Property("__jObject").CurrentValue; - Assert.NotNull(document); - Assert.Equal("Theon", document["Name"]); - - context.Remove(customer); - - await context.SaveChangesAsync(); - - } - - using (var context = CreateContext(contextFactory, transactionalBatch)) - { - Assert.Empty(await context.Set().ToListAsync()); - - var entry = await context.AddAsync(customer); - - entry.Property("__jObject").CurrentValue = new JObject { ["key1"] = "value1" }; - - await context.SaveChangesAsync(); - - var document = entry.Property("__jObject").CurrentValue; - Assert.NotNull(document); - Assert.Equal("Theon", document["Name"]); - Assert.Equal("value1", document["key1"]); - - document["key2"] = "value2"; - entry.State = EntityState.Modified; - await context.SaveChangesAsync(); - } - - using (var context = CreateContext(contextFactory, transactionalBatch)) - { - var customerFromStore = await context.Set().SingleAsync(); - - Assert.Equal(42, customerFromStore.Id); - Assert.Equal("Theon", customerFromStore.Name); - - var entry = context.Entry(customerFromStore); - var document = entry.Property("__jObject").CurrentValue; - Assert.Equal("value1", document["key1"]); - Assert.Equal("value2", document["key2"]); - - document["key1"] = "value1.1"; - customerFromStore.Name = "Theon Greyjoy"; - - await context.SaveChangesAsync(); - } - - using (var context = CreateContext(contextFactory, transactionalBatch)) - { - var customerFromStore = await context.Set().SingleAsync(); - - Assert.Equal("Theon Greyjoy", customerFromStore.Name); - - var entry = context.Entry(customerFromStore); - var document = entry.Property("__jObject").CurrentValue; - Assert.Equal("value1.1", document["key1"]); - Assert.Equal("value2", document["key2"]); - - context.Remove(customerFromStore); - - await context.SaveChangesAsync(); - } - - using (var context = CreateContext(contextFactory, transactionalBatch)) - { - Assert.Empty(await context.Set().ToListAsync()); - } - } - [ConditionalTheory, InlineData(false), InlineData(true)] public async Task Can_add_update_delete_end_to_end_with_Guid(bool transactionalBatch) { @@ -760,6 +674,25 @@ await Can_add_update_delete_with_collection( { { "1", new Dictionary { { "value", 1 } } }, { "2", null } }); + + await Can_add_update_delete_with_collection>>>>( + transactionalBatch, + new() { + { "2", new() { { "value", new() { { "1", ["1", "2"] } } } } }, + { "1", new() { { "value", new() { { "2", ["3", "4"] } } } } } + }, + c => + { + c.Collection.Add("3", new() { { "value", new() { { "3", ["5", "6"] } } } }); + c.Collection.Remove("1"); + c.Collection["2"].Remove("value"); + c.Collection["2"].Add("value2", new() { { "4", ["7", "8"] } }); + }, + new() + { + { "2", new() { { "value2", new() { { "4", ["7", "8"] } } } } }, + { "3", new() { { "value", new() { { "3", ["5", "6"] } } } } } + }); } private async Task Can_add_update_delete_with_collection( @@ -1611,10 +1544,6 @@ public async Task Can_have_non_string_property_named_Discriminator(bool useDiscr var entry = await context.AddAsync(new NonStringDiscriminator { Id = 1 }); await context.SaveChangesAsync(); - var document = entry.Property("__jObject").CurrentValue; - Assert.NotNull(document); - Assert.Equal("0", document["Discriminator"]); - var baseEntity = await context.Set().OrderBy(e => e.Id).FirstOrDefaultAsync(); Assert.NotNull(baseEntity); diff --git a/test/EFCore.Cosmos.FunctionalTests/ReloadTest.cs b/test/EFCore.Cosmos.FunctionalTests/ReloadTest.cs index b54ff771e0d..2913707dfff 100644 --- a/test/EFCore.Cosmos.FunctionalTests/ReloadTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/ReloadTest.cs @@ -31,9 +31,6 @@ public async Task Entity_reference_can_be_reloaded() var entry = await context.AddAsync(new Item { Id = 1337, PartitionKey = "Foo" }); await context.SaveChangesAsync(); - var itemJson = entry.Property("__jObject").CurrentValue; - itemJson["unmapped"] = 2; - await entry.ReloadAsync(); AssertSql( @@ -52,9 +49,6 @@ FROM root c WHERE (c["Id"] = @p) OFFSET 0 LIMIT 1 """); - - itemJson = entry.Property("__jObject").CurrentValue; - Assert.Null(itemJson["unmapped"]); } protected ReloadTestContext CreateContext() diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/Basic_cosmos_model/DataEntityType.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/Basic_cosmos_model/DataEntityType.cs index d6f21078377..a5437a357ab 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/Basic_cosmos_model/DataEntityType.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/Basic_cosmos_model/DataEntityType.cs @@ -193,7 +193,13 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas byte[] (byte[] source) => source.ToArray()), converter: new ValueConverter, byte[]>( byte[] (ReadOnlyMemory v) => ReadOnlyMemoryConverter.ToArray(v), - ReadOnlyMemory (byte[] v) => ReadOnlyMemoryConverter.ToMemory(v))); + ReadOnlyMemory (byte[] v) => ReadOnlyMemoryConverter.ToMemory(v)), + jsonValueReaderWriter: new JsonConvertedValueReaderWriter, IEnumerable>( + new JsonCollectionOfStructsReaderWriter( + JsonByteReaderWriter.Instance), + new ValueConverter, byte[]>( + byte[] (ReadOnlyMemory v) => ReadOnlyMemoryConverter.ToArray(v), + ReadOnlyMemory (byte[] v) => ReadOnlyMemoryConverter.ToMemory(v)))); bytes.SetSentinelFromProviderValue(new byte[0]); var list = runtimeEntityType.AddProperty( @@ -226,7 +232,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas List> (List> v) => v), clrType: typeof(List>), jsonValueReaderWriter: new JsonCollectionOfReferencesReaderWriter>, Dictionary>( - new CosmosTypeMappingSource.PlaceholderJsonStringKeyedDictionaryReaderWriter( + new CosmosTypeMappingSource.CosmosJsonStringKeyedDictionaryReaderWriter( JsonInt32ReaderWriter.Instance)), elementMapping: CosmosTypeMapping.Default.Clone( comparer: new StringDictionaryComparer, int>(new ValueComparer( @@ -242,7 +248,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas int (Dictionary v) => ((object)v).GetHashCode(), Dictionary (Dictionary v) => v), clrType: typeof(Dictionary), - jsonValueReaderWriter: new CosmosTypeMappingSource.PlaceholderJsonStringKeyedDictionaryReaderWriter( + jsonValueReaderWriter: new CosmosTypeMappingSource.CosmosJsonStringKeyedDictionaryReaderWriter( JsonInt32ReaderWriter.Instance))); var listElementType = list.SetElementType(typeof(Dictionary), nullable: true); @@ -277,7 +283,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas int (Dictionary v) => ((object)v).GetHashCode(), Dictionary (Dictionary v) => v), clrType: typeof(Dictionary), - jsonValueReaderWriter: new CosmosTypeMappingSource.PlaceholderJsonStringKeyedDictionaryReaderWriter( + jsonValueReaderWriter: new CosmosTypeMappingSource.CosmosJsonStringKeyedDictionaryReferenceCollectionValueReaderWriter( new JsonCollectionOfReferencesReaderWriter( JsonStringReaderWriter.Instance))); diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs index 0d56c570a44..c2115d4e3ed 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs @@ -233,15 +233,19 @@ private async Task CreateFromFile(DbContext context) if (reader.TokenType == JsonToken.StartObject) { var document = serializer.Deserialize(reader)!; - - document["id"] = discriminatorInId == true + var documentId = discriminatorInId == true ? $"{entityName}|{document["id"]}" : $"{document["id"]}"; + document["id"] = documentId; + document["$type"] = entityName; await cosmosClient.CreateItemAsync( - containerName!, document, new FakeUpdateEntry(), new NullSessionTokenStorage()).ConfigureAwait(false); + containerName!, documentId, + new MemoryStream(Encoding.UTF8.GetBytes(document.ToString())), + new FakeUpdateEntry(), + new NullSessionTokenStorage()).ConfigureAwait(false); } else if (reader.TokenType == JsonToken.EndObject) { From e54b34a9449e7c54fae03ce3c4ad59dd5b40003f Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:22:58 +0200 Subject: [PATCH 02/29] Fix tests and add clone method --- .../Internal/CosmosTimeOnlyTypeMapping.cs | 14 ++++++ .../Internal/CosmosTimeSpanTypeMapping.cs | 14 ++++++ .../JsonTypesCosmosTest.cs | 48 +++++++++++++++++++ .../Internal/CosmosTypeMappingSourceTest.cs | 8 ++-- .../JsonTypesTestBase.cs | 24 +++++----- 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs index 670456536e3..b1d8dc3e3d4 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs @@ -20,4 +20,18 @@ public class CosmosTimeOnlyTypeMapping : CosmosTypeMapping public CosmosTimeOnlyTypeMapping() : base(typeof(TimeOnly), null, null, null, CosmosJsonTimeOnlyReaderWriter.Instance) { } + + /// + /// 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. + /// + protected CosmosTimeOnlyTypeMapping(CoreTypeMappingParameters parameters) : base(parameters) + { + } + + /// + protected override CoreTypeMapping Clone(CoreTypeMappingParameters parameters) + => new CosmosTimeOnlyTypeMapping(parameters); } diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs index effc8c3a5ba..b8fe7ca8cc4 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs @@ -20,4 +20,18 @@ public class CosmosTimeSpanTypeMapping : CosmosTypeMapping public CosmosTimeSpanTypeMapping() : base(typeof(TimeSpan), null, null, null, CosmosJsonTimeSpanReaderWriter.Instance) { } + + /// + /// 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. + /// + protected CosmosTimeSpanTypeMapping(CoreTypeMappingParameters parameters) : base(parameters) + { + } + + /// + protected override CoreTypeMapping Clone(CoreTypeMappingParameters parameters) + => new CosmosTimeSpanTypeMapping(parameters); } diff --git a/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs index 2b287e85924..41a88d76f15 100644 --- a/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs @@ -5,6 +5,54 @@ namespace Microsoft.EntityFrameworkCore; public class JsonTypesCosmosTest(NonSharedFixture fixture) : JsonTypesTestBase(fixture) { + public override Task Can_read_write_TimeSpan_JSON_values(string value, string json) + => base.Can_read_write_TimeSpan_JSON_values( + value, value switch + { + "-10675199.02:48:05.4775808" => """{"Prop":"-10675199.02:48:05.4775808"}""", + "10675199.02:48:05.4775807" => """{"Prop":"10675199.02:48:05.4775807"}""", + "00:00:00" => """{"Prop":"00:00:00"}""", + _ => json, + }); + + public override Task Can_read_write_TimeOnly_JSON_values(string value, string json) + => base.Can_read_write_TimeOnly_JSON_values( + value, value switch + { + "00:00:00.0000000" => """{"Prop":"00:00:00"}""", + _ => json, + }); + + public override Task Can_read_write_nullable_TimeOnly_JSON_values(string? value, string json) + => base.Can_read_write_nullable_TimeOnly_JSON_values( + value, value switch + { + "00:00:00.0000000" => """{"Prop":"00:00:00"}""", + _ => json, + }); + + public override Task Can_read_write_nullable_TimeSpan_JSON_values(string? value, string json) + => base.Can_read_write_nullable_TimeSpan_JSON_values( + value, value switch + { + "-10675199.02:48:05.4775808" => """{"Prop":"-10675199.02:48:05.4775808"}""", + "10675199.02:48:05.4775807" => """{"Prop":"10675199.02:48:05.4775807"}""", + "00:00:00" => """{"Prop":"00:00:00"}""", + _ => json, + }); + + public override Task Can_read_write_collection_of_TimeOnly_JSON_values(string _) + => base.Can_read_write_collection_of_TimeOnly_JSON_values("""{"Prop":["00:00:00","11:05:02.003004","23:59:59.9999999"]}"""); + + public override Task Can_read_write_collection_of_TimeSpan_JSON_values(string _) + => base.Can_read_write_collection_of_TimeSpan_JSON_values("""{"Prop":["-10675199.02:48:05.4775808","1.02:03:04.0050000","10675199.02:48:05.4775807"]}"""); + + public override Task Can_read_write_collection_of_nullable_TimeOnly_JSON_values(string _) + => base.Can_read_write_collection_of_nullable_TimeOnly_JSON_values("""{"Prop":[null, "00:00:00","11:05:02.003004","23:59:59.9999999"]}"""); + + public override Task Can_read_write_collection_of_nullable_TimeSpan_JSON_values(string _) + => base.Can_read_write_collection_of_nullable_TimeSpan_JSON_values("""{"Prop":["-10675199.02:48:05.4775808","1.02:03:04.0050000","10675199.02:48:05.4775807", null]}"""); + public override Task Can_read_write_collection_of_Guid_converted_to_bytes_JSON_values(string expected) // Cosmos provider cannot map collections of elements with converters. See Issue #34026. => Assert.ThrowsAsync(() => base.Can_read_write_collection_of_Guid_converted_to_bytes_JSON_values( diff --git a/test/EFCore.Cosmos.Tests/Storage/Internal/CosmosTypeMappingSourceTest.cs b/test/EFCore.Cosmos.Tests/Storage/Internal/CosmosTypeMappingSourceTest.cs index d879f8208ff..f695c6c3339 100644 --- a/test/EFCore.Cosmos.Tests/Storage/Internal/CosmosTypeMappingSourceTest.cs +++ b/test/EFCore.Cosmos.Tests/Storage/Internal/CosmosTypeMappingSourceTest.cs @@ -68,7 +68,7 @@ public void Can_map_DateOnly() [ConditionalFact] public void Can_map_TimeOnly() - => Can_map_scalar_by_clr_type( + => Can_map_scalar_by_clr_type( new TimeOnly(20, 19, 12, 254), JTokenType.String, "\"20:19:12.254\""); [ConditionalFact] @@ -84,7 +84,7 @@ public void Can_map_DateTimeOffset() [ConditionalFact] public void Can_map_TimeSpan() - => Can_map_scalar_by_clr_type(new TimeSpan(2, 3, 4, 5), JTokenType.TimeSpan, "\"2.03:04:05\""); + => Can_map_scalar_by_clr_type(new TimeSpan(2, 3, 4, 5), JTokenType.TimeSpan, "\"2.03:04:05\""); [ConditionalFact] public void Can_map_string() @@ -148,7 +148,7 @@ public void Can_map_nullable_DateOnly() [ConditionalFact] public void Can_map_nullable_TimeOnly() - => Can_map_scalar_by_clr_type( + => Can_map_scalar_by_clr_type( new TimeOnly(20, 19, 12, 254), JTokenType.String, "\"20:19:12.254\""); [ConditionalFact] @@ -164,7 +164,7 @@ public void Can_map_nullable_DateTimeOffset() [ConditionalFact] public void Can_map_nullable_TimeSpan() - => Can_map_scalar_by_clr_type(new TimeSpan(2, 3, 4, 5), JTokenType.TimeSpan, "\"2.03:04:05\""); + => Can_map_scalar_by_clr_type(new TimeSpan(2, 3, 4, 5), JTokenType.TimeSpan, "\"2.03:04:05\""); [ConditionalFact] public void Can_map_nullable_string() diff --git a/test/EFCore.Specification.Tests/JsonTypesTestBase.cs b/test/EFCore.Specification.Tests/JsonTypesTestBase.cs index f79a154024e..e93ea1ee233 100644 --- a/test/EFCore.Specification.Tests/JsonTypesTestBase.cs +++ b/test/EFCore.Specification.Tests/JsonTypesTestBase.cs @@ -1657,8 +1657,8 @@ protected class DateOnlyCollectionType public IList DateOnly { get; set; } = null!; } - [ConditionalFact] - public virtual Task Can_read_write_collection_of_TimeOnly_JSON_values() + [ConditionalTheory, InlineData("""{"Prop":["00:00:00.0000000","11:05:02.0030040","23:59:59.9999999"]}""")] + public virtual Task Can_read_write_collection_of_TimeOnly_JSON_values(string expected) => Can_read_and_write_JSON_value>( nameof(TimeOnlyCollectionType.TimeOnly), [ @@ -1666,7 +1666,7 @@ public virtual Task Can_read_write_collection_of_TimeOnly_JSON_values() new TimeOnly(11, 5, 2, 3, 4), TimeOnly.MaxValue ], - """{"Prop":["00:00:00.0000000","11:05:02.0030040","23:59:59.9999999"]}""", + expected, mappedCollection: true, new List()); @@ -1712,8 +1712,8 @@ protected class DateTimeOffsetCollectionType public IList DateTimeOffset { get; set; } = null!; } - [ConditionalFact] - public virtual Task Can_read_write_collection_of_TimeSpan_JSON_values() + [ConditionalTheory, InlineData("""{"Prop":["-10675199:2:48:05.4775808","1:2:03:04.005","10675199:2:48:05.4775807"]}""")] + public virtual Task Can_read_write_collection_of_TimeSpan_JSON_values(string expected) => Can_read_and_write_JSON_value>( nameof(TimeSpanCollectionType.TimeSpan), [ @@ -1721,7 +1721,7 @@ public virtual Task Can_read_write_collection_of_TimeSpan_JSON_values() new TimeSpan(1, 2, 3, 4, 5), TimeSpan.MaxValue ], - """{"Prop":["-10675199:2:48:05.4775808","1:2:03:04.005","10675199:2:48:05.4775807"]}""", + expected, mappedCollection: true); protected class TimeSpanCollectionType @@ -2238,8 +2238,8 @@ protected class NullableDateOnlyCollectionType public IList DateOnly { get; set; } = null!; } - [ConditionalFact] - public virtual Task Can_read_write_collection_of_nullable_TimeOnly_JSON_values() + [ConditionalTheory, InlineData("""{"Prop":[null,"00:00:00.0000000","11:05:02.0030040","23:59:59.9999999"]}""")] + public virtual Task Can_read_write_collection_of_nullable_TimeOnly_JSON_values(string expected) => Can_read_and_write_JSON_value>( nameof(NullableTimeOnlyCollectionType.TimeOnly), [ @@ -2248,7 +2248,7 @@ public virtual Task Can_read_write_collection_of_nullable_TimeOnly_JSON_values() new TimeOnly(11, 5, 2, 3, 4), TimeOnly.MaxValue ], - """{"Prop":[null,"00:00:00.0000000","11:05:02.0030040","23:59:59.9999999"]}""", + expected, mappedCollection: true); protected class NullableTimeOnlyCollectionType @@ -2295,8 +2295,8 @@ protected class NullableDateTimeOffsetCollectionType public IReadOnlyList DateTimeOffset { get; set; } = null!; } - [ConditionalFact] - public virtual Task Can_read_write_collection_of_nullable_TimeSpan_JSON_values() + [ConditionalTheory, InlineData("""{"Prop":["-10675199:2:48:05.4775808","1:2:03:04.005","10675199:2:48:05.4775807",null]}""")] + public virtual Task Can_read_write_collection_of_nullable_TimeSpan_JSON_values(string expected) => Can_read_and_write_JSON_value>( nameof(NullableTimeSpanCollectionType.TimeSpan), [ @@ -2305,7 +2305,7 @@ public virtual Task Can_read_write_collection_of_nullable_TimeSpan_JSON_values() TimeSpan.MaxValue, null ], - """{"Prop":["-10675199:2:48:05.4775808","1:2:03:04.005","10675199:2:48:05.4775807",null]}""", + expected, mappedCollection: true); protected class NullableTimeSpanCollectionType From bd27697576f366374c827957fc316364ae5e4e4f Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:00:40 +0200 Subject: [PATCH 03/29] Vector fixes --- .../CosmosPropertyBuilderExtensions.cs | 1 + .../Internal/CosmosJsonVectorReaderWriter.cs | 122 ------------------ .../Internal/CosmosTypeMappingSource.cs | 36 +++++- .../Internal/CosmosVectorTypeMapping.cs | 41 +----- .../Storage/Json/JsonValueReaderWriter.cs | 8 +- 5 files changed, 36 insertions(+), 172 deletions(-) delete mode 100644 src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs diff --git a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs index ba3c2b815ee..b162344c66b 100644 --- a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs +++ b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore; diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs deleted file mode 100644 index 60a55d75818..00000000000 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosJsonVectorReaderWriter.cs +++ /dev/null @@ -1,122 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using Microsoft.EntityFrameworkCore.Storage.Json; - -namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; - -/// -/// 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. -/// -public sealed class CosmosJsonVectorReaderWriter : JsonValueReaderWriter -{ - private static readonly PropertyInfo InstanceProperty = typeof(CosmosJsonVectorReaderWriter).GetProperty(nameof(Instance))!; - - /// - /// 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. - /// - public static CosmosJsonVectorReaderWriter Instance { get; } = new(); - - /// - /// 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. - /// - public override object FromJson(ref Utf8JsonReaderManager manager, object? existingObject = null) - { - var tokenType = manager.CurrentReader.TokenType; - if (tokenType != JsonTokenType.StartArray) - { - throw new InvalidOperationException( - CoreStrings.JsonReaderInvalidTokenType(tokenType.ToString())); - } - - var result = new List(); - - while (tokenType != JsonTokenType.EndArray) - { - manager.MoveNext(); - tokenType = manager.CurrentReader.TokenType; - - if (tokenType != JsonTokenType.Number || !manager.CurrentReader.TryGetInt32(out var intValue)) - { - throw new InvalidOperationException( - CoreStrings.JsonReaderInvalidTokenType(tokenType.ToString())); - } - - result.Add((byte)intValue); - } - - return result.ToArray(); - } - - /// - /// 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. - /// - public override void ToJson(Utf8JsonWriter writer, object value) - { - writer.WriteStartArray(); - - switch (value) - { - case IEnumerable bytes: - foreach (var item in bytes) - { - writer.WriteNumberValue(item); - } - break; - case ReadOnlyMemory rom: - foreach (var item in rom.Span) - { - writer.WriteNumberValue(item); - } - break; - case IEnumerable bytes: - foreach (var item in bytes) - { - writer.WriteNumberValue(item); - } - break; - case ReadOnlyMemory rom: - foreach (var item in rom.Span) - { - writer.WriteNumberValue(item); - } - break; - case IEnumerable bytes: - foreach (var item in bytes) - { - writer.WriteNumberValue(item); - } - break; - case ReadOnlyMemory rom: - foreach (var item in rom.Span) - { - writer.WriteNumberValue(item); - } - break; - default: - throw new InvalidOperationException(); - } - - writer.WriteEndArray(); - } - - /// - public override Expression ConstructorExpression - => Expression.Property(null, InstanceProperty); - - /// - public override Type ValueType { get; } = typeof(byte[]); -} diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index 3781aa31fc4..44f0e99897d 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -48,17 +48,39 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public override CoreTypeMapping? FindMapping(IProperty property) + { // A provider should typically not override this because using the property directly causes problems with Migrations where // the property does not exist. However, since the Cosmos provider doesn't have Migrations, it should be okay to use the property // directly. - => base.FindMapping(property) switch + if (property.GetVectorDistanceFunction() is { } distanceFunction + && property.GetVectorDimensions() is { } dimensions) { - CosmosTypeMapping mapping - when property.GetVectorDistanceFunction() is { } distanceFunction - && property.GetVectorDimensions() is { } dimensions - => new CosmosVectorTypeMapping(mapping, new CosmosVectorType(distanceFunction, dimensions)), - var other => other - }; + CoreTypeMapping? elementMapping = null; + + var collectionType = property.ClrType; + Type? elementType = null; + if (collectionType.IsGenericType + && collectionType.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>)) + { + collectionType = collectionType.GetGenericArguments()[0].MakeArrayType(); + elementType = collectionType.GetElementType(); + } + + var collectionReaderWriter = TryFindJsonCollectionMapping(new TypeMappingInfo(collectionType), collectionType, null, ref elementMapping, out var _, out var r) ? r : null; + CoreTypeMapping mapping = new CosmosVectorTypeMapping(property.ClrType, new CosmosVectorType(distanceFunction, dimensions), collectionReaderWriter); + + if (elementType != null) + { + mapping = mapping.WithComposedConverter( + (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(elementType))!, + (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(elementType))!); + } + + return mapping; + } + + return base.FindMapping(property); + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs index 83b029e5d9b..7b8d57711dd 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs @@ -25,7 +25,7 @@ public class CosmosVectorTypeMapping : CosmosTypeMapping // Note that this default is not valid because dimensions cannot be zero. But since there is no reasonable // default dimensions size for a vector type, this is intentionally not valid rather than just being wrong. // The fundamental problem here is that type mappings are "required" to have some default now. - = new(typeof(byte[]), new CosmosVectorType(DistanceFunction.Cosine, 0)); + = new(typeof(byte[]), new CosmosVectorType(DistanceFunction.Cosine, 0), null); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -33,42 +33,9 @@ public class CosmosVectorTypeMapping : CosmosTypeMapping /// 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. /// - public CosmosVectorTypeMapping( - Type clrType, - CosmosVectorType vectorType, - ValueComparer? comparer = null, - ValueComparer? keyComparer = null, - CoreTypeMapping? elementMapping = null) - : this( - new CoreTypeMappingParameters( - clrType, - converter: null, - comparer, - keyComparer, - elementMapping: elementMapping, - jsonValueReaderWriter: CosmosJsonVectorReaderWriter.Instance), - vectorType) - { - } - - /// - /// 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. - /// - public CosmosVectorTypeMapping(CosmosTypeMapping mapping, CosmosVectorType vectorType) - : this( - new CoreTypeMappingParameters( - mapping.ClrType, - // This is a hack to allow both arrays and ROM types without different function overloads or type mappings. - converter: mapping.Converter?.GetType() == typeof(BytesToStringConverter) ? null : mapping.Converter, - mapping.Comparer, - mapping.KeyComparer, - elementMapping: mapping.ElementTypeMapping, - jsonValueReaderWriter: CosmosJsonVectorReaderWriter.Instance), - vectorType) + public CosmosVectorTypeMapping(Type clrType, CosmosVectorType vectorType, JsonValueReaderWriter? jsonValueReaderWriter) : base(clrType, jsonValueReaderWriter: jsonValueReaderWriter) { + VectorType = vectorType; } /// @@ -77,7 +44,7 @@ public CosmosVectorTypeMapping(CosmosTypeMapping mapping, CosmosVectorType vecto /// 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. /// - protected CosmosVectorTypeMapping(CoreTypeMappingParameters parameters, CosmosVectorType vectorType) + private CosmosVectorTypeMapping(CoreTypeMappingParameters parameters, CosmosVectorType vectorType) : base(parameters) => VectorType = vectorType; diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs index 6e280e77a3e..f61f541659d 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs @@ -15,13 +15,9 @@ namespace Microsoft.EntityFrameworkCore.Storage.Json; public abstract class JsonValueReaderWriter { /// - /// 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. + /// Ensures the external types extend from the generic /// - [EntityFrameworkInternal] - public JsonValueReaderWriter() + internal JsonValueReaderWriter() { } From da9163d3f9c6d5cd4c6a4cbccf8d604c6c75c4e4 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:38:49 +0200 Subject: [PATCH 04/29] fix whitespace --- test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs index 41a88d76f15..bac2f8c1056 100644 --- a/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs @@ -48,10 +48,10 @@ public override Task Can_read_write_collection_of_TimeSpan_JSON_values(string _) => base.Can_read_write_collection_of_TimeSpan_JSON_values("""{"Prop":["-10675199.02:48:05.4775808","1.02:03:04.0050000","10675199.02:48:05.4775807"]}"""); public override Task Can_read_write_collection_of_nullable_TimeOnly_JSON_values(string _) - => base.Can_read_write_collection_of_nullable_TimeOnly_JSON_values("""{"Prop":[null, "00:00:00","11:05:02.003004","23:59:59.9999999"]}"""); + => base.Can_read_write_collection_of_nullable_TimeOnly_JSON_values("""{"Prop":[null,"00:00:00","11:05:02.003004","23:59:59.9999999"]}"""); public override Task Can_read_write_collection_of_nullable_TimeSpan_JSON_values(string _) - => base.Can_read_write_collection_of_nullable_TimeSpan_JSON_values("""{"Prop":["-10675199.02:48:05.4775808","1.02:03:04.0050000","10675199.02:48:05.4775807", null]}"""); + => base.Can_read_write_collection_of_nullable_TimeSpan_JSON_values("""{"Prop":["-10675199.02:48:05.4775808","1.02:03:04.0050000","10675199.02:48:05.4775807",null]}"""); public override Task Can_read_write_collection_of_Guid_converted_to_bytes_JSON_values(string expected) // Cosmos provider cannot map collections of elements with converters. See Issue #34026. From d69f953010ae4a8e562e45f54d013a21f7b111ec Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:05:05 +0200 Subject: [PATCH 05/29] Fix tests --- .../Internal/CosmosTimeOnlyTypeMapping.cs | 8 +++ .../Internal/CosmosTimeSpanTypeMapping.cs | 8 +++ .../Internal/CosmosTypeMappingSource.cs | 4 +- .../Baselines/BigModel/ManyTypesEntityType.cs | 68 ++----------------- 4 files changed, 24 insertions(+), 64 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs index b1d8dc3e3d4..77a0a4ffaaf 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs @@ -11,6 +11,14 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class CosmosTimeOnlyTypeMapping : CosmosTypeMapping { + /// + /// 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. + /// + public static new CosmosTimeOnlyTypeMapping Default { get; } = new(); + /// /// 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 diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs index b8fe7ca8cc4..86bb1c7844f 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs @@ -11,6 +11,14 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class CosmosTimeSpanTypeMapping : CosmosTypeMapping { + /// + /// 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. + /// + public static new CosmosTimeSpanTypeMapping Default { get; } = new(); + /// /// 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 diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index 44f0e99897d..d9e5d3198fc 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -33,8 +33,8 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) => _clrTypeMappings = new Dictionary { - { typeof(TimeOnly), new CosmosTimeOnlyTypeMapping() }, - { typeof(TimeSpan), new CosmosTimeSpanTypeMapping() }, + { typeof(TimeOnly), CosmosTimeOnlyTypeMapping.Default }, + { typeof(TimeSpan), CosmosTimeSpanTypeMapping.Default }, { typeof(JObject), new CosmosTypeMapping( typeof(JObject), jsonValueReaderWriter: dependencies.JsonValueReaderWriterSource.FindReaderWriter(typeof(JObject))) diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs index 6f3597f77de..d3f87c5bb7a 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs @@ -6949,21 +6949,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableTimeOnly.TypeMapping = CosmosTypeMapping.Default.Clone( - comparer: new ValueComparer( - bool (TimeOnly v1, TimeOnly v2) => v1.Equals(v2), - int (TimeOnly v) => ((object)v).GetHashCode(), - TimeOnly (TimeOnly v) => v), - keyComparer: new ValueComparer( - bool (TimeOnly v1, TimeOnly v2) => v1.Equals(v2), - int (TimeOnly v) => ((object)v).GetHashCode(), - TimeOnly (TimeOnly v) => v), - providerValueComparer: new ValueComparer( - bool (TimeOnly v1, TimeOnly v2) => v1.Equals(v2), - int (TimeOnly v) => ((object)v).GetHashCode(), - TimeOnly (TimeOnly v) => v), - clrType: typeof(TimeOnly), - jsonValueReaderWriter: JsonTimeOnlyReaderWriter.Instance); + nullableTimeOnly.TypeMapping = CosmosTimeOnlyTypeMapping.Default; nullableTimeOnly.SetComparer(new NullableValueComparer(nullableTimeOnly.TypeMapping.Comparer)); nullableTimeOnly.SetKeyComparer(new NullableValueComparer(nullableTimeOnly.TypeMapping.KeyComparer)); @@ -6999,21 +6985,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableTimeSpan.TypeMapping = CosmosTypeMapping.Default.Clone( - comparer: new ValueComparer( - bool (TimeSpan v1, TimeSpan v2) => v1.Equals(v2), - int (TimeSpan v) => ((object)v).GetHashCode(), - TimeSpan (TimeSpan v) => v), - keyComparer: new ValueComparer( - bool (TimeSpan v1, TimeSpan v2) => v1.Equals(v2), - int (TimeSpan v) => ((object)v).GetHashCode(), - TimeSpan (TimeSpan v) => v), - providerValueComparer: new ValueComparer( - bool (TimeSpan v1, TimeSpan v2) => v1.Equals(v2), - int (TimeSpan v) => ((object)v).GetHashCode(), - TimeSpan (TimeSpan v) => v), - clrType: typeof(TimeSpan), - jsonValueReaderWriter: JsonTimeSpanReaderWriter.Instance); + nullableTimeSpan.TypeMapping = CosmosTimeSpanTypeMapping.Default; nullableTimeSpan.SetComparer(new NullableValueComparer(nullableTimeSpan.TypeMapping.Comparer)); nullableTimeSpan.SetKeyComparer(new NullableValueComparer(nullableTimeSpan.TypeMapping.KeyComparer)); @@ -8595,7 +8567,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas TimeOnly (string v) => TimeOnly.Parse(v, CultureInfo.InvariantCulture, DateTimeStyles.None), string (TimeOnly v) => (v.Ticks % 10000000L == 0L ? string.Format(CultureInfo.InvariantCulture, "{0:HH\\:mm\\:ss}", ((object)v)) : v.ToString("o"))), jsonValueReaderWriter: new JsonConvertedValueReaderWriter( - JsonTimeOnlyReaderWriter.Instance, + CosmosJsonTimeOnlyReaderWriter.Instance, new ValueConverter( TimeOnly (string v) => TimeOnly.Parse(v, CultureInfo.InvariantCulture, DateTimeStyles.None), string (TimeOnly v) => (v.Ticks % 10000000L == 0L ? string.Format(CultureInfo.InvariantCulture, "{0:HH\\:mm\\:ss}", ((object)v)) : v.ToString("o"))))); @@ -8649,7 +8621,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas TimeSpan (string v) => TimeSpan.Parse(v, CultureInfo.InvariantCulture), string (TimeSpan v) => v.ToString("c")), jsonValueReaderWriter: new JsonConvertedValueReaderWriter( - JsonTimeSpanReaderWriter.Instance, + CosmosJsonTimeSpanReaderWriter.Instance, new ValueConverter( TimeSpan (string v) => TimeSpan.Parse(v, CultureInfo.InvariantCulture), string (TimeSpan v) => v.ToString("c")))); @@ -8740,21 +8712,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - timeOnly.TypeMapping = CosmosTypeMapping.Default.Clone( - comparer: new ValueComparer( - bool (TimeOnly v1, TimeOnly v2) => v1.Equals(v2), - int (TimeOnly v) => ((object)v).GetHashCode(), - TimeOnly (TimeOnly v) => v), - keyComparer: new ValueComparer( - bool (TimeOnly v1, TimeOnly v2) => v1.Equals(v2), - int (TimeOnly v) => ((object)v).GetHashCode(), - TimeOnly (TimeOnly v) => v), - providerValueComparer: new ValueComparer( - bool (TimeOnly v1, TimeOnly v2) => v1.Equals(v2), - int (TimeOnly v) => ((object)v).GetHashCode(), - TimeOnly (TimeOnly v) => v), - clrType: typeof(TimeOnly), - jsonValueReaderWriter: JsonTimeOnlyReaderWriter.Instance); + timeOnly.TypeMapping = CosmosTimeOnlyTypeMapping.Default; var timeOnlyToStringConverterProperty = runtimeEntityType.AddProperty( "TimeOnlyToStringConverterProperty", @@ -8898,21 +8856,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - timeSpan.TypeMapping = CosmosTypeMapping.Default.Clone( - comparer: new ValueComparer( - bool (TimeSpan v1, TimeSpan v2) => v1.Equals(v2), - int (TimeSpan v) => ((object)v).GetHashCode(), - TimeSpan (TimeSpan v) => v), - keyComparer: new ValueComparer( - bool (TimeSpan v1, TimeSpan v2) => v1.Equals(v2), - int (TimeSpan v) => ((object)v).GetHashCode(), - TimeSpan (TimeSpan v) => v), - providerValueComparer: new ValueComparer( - bool (TimeSpan v1, TimeSpan v2) => v1.Equals(v2), - int (TimeSpan v) => ((object)v).GetHashCode(), - TimeSpan (TimeSpan v) => v), - clrType: typeof(TimeSpan), - jsonValueReaderWriter: JsonTimeSpanReaderWriter.Instance); + timeSpan.TypeMapping = CosmosTimeSpanTypeMapping.Default; var timeSpanToStringConverterProperty = runtimeEntityType.AddProperty( "TimeSpanToStringConverterProperty", From 530250623831aaa6dfe4d1ed5aa9d8dc9f7b4980 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:44:43 +0200 Subject: [PATCH 06/29] Clean --- src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs | 1 - .../Query/Internal/CosmosSerializationUtilities.cs | 2 +- src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs index b162344c66b..ba3c2b815ee 100644 --- a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs +++ b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; -using Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore; diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs b/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs index 98a7b309083..4daf7b928b2 100644 --- a/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs +++ b/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs @@ -17,7 +17,7 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal; /// /// Inspired by RelationalJsonUtilities. /// -public static class CosmosSerializationUtilities // @TODO: Can this be removed? Use document source instead? +public static class CosmosSerializationUtilities // @TODO: Can this be removed? Use document source instead? #34567 { /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs index 3aeeb145872..5ad66bcbfd6 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs @@ -520,7 +520,7 @@ public virtual DocumentSource GetDocumentSource(IEntityType entityType) if (!_documentCollections.TryGetValue(entityType, out var documentSource)) { _documentCollections.Add( - entityType, documentSource = new DocumentSource(entityType)); // @TODO: Make this singleton? + entityType, documentSource = new DocumentSource(entityType)); // @TODO: Make this singleton #34567 } return documentSource; From b38b19cdfdd9dcc834e215235078771465bd6a14 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:53:55 +0200 Subject: [PATCH 07/29] Clean --- .../Storage/Internal/CosmosClientWrapper.cs | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index f3a195c780c..34c7da6039d 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -676,50 +676,6 @@ private static void ProcessWriteResponse(IUpdateEntry entry, string eTag, Stream { entry.SetStoreGeneratedValue(etagProperty, eTag); } - - // @TODO: If the entry is loaded from the database, has an etag and has no triggers, we know that nothing has changed in the meantime right? - // Could we optimize? - if (content != null && content.Length > 0) - { - // @TODO: Entries without etag or with a pre- or post- trigger could have an updated document returned. - // We should consider processing the returned document in that case as well - // Invoke tracking shaper labmda? - - // How did that work before? is appears to be only updaing the underlying jobject (removed now), but not the entry? Would setting the _jObject update the entry no right? - // But updating the underlying jobject would make it so that subsequent updates would not use old data, unless the user updated the property on the entity... - // Now that behaviour would be gone. - - // @TODO: Ask what to do here? - - // used to be this: - //var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - //if (jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } - // && content != null) - //{ - // using var responseStream = content; - // using var reader = new StreamReader(responseStream); - // using var jsonReader = new JsonTextReader(reader); - - // var createdDocument = Serializer.Deserialize(jsonReader); - - // entry.SetStoreGeneratedValue(jObjectProperty, createdDocument); - //} - - // jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } is always false by default.. - // Can the user set this to true? Probably right. - // Interestingly, default for enableContentResponseOnRewrite is true, but jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } is always false. - // So enableContentResponseOnRewrite default or true does nothing without modifying the jObjectProperty's value generated. - // Does this mean anyone was really using this? - // Implementing this to work without setting jobject property value generated (because it will be removed) would be a theoratical breaking change? - // Or a bug fix? - - // No tests that don't use __jObject fail without this code - // @TODO: Add tests for this on main. - - // Suggestion: Make the new default enableContentResponseOnRewrite false? - // People who have set jObjectProperty ValueGenerated can migrate by manually setting EnableContentResponseOnRewrite to true. - // Others will notice no changes this way. - } } /// From ab0a8f56e7e3175c404ba24aa0de97c47f76486f Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:55:25 +0200 Subject: [PATCH 08/29] remove ContentResponseOnWriteEnabled --- .../Internal/CosmosDbOptionExtension.cs | 27 -------------- .../Internal/CosmosSingletonOptions.cs | 8 ----- .../Internal/ICosmosSingletonOptions.cs | 8 ----- .../Storage/Internal/CosmosClientWrapper.cs | 14 ++++---- .../CosmosTransactionalBatchWrapper.cs | 15 ++++---- .../Storage/Internal/RequestOptionsHelper.cs | 36 +++---------------- .../CosmosDbContextOptionsExtensionsTests.cs | 1 - 7 files changed, 16 insertions(+), 93 deletions(-) diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs index e1656712408..7b994bb9e1a 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs @@ -33,7 +33,6 @@ public class CosmosOptionsExtension : IDbContextOptionsExtension private int? _gatewayModeMaxConnectionLimit; private int? _maxTcpConnectionsPerEndpoint; private int? _maxRequestsPerTcpConnection; - private bool? _enableContentResponseOnWrite; private DbContextOptionsExtensionInfo? _info; private Func? _httpClientFactory; private SessionTokenManagementMode _sessionTokenManagementMode = SessionTokenManagementMode.FullyAutomatic; @@ -497,30 +496,6 @@ public virtual CosmosOptionsExtension WithMaxRequestsPerTcpConnection(int? reque return clone; } - /// - /// 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. - /// - public virtual bool? EnableContentResponseOnWrite - => _enableContentResponseOnWrite; - - /// - /// 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. - /// - public virtual CosmosOptionsExtension ContentResponseOnWriteEnabled(bool enabled) - { - var clone = Clone(); - - clone._enableContentResponseOnWrite = enabled; - - return clone; - } - /// /// A factory for creating the default , or if none has been /// configured. @@ -674,7 +649,6 @@ public override int GetServiceProviderHashCode() hashCode.Add(Extension._region); hashCode.Add(Extension._connectionMode); hashCode.Add(Extension._limitToEndpoint); - hashCode.Add(Extension._enableContentResponseOnWrite); hashCode.Add(Extension._webProxy); hashCode.Add(Extension._requestTimeout); hashCode.Add(Extension._openTcpConnectionTimeout); @@ -701,7 +675,6 @@ public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo && Extension._region == otherInfo.Extension._region && Extension._connectionMode == otherInfo.Extension._connectionMode && Extension._limitToEndpoint == otherInfo.Extension._limitToEndpoint - && Extension._enableContentResponseOnWrite == otherInfo.Extension._enableContentResponseOnWrite && Extension._webProxy == otherInfo.Extension._webProxy && Extension._requestTimeout == otherInfo.Extension._requestTimeout && Extension._openTcpConnectionTimeout == otherInfo.Extension._openTcpConnectionTimeout diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs index 5fa7ae5a2da..f1c6292c62a 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs @@ -135,14 +135,6 @@ public class CosmosSingletonOptions : ICosmosSingletonOptions /// public virtual int? MaxRequestsPerTcpConnection { get; private set; } - /// - /// 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. - /// - public virtual bool? EnableContentResponseOnWrite { get; } - /// /// 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 diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs b/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs index b2d043279fb..84f64d3ed56 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs @@ -140,14 +140,6 @@ public interface ICosmosSingletonOptions : ISingletonOptions /// int? MaxRequestsPerTcpConnection { get; } - /// - /// 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. - /// - bool? EnableContentResponseOnWrite { get; } - /// /// 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 diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index 34c7da6039d..cbbc6286190 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -46,7 +46,6 @@ public class CosmosClientWrapper : ICosmosClientWrapper private readonly string _databaseId; private readonly IExecutionStrategy _executionStrategy; private readonly IDiagnosticsLogger _commandLogger; - private readonly bool? _enableContentResponseOnWrite; static CosmosClientWrapper() { @@ -74,7 +73,6 @@ public CosmosClientWrapper( _databaseId = options!.DatabaseName; _executionStrategy = executionStrategy; _commandLogger = commandLogger; - _enableContentResponseOnWrite = options.EnableContentResponseOnWrite; } /// @@ -332,7 +330,7 @@ private static async Task CreateItemOnceAsync( var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Create); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Create); @@ -395,7 +393,7 @@ private static async Task ReplaceItemOnceAsync( var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Replace); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Replace); @@ -458,7 +456,7 @@ private static async Task DeleteItemOnceAsync( var sessionTokenStorage = parameters.SessionTokenStorage; var items = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Delete); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Delete); @@ -516,7 +514,7 @@ public virtual ICosmosTransactionalBatchWrapper CreateTransactionalBatch(string var batch = container.CreateTransactionalBatch(partitionKeyValue); - return new CosmosTransactionalBatchWrapper(batch, containerId, partitionKeyValue, checkSize, _enableContentResponseOnWrite); + return new CosmosTransactionalBatchWrapper(batch, containerId, partitionKeyValue, checkSize); } /// @@ -555,9 +553,9 @@ private static async Task ExecuteTransactionalBa return ProcessBatchResponse(batch.CollectionId, response, batch.Entries, sessionTokenStorage); } - private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, bool? enableContentResponseOnWrite, string? sessionToken) + private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, string? sessionToken) { - var helper = RequestOptionsHelper.Create(entry, enableContentResponseOnWrite); + var helper = RequestOptionsHelper.Create(entry); var itemRequestOptions = new ItemRequestOptions { diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs index f2f9fead506..d9ad740b512 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs @@ -22,7 +22,6 @@ public class CosmosTransactionalBatchWrapper : ICosmosTransactionalBatchWrapper private readonly string _collectionId; private readonly PartitionKey _partitionKeyValue; private readonly bool _checkSize; - private readonly bool? _enableContentResponseOnWrite; private readonly List _entries = new(); /// @@ -35,14 +34,12 @@ public CosmosTransactionalBatchWrapper( TransactionalBatch transactionalBatch, string collectionId, PartitionKey partitionKeyValue, - bool checkSize, - bool? enableContentResponseOnWrite) + bool checkSize) { _transactionalBatch = transactionalBatch; _collectionId = collectionId; _partitionKeyValue = partitionKeyValue; _checkSize = checkSize; - _enableContentResponseOnWrite = enableContentResponseOnWrite; } /// @@ -77,7 +74,7 @@ public CosmosTransactionalBatchWrapper( /// public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { @@ -104,7 +101,7 @@ public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) /// public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { @@ -131,7 +128,7 @@ public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEnt /// public bool DeleteItem(string documentId, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { @@ -158,9 +155,9 @@ public bool DeleteItem(string documentId, IUpdateEntry updateEntry) /// public TransactionalBatch GetTransactionalBatch() => _transactionalBatch; - private TransactionalBatchItemRequestOptions? CreateItemRequestOptions(IUpdateEntry entry, bool? enableContentResponseOnWrite, out int size) + private TransactionalBatchItemRequestOptions? CreateItemRequestOptions(IUpdateEntry entry, out int size) { - var helper = RequestOptionsHelper.Create(entry, enableContentResponseOnWrite); + var helper = RequestOptionsHelper.Create(entry); size = 0; if (helper == null) diff --git a/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs b/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs index 7e4e20b46ba..cfd653a5c9f 100644 --- a/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs @@ -11,10 +11,9 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class RequestOptionsHelper { - private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWrite) + private RequestOptionsHelper(string? ifMatchEtag) { IfMatchEtag = ifMatchEtag; - EnableContentResponseOnWrite = enableContentResponseOnWrite; } /// @@ -31,7 +30,7 @@ private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWr /// 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. /// - public virtual bool EnableContentResponseOnWrite { get; } + public virtual bool EnableContentResponseOnWrite { get; } = false; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -39,7 +38,7 @@ private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWr /// 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. /// - public static RequestOptionsHelper? Create(IUpdateEntry entry, bool? enableContentResponseOnWrite) + public static RequestOptionsHelper? Create(IUpdateEntry entry) { var etagProperty = entry.EntityType.GetETagProperty(); if (etagProperty == null) @@ -54,33 +53,6 @@ private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWr etag = converter.ConvertToProvider(etag); } - bool enabledContentResponse; - if (enableContentResponseOnWrite.HasValue) - { - enabledContentResponse = enableContentResponseOnWrite.Value; - } - else - { - switch (entry.EntityState) - { - case EntityState.Modified: - { - var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - enabledContentResponse = (jObjectProperty?.ValueGenerated & ValueGenerated.OnUpdate) == ValueGenerated.OnUpdate; - break; - } - case EntityState.Added: - { - var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - enabledContentResponse = (jObjectProperty?.ValueGenerated & ValueGenerated.OnAdd) == ValueGenerated.OnAdd; - break; - } - default: - enabledContentResponse = false; - break; - } - } - - return new RequestOptionsHelper((string?)etag, enabledContentResponse); + return new RequestOptionsHelper((string?)etag); } } diff --git a/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs b/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs index b7c7b66580a..886932226b9 100644 --- a/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs +++ b/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs @@ -64,7 +64,6 @@ public void Can_create_options_with_valid_values() Test(o => o.MaxRequestsPerTcpConnection(3), o => Assert.Equal(3, o.MaxRequestsPerTcpConnection)); Test(o => o.MaxTcpConnectionsPerEndpoint(3), o => Assert.Equal(3, o.MaxTcpConnectionsPerEndpoint)); Test(o => o.LimitToEndpoint(), o => Assert.True(o.LimitToEndpoint)); - Test(o => o.ContentResponseOnWriteEnabled(), o => Assert.True(o.EnableContentResponseOnWrite)); Test(o => o.SessionTokenManagementMode(Cosmos.Infrastructure.SessionTokenManagementMode.EnforcedManual), o => Assert.Equal(Cosmos.Infrastructure.SessionTokenManagementMode.EnforcedManual, o.SessionTokenManagementMode)); Test(o => o.BulkExecutionEnabled(), o => Assert.True(o.EnableBulkExecution)); From 139fa7922a06e4df119e213b8ceaafb136b01ae4 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:36:51 +0200 Subject: [PATCH 09/29] Revert "remove ContentResponseOnWriteEnabled" This reverts commit ab0a8f56e7e3175c404ba24aa0de97c47f76486f. --- .../Internal/CosmosDbOptionExtension.cs | 27 ++++++++++++++ .../Internal/CosmosSingletonOptions.cs | 8 +++++ .../Internal/ICosmosSingletonOptions.cs | 8 +++++ .../Storage/Internal/CosmosClientWrapper.cs | 14 ++++---- .../CosmosTransactionalBatchWrapper.cs | 15 ++++---- .../Storage/Internal/RequestOptionsHelper.cs | 36 ++++++++++++++++--- .../CosmosDbContextOptionsExtensionsTests.cs | 1 + 7 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs index 7b994bb9e1a..e1656712408 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs @@ -33,6 +33,7 @@ public class CosmosOptionsExtension : IDbContextOptionsExtension private int? _gatewayModeMaxConnectionLimit; private int? _maxTcpConnectionsPerEndpoint; private int? _maxRequestsPerTcpConnection; + private bool? _enableContentResponseOnWrite; private DbContextOptionsExtensionInfo? _info; private Func? _httpClientFactory; private SessionTokenManagementMode _sessionTokenManagementMode = SessionTokenManagementMode.FullyAutomatic; @@ -496,6 +497,30 @@ public virtual CosmosOptionsExtension WithMaxRequestsPerTcpConnection(int? reque return clone; } + /// + /// 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. + /// + public virtual bool? EnableContentResponseOnWrite + => _enableContentResponseOnWrite; + + /// + /// 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. + /// + public virtual CosmosOptionsExtension ContentResponseOnWriteEnabled(bool enabled) + { + var clone = Clone(); + + clone._enableContentResponseOnWrite = enabled; + + return clone; + } + /// /// A factory for creating the default , or if none has been /// configured. @@ -649,6 +674,7 @@ public override int GetServiceProviderHashCode() hashCode.Add(Extension._region); hashCode.Add(Extension._connectionMode); hashCode.Add(Extension._limitToEndpoint); + hashCode.Add(Extension._enableContentResponseOnWrite); hashCode.Add(Extension._webProxy); hashCode.Add(Extension._requestTimeout); hashCode.Add(Extension._openTcpConnectionTimeout); @@ -675,6 +701,7 @@ public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo && Extension._region == otherInfo.Extension._region && Extension._connectionMode == otherInfo.Extension._connectionMode && Extension._limitToEndpoint == otherInfo.Extension._limitToEndpoint + && Extension._enableContentResponseOnWrite == otherInfo.Extension._enableContentResponseOnWrite && Extension._webProxy == otherInfo.Extension._webProxy && Extension._requestTimeout == otherInfo.Extension._requestTimeout && Extension._openTcpConnectionTimeout == otherInfo.Extension._openTcpConnectionTimeout diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs index f1c6292c62a..5fa7ae5a2da 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs @@ -135,6 +135,14 @@ public class CosmosSingletonOptions : ICosmosSingletonOptions /// public virtual int? MaxRequestsPerTcpConnection { get; private set; } + /// + /// 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. + /// + public virtual bool? EnableContentResponseOnWrite { get; } + /// /// 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 diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs b/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs index 84f64d3ed56..b2d043279fb 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/ICosmosSingletonOptions.cs @@ -140,6 +140,14 @@ public interface ICosmosSingletonOptions : ISingletonOptions /// int? MaxRequestsPerTcpConnection { get; } + /// + /// 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. + /// + bool? EnableContentResponseOnWrite { get; } + /// /// 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 diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index cbbc6286190..34c7da6039d 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -46,6 +46,7 @@ public class CosmosClientWrapper : ICosmosClientWrapper private readonly string _databaseId; private readonly IExecutionStrategy _executionStrategy; private readonly IDiagnosticsLogger _commandLogger; + private readonly bool? _enableContentResponseOnWrite; static CosmosClientWrapper() { @@ -73,6 +74,7 @@ public CosmosClientWrapper( _databaseId = options!.DatabaseName; _executionStrategy = executionStrategy; _commandLogger = commandLogger; + _enableContentResponseOnWrite = options.EnableContentResponseOnWrite; } /// @@ -330,7 +332,7 @@ private static async Task CreateItemOnceAsync( var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Create); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Create); @@ -393,7 +395,7 @@ private static async Task ReplaceItemOnceAsync( var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Replace); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Replace); @@ -456,7 +458,7 @@ private static async Task DeleteItemOnceAsync( var sessionTokenStorage = parameters.SessionTokenStorage; var items = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Delete); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Delete); @@ -514,7 +516,7 @@ public virtual ICosmosTransactionalBatchWrapper CreateTransactionalBatch(string var batch = container.CreateTransactionalBatch(partitionKeyValue); - return new CosmosTransactionalBatchWrapper(batch, containerId, partitionKeyValue, checkSize); + return new CosmosTransactionalBatchWrapper(batch, containerId, partitionKeyValue, checkSize, _enableContentResponseOnWrite); } /// @@ -553,9 +555,9 @@ private static async Task ExecuteTransactionalBa return ProcessBatchResponse(batch.CollectionId, response, batch.Entries, sessionTokenStorage); } - private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, string? sessionToken) + private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, bool? enableContentResponseOnWrite, string? sessionToken) { - var helper = RequestOptionsHelper.Create(entry); + var helper = RequestOptionsHelper.Create(entry, enableContentResponseOnWrite); var itemRequestOptions = new ItemRequestOptions { diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs index d9ad740b512..f2f9fead506 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs @@ -22,6 +22,7 @@ public class CosmosTransactionalBatchWrapper : ICosmosTransactionalBatchWrapper private readonly string _collectionId; private readonly PartitionKey _partitionKeyValue; private readonly bool _checkSize; + private readonly bool? _enableContentResponseOnWrite; private readonly List _entries = new(); /// @@ -34,12 +35,14 @@ public CosmosTransactionalBatchWrapper( TransactionalBatch transactionalBatch, string collectionId, PartitionKey partitionKeyValue, - bool checkSize) + bool checkSize, + bool? enableContentResponseOnWrite) { _transactionalBatch = transactionalBatch; _collectionId = collectionId; _partitionKeyValue = partitionKeyValue; _checkSize = checkSize; + _enableContentResponseOnWrite = enableContentResponseOnWrite; } /// @@ -74,7 +77,7 @@ public CosmosTransactionalBatchWrapper( /// public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); if (_checkSize) { @@ -101,7 +104,7 @@ public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) /// public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); if (_checkSize) { @@ -128,7 +131,7 @@ public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEnt /// public bool DeleteItem(string documentId, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); if (_checkSize) { @@ -155,9 +158,9 @@ public bool DeleteItem(string documentId, IUpdateEntry updateEntry) /// public TransactionalBatch GetTransactionalBatch() => _transactionalBatch; - private TransactionalBatchItemRequestOptions? CreateItemRequestOptions(IUpdateEntry entry, out int size) + private TransactionalBatchItemRequestOptions? CreateItemRequestOptions(IUpdateEntry entry, bool? enableContentResponseOnWrite, out int size) { - var helper = RequestOptionsHelper.Create(entry); + var helper = RequestOptionsHelper.Create(entry, enableContentResponseOnWrite); size = 0; if (helper == null) diff --git a/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs b/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs index cfd653a5c9f..7e4e20b46ba 100644 --- a/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs @@ -11,9 +11,10 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class RequestOptionsHelper { - private RequestOptionsHelper(string? ifMatchEtag) + private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWrite) { IfMatchEtag = ifMatchEtag; + EnableContentResponseOnWrite = enableContentResponseOnWrite; } /// @@ -30,7 +31,7 @@ private RequestOptionsHelper(string? ifMatchEtag) /// 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. /// - public virtual bool EnableContentResponseOnWrite { get; } = false; + public virtual bool EnableContentResponseOnWrite { get; } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -38,7 +39,7 @@ private RequestOptionsHelper(string? ifMatchEtag) /// 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. /// - public static RequestOptionsHelper? Create(IUpdateEntry entry) + public static RequestOptionsHelper? Create(IUpdateEntry entry, bool? enableContentResponseOnWrite) { var etagProperty = entry.EntityType.GetETagProperty(); if (etagProperty == null) @@ -53,6 +54,33 @@ private RequestOptionsHelper(string? ifMatchEtag) etag = converter.ConvertToProvider(etag); } - return new RequestOptionsHelper((string?)etag); + bool enabledContentResponse; + if (enableContentResponseOnWrite.HasValue) + { + enabledContentResponse = enableContentResponseOnWrite.Value; + } + else + { + switch (entry.EntityState) + { + case EntityState.Modified: + { + var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); + enabledContentResponse = (jObjectProperty?.ValueGenerated & ValueGenerated.OnUpdate) == ValueGenerated.OnUpdate; + break; + } + case EntityState.Added: + { + var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); + enabledContentResponse = (jObjectProperty?.ValueGenerated & ValueGenerated.OnAdd) == ValueGenerated.OnAdd; + break; + } + default: + enabledContentResponse = false; + break; + } + } + + return new RequestOptionsHelper((string?)etag, enabledContentResponse); } } diff --git a/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs b/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs index 886932226b9..b7c7b66580a 100644 --- a/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs +++ b/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs @@ -64,6 +64,7 @@ public void Can_create_options_with_valid_values() Test(o => o.MaxRequestsPerTcpConnection(3), o => Assert.Equal(3, o.MaxRequestsPerTcpConnection)); Test(o => o.MaxTcpConnectionsPerEndpoint(3), o => Assert.Equal(3, o.MaxTcpConnectionsPerEndpoint)); Test(o => o.LimitToEndpoint(), o => Assert.True(o.LimitToEndpoint)); + Test(o => o.ContentResponseOnWriteEnabled(), o => Assert.True(o.EnableContentResponseOnWrite)); Test(o => o.SessionTokenManagementMode(Cosmos.Infrastructure.SessionTokenManagementMode.EnforcedManual), o => Assert.Equal(Cosmos.Infrastructure.SessionTokenManagementMode.EnforcedManual, o.SessionTokenManagementMode)); Test(o => o.BulkExecutionEnabled(), o => Assert.True(o.EnableBulkExecution)); From 8bb031ed731e67979d156b6b85225ecb31adcc87 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:55:46 +0200 Subject: [PATCH 10/29] Obsolete EnableContentResponseOnWrite --- .../CosmosDbContextOptionsBuilder.cs | 5 ++- .../Internal/CosmosDbOptionExtension.cs | 1 + .../Internal/CosmosSingletonOptions.cs | 4 +- .../Storage/Internal/CosmosClientWrapper.cs | 15 +++---- .../CosmosTransactionalBatchWrapper.cs | 18 ++++---- .../Storage/Internal/RequestOptionsHelper.cs | 41 ++----------------- .../Internal/SingletonCosmosClientWrapper.cs | 2 + .../CosmosConcurrencyTest.cs | 4 ++ .../CosmosDbContextOptionsExtensionsTests.cs | 2 + 9 files changed, 31 insertions(+), 61 deletions(-) diff --git a/src/EFCore.Cosmos/Infrastructure/CosmosDbContextOptionsBuilder.cs b/src/EFCore.Cosmos/Infrastructure/CosmosDbContextOptionsBuilder.cs index 452dda4e45e..cc1a2abcdf0 100644 --- a/src/EFCore.Cosmos/Infrastructure/CosmosDbContextOptionsBuilder.cs +++ b/src/EFCore.Cosmos/Infrastructure/CosmosDbContextOptionsBuilder.cs @@ -205,13 +205,14 @@ public virtual CosmosDbContextOptionsBuilder MaxRequestsPerTcpConnection(int req /// This reduces networking and CPU load by not sending the resource back over the network and serializing it on the client. /// /// + /// The EntityFrameworkCore default is since 11.0. /// See Using DbContextOptions, and /// Accessing Azure Cosmos DB with EF Core for more information and examples. /// /// to have null resource + [Obsolete("Enabling ContentResponseOnWrite currently has no benefit for EF Core.")] public virtual CosmosDbContextOptionsBuilder ContentResponseOnWriteEnabled(bool enabled = true) - => WithOption(e => e.ContentResponseOnWriteEnabled(Check.NotNull(enabled))); - + => WithOption(e => e.ContentResponseOnWriteEnabled(enabled)); /// /// Sets the to use. diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs index e1656712408..2f0474bea3f 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosDbOptionExtension.cs @@ -74,6 +74,7 @@ protected CosmosOptionsExtension(CosmosOptionsExtension copyFrom) _gatewayModeMaxConnectionLimit = copyFrom._gatewayModeMaxConnectionLimit; _maxTcpConnectionsPerEndpoint = copyFrom._maxTcpConnectionsPerEndpoint; _maxRequestsPerTcpConnection = copyFrom._maxRequestsPerTcpConnection; + _enableContentResponseOnWrite = copyFrom._enableContentResponseOnWrite; _httpClientFactory = copyFrom._httpClientFactory; _sessionTokenManagementMode = copyFrom._sessionTokenManagementMode; _enableBulkExecution = copyFrom._enableBulkExecution; diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs index 5fa7ae5a2da..a48b7c8ac55 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosSingletonOptions.cs @@ -141,7 +141,7 @@ public class CosmosSingletonOptions : ICosmosSingletonOptions /// 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. /// - public virtual bool? EnableContentResponseOnWrite { get; } + public virtual bool? EnableContentResponseOnWrite { get; private set; } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -185,6 +185,7 @@ public virtual void Initialize(IDbContextOptions options) GatewayModeMaxConnectionLimit = cosmosOptions.GatewayModeMaxConnectionLimit; MaxTcpConnectionsPerEndpoint = cosmosOptions.MaxTcpConnectionsPerEndpoint; MaxRequestsPerTcpConnection = cosmosOptions.MaxRequestsPerTcpConnection; + EnableContentResponseOnWrite = cosmosOptions.EnableContentResponseOnWrite; HttpClientFactory = cosmosOptions.HttpClientFactory; EnableBulkExecution = cosmosOptions.EnableBulkExecution; } @@ -216,6 +217,7 @@ public virtual void Validate(IDbContextOptions options) || GatewayModeMaxConnectionLimit != cosmosOptions.GatewayModeMaxConnectionLimit || MaxTcpConnectionsPerEndpoint != cosmosOptions.MaxTcpConnectionsPerEndpoint || MaxRequestsPerTcpConnection != cosmosOptions.MaxRequestsPerTcpConnection + || EnableContentResponseOnWrite != cosmosOptions.EnableContentResponseOnWrite || HttpClientFactory != cosmosOptions.HttpClientFactory || EnableBulkExecution != cosmosOptions.EnableBulkExecution )) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index 34c7da6039d..8d099a8606a 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -46,7 +46,6 @@ public class CosmosClientWrapper : ICosmosClientWrapper private readonly string _databaseId; private readonly IExecutionStrategy _executionStrategy; private readonly IDiagnosticsLogger _commandLogger; - private readonly bool? _enableContentResponseOnWrite; static CosmosClientWrapper() { @@ -74,7 +73,6 @@ public CosmosClientWrapper( _databaseId = options!.DatabaseName; _executionStrategy = executionStrategy; _commandLogger = commandLogger; - _enableContentResponseOnWrite = options.EnableContentResponseOnWrite; } /// @@ -332,7 +330,7 @@ private static async Task CreateItemOnceAsync( var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Create); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Create); @@ -395,7 +393,7 @@ private static async Task ReplaceItemOnceAsync( var wrapper = parameters.Wrapper; var sessionTokenStorage = parameters.SessionTokenStorage; var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Replace); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Replace); @@ -458,7 +456,7 @@ private static async Task DeleteItemOnceAsync( var sessionTokenStorage = parameters.SessionTokenStorage; var items = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId); - var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite, sessionTokenStorage.GetSessionToken(containerId)); + var itemRequestOptions = CreateItemRequestOptions(entry, sessionTokenStorage.GetSessionToken(containerId)); var partitionKeyValue = ExtractPartitionKeyValue(entry); var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Delete); var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Delete); @@ -516,7 +514,7 @@ public virtual ICosmosTransactionalBatchWrapper CreateTransactionalBatch(string var batch = container.CreateTransactionalBatch(partitionKeyValue); - return new CosmosTransactionalBatchWrapper(batch, containerId, partitionKeyValue, checkSize, _enableContentResponseOnWrite); + return new CosmosTransactionalBatchWrapper(batch, containerId, partitionKeyValue, checkSize); } /// @@ -555,9 +553,9 @@ private static async Task ExecuteTransactionalBa return ProcessBatchResponse(batch.CollectionId, response, batch.Entries, sessionTokenStorage); } - private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, bool? enableContentResponseOnWrite, string? sessionToken) + private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, string? sessionToken) { - var helper = RequestOptionsHelper.Create(entry, enableContentResponseOnWrite); + var helper = RequestOptionsHelper.Create(entry); var itemRequestOptions = new ItemRequestOptions { @@ -567,7 +565,6 @@ private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, b if (helper != null) { itemRequestOptions.IfMatchEtag = helper.IfMatchEtag; - itemRequestOptions.EnableContentResponseOnWrite = helper.EnableContentResponseOnWrite; } return itemRequestOptions; diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs index f2f9fead506..e56fc7ece9c 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs @@ -22,7 +22,6 @@ public class CosmosTransactionalBatchWrapper : ICosmosTransactionalBatchWrapper private readonly string _collectionId; private readonly PartitionKey _partitionKeyValue; private readonly bool _checkSize; - private readonly bool? _enableContentResponseOnWrite; private readonly List _entries = new(); /// @@ -35,14 +34,12 @@ public CosmosTransactionalBatchWrapper( TransactionalBatch transactionalBatch, string collectionId, PartitionKey partitionKeyValue, - bool checkSize, - bool? enableContentResponseOnWrite) + bool checkSize) { _transactionalBatch = transactionalBatch; _collectionId = collectionId; _partitionKeyValue = partitionKeyValue; _checkSize = checkSize; - _enableContentResponseOnWrite = enableContentResponseOnWrite; } /// @@ -77,7 +74,7 @@ public CosmosTransactionalBatchWrapper( /// public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { @@ -104,7 +101,7 @@ public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) /// public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { @@ -131,7 +128,7 @@ public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEnt /// public bool DeleteItem(string documentId, IUpdateEntry updateEntry) { - var itemRequestOptions = CreateItemRequestOptions(updateEntry, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { @@ -158,9 +155,9 @@ public bool DeleteItem(string documentId, IUpdateEntry updateEntry) /// public TransactionalBatch GetTransactionalBatch() => _transactionalBatch; - private TransactionalBatchItemRequestOptions? CreateItemRequestOptions(IUpdateEntry entry, bool? enableContentResponseOnWrite, out int size) + private TransactionalBatchItemRequestOptions? CreateItemRequestOptions(IUpdateEntry entry, out int size) { - var helper = RequestOptionsHelper.Create(entry, enableContentResponseOnWrite); + var helper = RequestOptionsHelper.Create(entry); size = 0; if (helper == null) @@ -173,7 +170,6 @@ public bool DeleteItem(string documentId, IUpdateEntry updateEntry) size += helper.IfMatchEtag.Length; } - // EnableContentResponseOnWrite is a header so no request body size for that. - return new TransactionalBatchItemRequestOptions { IfMatchEtag = helper.IfMatchEtag, EnableContentResponseOnWrite = helper.EnableContentResponseOnWrite }; + return new TransactionalBatchItemRequestOptions { IfMatchEtag = helper.IfMatchEtag }; } } diff --git a/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs b/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs index 7e4e20b46ba..a204e2c33ab 100644 --- a/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/RequestOptionsHelper.cs @@ -11,10 +11,9 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class RequestOptionsHelper { - private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWrite) + private RequestOptionsHelper(string? ifMatchEtag) { IfMatchEtag = ifMatchEtag; - EnableContentResponseOnWrite = enableContentResponseOnWrite; } /// @@ -25,13 +24,6 @@ private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWr /// public virtual string? IfMatchEtag { get; } - /// - /// 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. - /// - public virtual bool EnableContentResponseOnWrite { get; } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -39,7 +31,7 @@ private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWr /// 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. /// - public static RequestOptionsHelper? Create(IUpdateEntry entry, bool? enableContentResponseOnWrite) + public static RequestOptionsHelper? Create(IUpdateEntry entry) { var etagProperty = entry.EntityType.GetETagProperty(); if (etagProperty == null) @@ -54,33 +46,6 @@ private RequestOptionsHelper(string? ifMatchEtag, bool enableContentResponseOnWr etag = converter.ConvertToProvider(etag); } - bool enabledContentResponse; - if (enableContentResponseOnWrite.HasValue) - { - enabledContentResponse = enableContentResponseOnWrite.Value; - } - else - { - switch (entry.EntityState) - { - case EntityState.Modified: - { - var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - enabledContentResponse = (jObjectProperty?.ValueGenerated & ValueGenerated.OnUpdate) == ValueGenerated.OnUpdate; - break; - } - case EntityState.Added: - { - var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - enabledContentResponse = (jObjectProperty?.ValueGenerated & ValueGenerated.OnAdd) == ValueGenerated.OnAdd; - break; - } - default: - enabledContentResponse = false; - break; - } - } - - return new RequestOptionsHelper((string?)etag, enabledContentResponse); + return new RequestOptionsHelper((string?)etag); } } diff --git a/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs index f52ed9ada70..ac5c298ec9c 100644 --- a/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs @@ -93,6 +93,8 @@ public SingletonCosmosClientWrapper(ICosmosSingletonOptions options) configuration.AllowBulkExecution = options.EnableBulkExecution.Value; } + configuration.EnableContentResponseOnWrite = options.EnableContentResponseOnWrite == true; + _client = options switch { { ConnectionString: not null and not "" } => new CosmosClient(options.ConnectionString, configuration), diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs index 3a576324a62..77e859a1485 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs @@ -59,7 +59,9 @@ public async Task Etag_is_updated_in_entity_after_SaveChanges(bool? contentRespo { if (contentResponseOnWriteEnabled != null) { +#pragma warning disable CS0618 // Type or member is obsolete o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); +#pragma warning restore CS0618 // Type or member is obsolete } }) .Options; @@ -119,7 +121,9 @@ public async Task Etag_is_updated_in_derived_entity_after_SaveChanges(bool? cont { if (contentResponseOnWriteEnabled != null) { +#pragma warning disable CS0618 // Type or member is obsolete o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); +#pragma warning restore CS0618 // Type or member is obsolete } }) .Options; diff --git a/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs b/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs index b7c7b66580a..79e7571f4e1 100644 --- a/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs +++ b/test/EFCore.Cosmos.Tests/Extensions/CosmosDbContextOptionsExtensionsTests.cs @@ -64,7 +64,9 @@ public void Can_create_options_with_valid_values() Test(o => o.MaxRequestsPerTcpConnection(3), o => Assert.Equal(3, o.MaxRequestsPerTcpConnection)); Test(o => o.MaxTcpConnectionsPerEndpoint(3), o => Assert.Equal(3, o.MaxTcpConnectionsPerEndpoint)); Test(o => o.LimitToEndpoint(), o => Assert.True(o.LimitToEndpoint)); +#pragma warning disable CS0618 // Type or member is obsolete Test(o => o.ContentResponseOnWriteEnabled(), o => Assert.True(o.EnableContentResponseOnWrite)); +#pragma warning restore CS0618 // Type or member is obsolete Test(o => o.SessionTokenManagementMode(Cosmos.Infrastructure.SessionTokenManagementMode.EnforcedManual), o => Assert.Equal(Cosmos.Infrastructure.SessionTokenManagementMode.EnforcedManual, o.SessionTokenManagementMode)); Test(o => o.BulkExecutionEnabled(), o => Assert.True(o.EnableBulkExecution)); From a1ad382d789eec0380e1ccd9b81d79224d2fead4 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:18:06 +0200 Subject: [PATCH 11/29] Simplify batch tests --- .../CosmosConcurrencyTest.cs | 10 +- .../CosmosTransactionalBatchTest.cs | 244 +++--------------- 2 files changed, 40 insertions(+), 214 deletions(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs index 77e859a1485..197acde4f9a 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs @@ -54,7 +54,7 @@ public virtual Task Updating_then_updating_the_same_entity_results_in_DbUpdateCo [ConditionalTheory, InlineData(null), InlineData(true), InlineData(false)] public async Task Etag_is_updated_in_entity_after_SaveChanges(bool? contentResponseOnWriteEnabled) { - var options = new DbContextOptionsBuilder(Fixture.CreateOptions()) + var options = Fixture.TestStore.AddProviderOptions(Fixture.AddOptions(new DbContextOptionsBuilder() .UseCosmos(o => { if (contentResponseOnWriteEnabled != null) @@ -63,8 +63,7 @@ public async Task Etag_is_updated_in_entity_after_SaveChanges(bool? contentRespo o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); #pragma warning restore CS0618 // Type or member is obsolete } - }) - .Options; + }))).Options; var customer = new Customer { @@ -116,7 +115,7 @@ public async Task Etag_is_updated_in_entity_after_SaveChanges(bool? contentRespo [ConditionalTheory, InlineData(null), InlineData(true), InlineData(false)] public async Task Etag_is_updated_in_derived_entity_after_SaveChanges(bool? contentResponseOnWriteEnabled) { - var options = new DbContextOptionsBuilder(Fixture.CreateOptions()) + var options = Fixture.TestStore.AddProviderOptions(Fixture.AddOptions(new DbContextOptionsBuilder() .UseCosmos(o => { if (contentResponseOnWriteEnabled != null) @@ -125,8 +124,7 @@ public async Task Etag_is_updated_in_derived_entity_after_SaveChanges(bool? cont o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); #pragma warning restore CS0618 // Type or member is obsolete } - }) - .Options; + }))).Options; var customer = new PremiumCustomer { diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs index 26cfa81bdb3..4d2b0209cfb 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using System.Runtime.CompilerServices; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Scripts; using Microsoft.EntityFrameworkCore.Cosmos.Internal; @@ -279,50 +280,6 @@ public virtual async Task SaveChanges_entity_too_large_throws() Assert.Equal(0, customersCount); } - [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_exactly_2_mib_does_not_split_and_one_byte_over_splits(bool oneByteOver) - { - //for (var i = 350; i > 0; i--) - //{ - - using var context = Fixture.CreateContext(); - - var customer1 = new Customer { Id = new string('x', 1023), PartitionKey = new string('x', 1023) }; - var customer2 = new Customer { Id = new string('y', 1023), PartitionKey = new string('x', 1023) }; - - context.Customers.Add(customer1); - context.Customers.Add(customer2); - - await context.SaveChangesAsync(); - Fixture.ListLoggerFactory.Clear(); - - customer1.Name = new string('x', 1045148); - customer2.Name = new string('x', 1045148); - - if (oneByteOver) - { - customer1.Name += 'x'; - } - - await context.SaveChangesAsync(); - using var assertContext = Fixture.CreateContext(); - Assert.Equal(2, (await assertContext.Customers.ToListAsync()).Count); - - if (oneByteOver) - { - Assert.Equal(2, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); - } - else - { - //Debug.Assert(2 == Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch), $"Failed on iteration {i}"); - //context.Remove(customer1); - //context.Remove(customer2); - //await context.SaveChangesAsync(); - //Fixture.ListLoggerFactory.Clear(); - Assert.Equal(1, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); - } - //} - } [ConditionalFact] public virtual async Task SaveChanges_too_large_entry_after_smaller_throws_after_saving_smaller() @@ -340,22 +297,6 @@ public virtual async Task SaveChanges_too_large_entry_after_smaller_throws_after Assert.Equal("1", (await assertContext.Customers.FirstAsync()).Id); } - [ConditionalFact] - public virtual async Task SaveChanges_transaction_behavior_always_payload_exactly_2_mib() - { - using var context = Fixture.CreateContext(); - context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; - - context.Customers.Add(new Customer { Id = "1", Name = new string('x', 1048291), PartitionKey = "1" }); - context.Customers.Add(new Customer { Id = "2", Name = new string('x', 1048291), PartitionKey = "1" }); - - await context.SaveChangesAsync(); - - using var assertContext = Fixture.CreateContext(); - var customersCount = await assertContext.Customers.CountAsync(); - Assert.Equal(2, customersCount); - } - [ConditionalFact] public virtual async Task SaveChanges_transaction_behavior_always_payload_larger_than_cosmos_limit_throws() { @@ -375,18 +316,19 @@ public virtual async Task SaveChanges_transaction_behavior_always_payload_larger Assert.Equal(0, customersCount); } - private const int nameLengthToExceed2MiBWithSpecialCharIdOnUpdate = 1046358; + /// + /// How many bytes of data can be in a customer's properties to reach the max request size in a EF transactional batch request + /// + private const int MaxSerializedCustomerTransactionalBatchRequestSize = 2094389; + private const int MaxKeySize = 1023; [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_update_id_contains_special_chars_which_makes_request_larger_than_2_mib_splits_into_2_batches(bool isIdSpecialChar) + public virtual async Task SaveChanges_exactly_2_mib_does_not_split_and_one_byte_over_splits(bool oneByteOver) { using var context = Fixture.CreateContext(); - var id1 = isIdSpecialChar ? new string('€', 341) : new string('x', 341); - var id2 = isIdSpecialChar ? new string('Ω', 341) : new string('y', 341); - - var customer1 = new Customer { Id = id1, PartitionKey = new string('€', 341) }; - var customer2 = new Customer { Id = id2, PartitionKey = new string('€', 341) }; + var customer1 = new Customer { Id = new string('x', MaxKeySize), PartitionKey = new string('x', MaxKeySize) }; + var customer2 = new Customer { Id = new string('y', MaxKeySize), PartitionKey = new string('x', MaxKeySize) }; context.Customers.Add(customer1); context.Customers.Add(customer2); @@ -394,15 +336,19 @@ public virtual async Task SaveChanges_update_id_contains_special_chars_which_mak await context.SaveChangesAsync(); Fixture.ListLoggerFactory.Clear(); - customer1.Name = new string('x', nameLengthToExceed2MiBWithSpecialCharIdOnUpdate); - customer2.Name = new string('x', nameLengthToExceed2MiBWithSpecialCharIdOnUpdate); + customer1.Name = new string('x', MaxSerializedCustomerTransactionalBatchRequestSize / 2 - 2 * MaxKeySize); + customer2.Name = new string('x', MaxSerializedCustomerTransactionalBatchRequestSize / 2 - 2 * MaxKeySize); + + if (oneByteOver) + { + customer1.Name += 'x'; + } await context.SaveChangesAsync(); using var assertContext = Fixture.CreateContext(); - Assert.Equal(2, (await context.Customers.ToListAsync()).Count); + Assert.Equal(2, (await assertContext.Customers.ToListAsync()).Count); - // The id being a special character should make the difference whether this fits in 1 batch. - if (isIdSpecialChar) + if (oneByteOver) { Assert.Equal(2, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); } @@ -412,162 +358,44 @@ public virtual async Task SaveChanges_update_id_contains_special_chars_which_mak } } + private const int MaxSpecialCharsInId = MaxKeySize / 3; + [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_create_id_contains_special_chars_which_would_make_request_larger_than_2_mib_on_update_does_not_split_into_2_batches_for_create(bool isIdSpecialChar) + public virtual async Task SaveChanges_update_id_contains_special_chars_which_makes_request_larger_than_2_mib_splits_into_2_batches(bool isIdSpecialChar) { - Fixture.ListLoggerFactory.Clear(); using var context = Fixture.CreateContext(); + Fixture.ListLoggerFactory.Clear(); - var id1 = isIdSpecialChar ? new string('€', 341) : new string('x', 341); - var id2 = isIdSpecialChar ? new string('Ω', 341) : new string('y', 341); + var id1 = isIdSpecialChar ? new string('€', MaxSpecialCharsInId) : new string('x', MaxSpecialCharsInId); + var id2 = isIdSpecialChar ? new string('Ω', MaxSpecialCharsInId) : new string('y', MaxSpecialCharsInId); - var customer1 = new Customer { Id = id1, Name = new string('x', nameLengthToExceed2MiBWithSpecialCharIdOnUpdate), PartitionKey = new string('€', 341) }; - var customer2 = new Customer { Id = id2, Name = new string('x', nameLengthToExceed2MiBWithSpecialCharIdOnUpdate), PartitionKey = new string('€', 341) }; + var customer1 = new Customer { Id = id1, Name = new string('x', MaxSerializedCustomerTransactionalBatchRequestSize / 2 - MaxKeySize - 1), PartitionKey = new string('€', MaxSpecialCharsInId) }; + var customer2 = new Customer { Id = id2, Name = new string('x', MaxSerializedCustomerTransactionalBatchRequestSize / 2 - MaxKeySize - 1), PartitionKey = new string('€', MaxSpecialCharsInId) }; context.Customers.Add(customer1); context.Customers.Add(customer2); await context.SaveChangesAsync(); - using var assertContext = Fixture.CreateContext(); - Assert.Equal(2, (await context.Customers.ToListAsync()).Count); - - // The id being a special character should not make the difference whether this fits in 1 batch, as id is duplicated in the payload on create. + // The create doesn't duplicate the id in the payload, so it should fit in one batch even with special chars Assert.Equal(1, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); - } - - [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_transaction_behavior_always_update_entities_payload_can_be_exactly_cosmos_limit_and_throws_when_1byte_over(bool oneByteOver) - { - using var context = Fixture.CreateContext(); - context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; - - var customer1 = new Customer { Id = new string('x', 1_023), PartitionKey = new string('x', 1_023) }; - var customer2 = new Customer { Id = new string('y', 1_023), PartitionKey = new string('x', 1_023) }; - - context.Customers.Add(customer1); - context.Customers.Add(customer2); - await context.SaveChangesAsync(); - - customer1.Name = new string('x', 1097736); - customer2.Name = new string('x', 1097737); - - if (oneByteOver) - { - customer1.Name += 'x'; - customer2.Name += 'x'; - //for (var i = 1; i <= 1000; i++) - //{ - // customer1.Name += 'x'; - // customer2.Name += 'x'; - // try - // { - // await context.SaveChangesAsync(); - // } - // catch (Exception ex) - // { - // throw new Exception($"Off by 2 * {i} bytes", ex); - // } - //} - await Assert.ThrowsAsync(() => context.SaveChangesAsync()); - } - else - { - await context.SaveChangesAsync(); - } - } - - [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_id_counts_double_toward_request_size_on_update(bool oneByteOver) - { - using var context = Fixture.CreateContext(); - context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; + Fixture.ListLoggerFactory.Clear(); - var customer1 = new Customer { Id = new string('x', 1), PartitionKey = new string('x', 1_023) }; - var customer2 = new Customer { Id = new string('y', 1_023), PartitionKey = new string('x', 1_023) }; - - context.Customers.Add(customer1); - context.Customers.Add(customer2); + context.Update(customer1); + context.Update(customer2); await context.SaveChangesAsync(); + using var assertContext = Fixture.CreateContext(); + Assert.Equal(2, (await context.Customers.ToListAsync()).Count); - customer1.Name = new string('x', 1097735 + (1_024 - customer1.Id.Length) * 2); - customer2.Name = new string('x', 1097735 + (1_024 - customer2.Id.Length) * 2); - - if (oneByteOver) - { - customer1.Name += 'x'; - customer2.Name += 'x'; - //for (var i = 1; i <= 1000; i++) - //{ - // customer1.Name += 'x'; - // customer2.Name += 'x'; - // try - // { - // await context.SaveChangesAsync(); - // } - // catch (Exception ex) - // { - // throw new Exception($"Off by 2 * {i} bytes", ex); - // } - //} - await Assert.ThrowsAsync(() => context.SaveChangesAsync()); - } - else - { - await context.SaveChangesAsync(); - } - } - - [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_transaction_behavior_always_create_entities_payload_can_be_exactly_cosmos_limit_and_throws_when_1byte_over(bool oneByteOver) - { - using var context = Fixture.CreateContext(); - context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; - - var customer1 = new Customer { Id = new string('x', 1_023), Name = new string('x', 1098841), PartitionKey = new string('x', 1_023) }; - var customer2 = new Customer { Id = new string('y', 1_023), Name = new string('x', 1098841), PartitionKey = new string('x', 1_023) }; - if (oneByteOver) - { - customer1.Name += 'x'; - customer2.Name += 'x'; - } - - context.Customers.Add(customer1); - context.Customers.Add(customer2); - if (oneByteOver) - { - await Assert.ThrowsAsync(() => context.SaveChangesAsync()); - } - else - { - await context.SaveChangesAsync(); - } - } - - [ConditionalTheory, InlineData(true), InlineData(false)] - public virtual async Task SaveChanges_id_does_not_count_double_toward_request_size_on_create(bool oneByteOver) - { - using var context = Fixture.CreateContext(); - context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; - - var customer1 = new Customer { Id = new string('x', 1), Name = new string('x', 1098841 + 1_022), PartitionKey = new string('x', 1_023) }; - var customer2 = new Customer { Id = new string('y', 1_023), Name = new string('x', 1098841), PartitionKey = new string('x', 1_023) }; - if (oneByteOver) - { - customer1.Name += 'x'; - customer2.Name += 'x'; - } - - context.Customers.Add(customer1); - context.Customers.Add(customer2); - if (oneByteOver) + // The id being a special character should make the difference whether this fits in 1 batch. + if (isIdSpecialChar) { - await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + Assert.Equal(2, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); } else { - await context.SaveChangesAsync(); + Assert.Equal(1, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); } } From 1d432d53224294818f47482004a4174184b49425 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:18:42 +0200 Subject: [PATCH 12/29] Clean --- test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs index 6093a7b6b59..2eb32d9017d 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs @@ -163,8 +163,6 @@ await context.AddAsync( await context.AddAsync( new Person { Id = 3, Addresses = new List
{ existingAddress1Person3, existingAddress2Person3 } }); - var entrys = context.ChangeTracker.Entries().ToList(); - await context.SaveChangesAsync(); var people = await context.Set().ToListAsync(); From 057f7693bb7790ac99ca086076e5ecc9cf7ffbad Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:31:30 +0200 Subject: [PATCH 13/29] Set type mapping via CosmosPropertyBuilderExtensions --- .../CosmosPropertyBuilderExtensions.cs | 3 ++ .../Internal/CosmosTypeMappingSource.cs | 42 ------------------ .../Internal/CosmosVectorTypeMapping.cs | 43 +++++++++++++++++-- 3 files changed, 42 insertions(+), 46 deletions(-) diff --git a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs index ba3c2b815ee..5a50e25d5ee 100644 --- a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs +++ b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore; @@ -122,6 +123,7 @@ public static PropertyBuilder IsVectorProperty( { propertyBuilder.Metadata.SetVectorDistanceFunction(ValidateVectorDistanceFunction(distanceFunction)); propertyBuilder.Metadata.SetVectorDimensions(dimensions); + propertyBuilder.Metadata.SetTypeMapping(CosmosVectorTypeMapping.Create(propertyBuilder.Metadata.ClrType, new CosmosVectorType(distanceFunction, dimensions))); return propertyBuilder; } @@ -176,6 +178,7 @@ public static PropertyBuilder IsVectorProperty( propertyBuilder.Metadata.SetVectorDistanceFunction(ValidateVectorDistanceFunction(distanceFunction), fromDataAnnotation); propertyBuilder.Metadata.SetVectorDimensions(dimensions, fromDataAnnotation); + propertyBuilder.Metadata.SetTypeMapping(CosmosVectorTypeMapping.Create(propertyBuilder.Metadata.ClrType, new CosmosVectorType(distanceFunction, dimensions))); return propertyBuilder; } diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index d9e5d3198fc..edf70fafa81 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -5,7 +5,6 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal; -using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; using Microsoft.EntityFrameworkCore.Storage.Internal; using Microsoft.EntityFrameworkCore.Storage.Json; using Newtonsoft.Json.Linq; @@ -41,47 +40,6 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) } }.ToFrozenDictionary(); - /// - /// 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. - /// - public override CoreTypeMapping? FindMapping(IProperty property) - { - // A provider should typically not override this because using the property directly causes problems with Migrations where - // the property does not exist. However, since the Cosmos provider doesn't have Migrations, it should be okay to use the property - // directly. - if (property.GetVectorDistanceFunction() is { } distanceFunction - && property.GetVectorDimensions() is { } dimensions) - { - CoreTypeMapping? elementMapping = null; - - var collectionType = property.ClrType; - Type? elementType = null; - if (collectionType.IsGenericType - && collectionType.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>)) - { - collectionType = collectionType.GetGenericArguments()[0].MakeArrayType(); - elementType = collectionType.GetElementType(); - } - - var collectionReaderWriter = TryFindJsonCollectionMapping(new TypeMappingInfo(collectionType), collectionType, null, ref elementMapping, out var _, out var r) ? r : null; - CoreTypeMapping mapping = new CosmosVectorTypeMapping(property.ClrType, new CosmosVectorType(distanceFunction, dimensions), collectionReaderWriter); - - if (elementType != null) - { - mapping = mapping.WithComposedConverter( - (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(elementType))!, - (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(elementType))!); - } - - return mapping; - } - - return base.FindMapping(property); - } - /// /// 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 diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs index 7b8d57711dd..7c004f31ef4 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs @@ -25,7 +25,7 @@ public class CosmosVectorTypeMapping : CosmosTypeMapping // Note that this default is not valid because dimensions cannot be zero. But since there is no reasonable // default dimensions size for a vector type, this is intentionally not valid rather than just being wrong. // The fundamental problem here is that type mappings are "required" to have some default now. - = new(typeof(byte[]), new CosmosVectorType(DistanceFunction.Cosine, 0), null); + = Create(typeof(byte[]), new CosmosVectorType(DistanceFunction.Cosine, 0)); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -33,9 +33,44 @@ public class CosmosVectorTypeMapping : CosmosTypeMapping /// 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. /// - public CosmosVectorTypeMapping(Type clrType, CosmosVectorType vectorType, JsonValueReaderWriter? jsonValueReaderWriter) : base(clrType, jsonValueReaderWriter: jsonValueReaderWriter) + public static CosmosVectorTypeMapping Create(Type clrType, CosmosVectorType vectorType) { - VectorType = vectorType; + var collectionType = clrType; + var isRom = clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>); + if (isRom) + { + collectionType = clrType.GetGenericArguments()[0].MakeArrayType(); + } + + var elementType = collectionType.GetElementType()!; + + JsonValueReaderWriter? jsonValueReaderWriter = collectionType switch + { + Type t when t == typeof(byte[]) => new JsonCollectionOfStructsReaderWriter(JsonByteReaderWriter.Instance), + Type t when t == typeof(sbyte[]) => new JsonCollectionOfStructsReaderWriter(JsonSByteReaderWriter.Instance), + Type t when t == typeof(float[]) => new JsonCollectionOfStructsReaderWriter(JsonFloatReaderWriter.Instance), + _ => null + }; + + var parameters = new CoreTypeMappingParameters( + clrType, + null, + null, + null, + null, + jsonValueReaderWriter: jsonValueReaderWriter); + + if (isRom) + { + parameters = parameters.WithComposedConverter( + (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(elementType))!, + (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(elementType))!, + null, + null, + null); + } + + return new CosmosVectorTypeMapping(parameters, vectorType); } /// @@ -44,7 +79,7 @@ public CosmosVectorTypeMapping(Type clrType, CosmosVectorType vectorType, JsonVa /// 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. /// - private CosmosVectorTypeMapping(CoreTypeMappingParameters parameters, CosmosVectorType vectorType) + protected CosmosVectorTypeMapping(CoreTypeMappingParameters parameters, CosmosVectorType vectorType) : base(parameters) => VectorType = vectorType; From a411b55ade4bb4b3931f29f2cc7409447b0e0688 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:04:29 +0200 Subject: [PATCH 14/29] Ignore ManyServiceProvidersCreatedWarning --- .../TestUtilities/CosmosTestStore.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs index 379a03d19c0..50bd8aea926 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs @@ -86,6 +86,8 @@ public override DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuild result.AddInterceptors(LinuxEmulatorSaveChangesInterceptor.Instance); } + builder.ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)); + return result; } From 87ccd090b313893477f932e13b419de0fad83d25 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:12:59 +0200 Subject: [PATCH 15/29] Clean --- .../CosmosTransactionalBatchTest.cs | 1 - .../TestUtilities/CosmosTestStore.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs index 4d2b0209cfb..9392722784e 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Runtime.CompilerServices; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Scripts; using Microsoft.EntityFrameworkCore.Cosmos.Internal; diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs index 50bd8aea926..ade073c9910 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs @@ -169,6 +169,7 @@ protected override async Task InitializeAsync(Func createContext, Fun { return; } + await base.InitializeAsync(createContext ?? (() => _storeContext), seed, clean).ConfigureAwait(false); } From 6133ccb6137b7694d92b4649dc5e941a17e11cb5 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:47:42 +0200 Subject: [PATCH 16/29] Move ManyServiceProvidersCreatedWarning to specific test --- test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs | 6 ++++-- .../TestUtilities/CosmosTestStore.cs | 2 -- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs index 197acde4f9a..b6adaebde2b 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs @@ -63,7 +63,8 @@ public async Task Etag_is_updated_in_entity_after_SaveChanges(bool? contentRespo o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); #pragma warning restore CS0618 // Type or member is obsolete } - }))).Options; + }) + .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)))).Options; var customer = new Customer { @@ -124,7 +125,8 @@ public async Task Etag_is_updated_in_derived_entity_after_SaveChanges(bool? cont o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); #pragma warning restore CS0618 // Type or member is obsolete } - }))).Options; + }) + .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)))).Options; var customer = new PremiumCustomer { diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs index ade073c9910..554256887ac 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs @@ -86,8 +86,6 @@ public override DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuild result.AddInterceptors(LinuxEmulatorSaveChangesInterceptor.Instance); } - builder.ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)); - return result; } From 2e3f7c4bdc9fe2d4988987d916f55392eab96acd Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:52:36 +0200 Subject: [PATCH 17/29] Use HandlesNullWrites --- .../Update/Internal/DocumentSource.cs | 3 +-- .../Storage/Json/JsonConvertedValueReaderWriter.cs | 14 +++++++++++++- src/EFCore/Storage/Json/JsonValueReaderWriter.cs | 11 ++++++++++- src/EFCore/Storage/Json/JsonValueReaderWriter`.cs | 11 +++++++++-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs index 0432123be04..bb336243b84 100644 --- a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs +++ b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs @@ -7,7 +7,6 @@ using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; -using Microsoft.EntityFrameworkCore.Storage.Internal; using Microsoft.EntityFrameworkCore.Update.Internal; namespace Microsoft.EntityFrameworkCore.Cosmos.Update.Internal; @@ -104,7 +103,7 @@ private void WriteJsonObject( writer.WritePropertyName(jsonPropertyName); var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; - if (propertyValue is not null || jsonValueReaderWriter is IJsonConvertedValueReaderWriter { Converter.ConvertsNulls: true }) + if (propertyValue is not null || jsonValueReaderWriter?.HandlesNullWrites == true) { Check.DebugAssert(jsonValueReaderWriter is not null, $"Missing JsonValueReaderWriter for property: {property}"); jsonValueReaderWriter.ToJson(writer, propertyValue!); diff --git a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs index 8c137b0e3df..74a36927a6e 100644 --- a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs @@ -32,13 +32,25 @@ public JsonConvertedValueReaderWriter( _converter = converter; } + /// + public override bool HandlesNullWrites => _converter.ConvertsNulls; + /// public override TModel FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) => (TModel)_converter.ConvertFromProvider(_providerReaderWriter.FromJsonTyped(ref manager, existingObject))!; /// public override void ToJsonTyped(Utf8JsonWriter writer, TModel value) - => _providerReaderWriter.ToJson(writer, (TProvider)_converter.ConvertToProvider(value)!); + { + var convertedValue = _converter.ConvertToProvider(value); + if (convertedValue == null && !_providerReaderWriter.HandlesNullWrites) + { + writer.WriteNullValue(); + return; + } + + _providerReaderWriter.ToJson(writer, convertedValue); + } JsonValueReaderWriter ICompositeJsonValueReaderWriter.InnerReaderWriter => _providerReaderWriter; diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs index f61f541659d..94daf881dcb 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs @@ -21,6 +21,15 @@ internal JsonValueReaderWriter() { } + /// + /// If , then the nulls will be passed to the writer's method. Otherwise null + /// values will always be written as . + /// + /// + /// The default is . + /// + public virtual bool HandlesNullWrites { get; } = false; + /// /// Reads the value from a UTF8 JSON stream or buffer. /// @@ -48,7 +57,7 @@ internal JsonValueReaderWriter() /// /// The into which the value should be written. /// The value to write. - public abstract void ToJson(Utf8JsonWriter writer, object value); + public abstract void ToJson(Utf8JsonWriter writer, object? value); /// /// The type of the value being read/written. diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs index f6b67545f7e..1c9735fb8d2 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs @@ -15,8 +15,15 @@ public sealed override object FromJson(ref Utf8JsonReaderManager manager, object => FromJsonTyped(ref manager, existingObject)!; /// - public sealed override void ToJson(Utf8JsonWriter writer, object value) - => ToJsonTyped(writer, (TValue)value!); + public sealed override void ToJson(Utf8JsonWriter writer, object? value) + { + if (value == null && !HandlesNullWrites) + { + throw new ArgumentNullException(nameof(value)); + } + + ToJsonTyped(writer, (TValue)value!); + } /// public sealed override Type ValueType From 8bb011068b69dd0a0bd5d4bc5c1a3919a1025dfb Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:12:56 +0200 Subject: [PATCH 18/29] Add more Ignore(ManyServiceProvidersCreatedWarning) to ConfigPatternsCosmosTest --- .../EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs index dffdabadae9..6322a8e0765 100644 --- a/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs @@ -187,7 +187,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public class CosmosFixture : ServiceProviderFixtureBase { public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) - => base.AddOptions(builder).ConfigureWarnings(w => w.Ignore(CosmosEventId.NoPartitionKeyDefined)); + => base.AddOptions(builder).ConfigureWarnings(w => + w.Ignore(CosmosEventId.NoPartitionKeyDefined) + .Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)); protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance; From f0a20b213763f0d5ca5cd6efc76715eb4113da7c Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:06:45 +0200 Subject: [PATCH 19/29] Use separate SP --- .../ConfigPatternsCosmosTest.cs | 3 +-- .../CosmosConcurrencyTest.cs | 14 ++++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs index 6322a8e0765..890ff565da1 100644 --- a/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs @@ -188,8 +188,7 @@ public class CosmosFixture : ServiceProviderFixtureBase { public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) => base.AddOptions(builder).ConfigureWarnings(w => - w.Ignore(CosmosEventId.NoPartitionKeyDefined) - .Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)); + w.Ignore(CosmosEventId.NoPartitionKeyDefined)); protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance; diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs index b6adaebde2b..8f875f00922 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs @@ -9,6 +9,10 @@ public class CosmosConcurrencyTest(CosmosConcurrencyTest.CosmosFixture fixture) { private const string DatabaseName = "CosmosConcurrencyTest"; + protected IServiceProvider ServiceProvider { get; } = new ServiceCollection() + .AddEntityFrameworkCosmos() + .BuildServiceProvider(); + protected CosmosFixture Fixture { get; } = fixture; [ConditionalFact] @@ -63,8 +67,9 @@ public async Task Etag_is_updated_in_entity_after_SaveChanges(bool? contentRespo o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); #pragma warning restore CS0618 // Type or member is obsolete } - }) - .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)))).Options; + }))) + .UseInternalServiceProvider(ServiceProvider) + .Options; var customer = new Customer { @@ -125,8 +130,9 @@ public async Task Etag_is_updated_in_derived_entity_after_SaveChanges(bool? cont o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); #pragma warning restore CS0618 // Type or member is obsolete } - }) - .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)))).Options; + }))) + .UseInternalServiceProvider(ServiceProvider) + .Options; var customer = new PremiumCustomer { From cd57ea4798ae8c538fe816cfb020c534aff86ec6 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:04:10 +0200 Subject: [PATCH 20/29] Dispose SP --- .../EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs index 8f875f00922..f69473226bf 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs @@ -5,11 +5,11 @@ namespace Microsoft.EntityFrameworkCore; #nullable disable -public class CosmosConcurrencyTest(CosmosConcurrencyTest.CosmosFixture fixture) : IClassFixture +public class CosmosConcurrencyTest(CosmosConcurrencyTest.CosmosFixture fixture) : IClassFixture, IAsyncLifetime { private const string DatabaseName = "CosmosConcurrencyTest"; - protected IServiceProvider ServiceProvider { get; } = new ServiceCollection() + protected ServiceProvider ServiceProvider { get; } = new ServiceCollection() .AddEntityFrameworkCosmos() .BuildServiceProvider(); @@ -248,6 +248,9 @@ protected virtual ConcurrencyContext CreateContext() protected virtual ConcurrencyContext CreateContext(DbContextOptions options) => new ConcurrencyContext(options); + public virtual Task InitializeAsync() => Task.CompletedTask; + public virtual async Task DisposeAsync() => await ServiceProvider.DisposeAsync(); + public class CosmosFixture : SharedStoreFixtureBase { protected override string StoreName From ab307c8edece0d93284eca8117ad98680affafdf Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:20:16 +0200 Subject: [PATCH 21/29] Add CosmosRetryTest --- .../CosmosRetryStrategyTest.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs new file mode 100644 index 00000000000..71a30536167 --- /dev/null +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.EntityFrameworkCore; + +#nullable disable + +[CosmosCondition(CosmosCondition.IsNotLinuxEmulator)] +public class CosmosRetryTest(CosmosRetryTest.CosmosRetryFixture fixture) + : IClassFixture, IAsyncLifetime +{ + private const string DatabaseName = nameof(CosmosRetryTest); + + protected CosmosRetryFixture Fixture { get; } = fixture; + + [ConditionalTheory, InlineData(false), InlineData(true)] + public async Task Retry_for_create_stores_document(bool transactionalBatch) + { + var customer = new Customer { Id = 42, Name = "Theon" }; + using (var context = CreateContext(transactionalBatch)) + { + context.Add(customer); + + try + { + Fixture.RequestHandler.Reset(); + Fixture.RequestHandler.ShouldFailNextRequest = true; + await context.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (ex.InnerException is CosmosException { StatusCode: HttpStatusCode.Conflict }) + { + // Ignored, because the request is actually executed and the error is only a mock, + // the document was already created and we get a conflict + } + + Assert.Equal(2, Fixture.RequestHandler.RequestCount); + } + + using (var context = CreateContext(transactionalBatch)) + { + var customerFromStore = await context.Set().SingleAsync(); + Assert.Equal(42, customerFromStore.Id); + Assert.Equal("Theon", customerFromStore.Name); + } + } + + [ConditionalTheory, InlineData(false), InlineData(true)] + public async Task Retry_for_update_stores_document(bool transactionalBatch) + { + var customer = new Customer { Id = 42, Name = "Theon" }; + + using (var context = CreateContext(transactionalBatch)) + { + context.Add(customer); + await context.SaveChangesAsync(); + } + + using (var context = CreateContext(transactionalBatch)) + { + var customerFromStore = await context.Set().SingleAsync(); + customerFromStore.Name = "Theon Greyjoy"; + + Fixture.RequestHandler.Reset(); + Fixture.RequestHandler.ShouldFailNextRequest = true; + await context.SaveChangesAsync(); + Assert.Equal(2, Fixture.RequestHandler.RequestCount); + } + + using (var context = CreateContext(transactionalBatch)) + { + var customerFromStore = await context.Set().SingleAsync(); + Assert.Equal("Theon Greyjoy", customerFromStore.Name); + } + } + + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } + } + + private RetryStrategyContext CreateContext(bool transactionalBatch) + { + var context = Fixture.CreateContext(); + if (transactionalBatch) + { + context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; + } + return context; + } + + public async Task InitializeAsync() + { + Fixture.RequestHandler.Reset(); + using var context = Fixture.CreateContext(); + context.RemoveRange(await context.Customers.ToListAsync()); + await context.SaveChangesAsync(); + } + + public async Task DisposeAsync() + { + } + + public class CosmosRetryFixture : SharedStoreFixtureBase + { + protected override string StoreName + => DatabaseName; + + protected override ITestStoreFactory TestStoreFactory + => CosmosTestStoreFactory.Instance; + + public TooManyRequestsHandler RequestHandler { get; } = new TooManyRequestsHandler(); + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + => base.AddOptions(builder) + .UseCosmos(b => b.HttpClientFactory(() => new HttpClient(RequestHandler))); + } + + public class RetryStrategyContext(DbContextOptions options) : PoolableDbContext(options) + { + public DbSet Customers { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder builder) + => builder.Entity( + b => b.HasPartitionKey(c => c.Id)); + } + + public class TooManyRequestsHandler : DelegatingHandler + { + public TooManyRequestsHandler() + : base(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }) + { + } + + public int RequestCount { get; private set; } + + public bool ShouldFailNextRequest { get; set; } + + public void Reset() + { + ShouldFailNextRequest = false; + RequestCount = 0; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + var response = await base.SendAsync(request, cancellationToken); + if (ShouldFailNextRequest) + { + ShouldFailNextRequest = false; + + response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("x-ms-retry-after-ms", "1"); + } + return response; + + } + } +} From d74bd6407e44aef7d614d99045fd6f17186842ec Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:27:09 +0200 Subject: [PATCH 22/29] Clean --- test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs index 2eb32d9017d..ed2ca601c95 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs @@ -240,7 +240,6 @@ await context.AddAsync( var existingFirstAddressEntry = context.Entry(people[2].Addresses.First()); Assert.Equal("First", people[2].Addresses.First().Street); - people[2].Addresses.First(); existingAddress1Person3 = people[2].Addresses.First(); existingAddress2Person3 = people[2].Addresses.Last(); From a5f0443010d7bc4c092ba3cc68ce6da23fd26810 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:27:13 +0200 Subject: [PATCH 23/29] Add using --- .../Storage/Internal/CosmosDatabaseWrapper.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs index 5ad66bcbfd6..e08d9bba257 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs @@ -467,19 +467,23 @@ private async Task SaveAsync(CosmosUpdateEntry updateEntry, CancellationTo try { var id = updateEntry.DocumentSource.GetId(updateEntry.Entry.SharedIdentityEntry ?? updateEntry.Entry); + using var stream = updateEntry.Operation != CosmosCudOperation.Delete + ? updateEntry.DocumentSource.Serialize(updateEntry.Entry) + : null; + return updateEntry.Operation switch { CosmosCudOperation.Create => await _cosmosClient.CreateItemAsync( updateEntry.CollectionId, id, - updateEntry.DocumentSource.Serialize(updateEntry.Entry), + stream!, updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), CosmosCudOperation.Update => await _cosmosClient.ReplaceItemAsync( updateEntry.CollectionId, id, - updateEntry.DocumentSource.Serialize(updateEntry.Entry), + stream!, updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), From 18028cb5f162d052a82f96816fb85be795b2874a Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:53:25 +0200 Subject: [PATCH 24/29] Clean --- .../Storage/Internal/SingletonCosmosClientWrapper.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs index ac5c298ec9c..88310a1ea49 100644 --- a/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/SingletonCosmosClientWrapper.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Azure.Core; using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Internal; From c2aea817e9d22b2adec37c6f4b44f583e2bce519 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:08:48 +0200 Subject: [PATCH 25/29] Use ROM and new streams --- .../Storage/Internal/CosmosClientWrapper.cs | 25 ++- .../Storage/Internal/CosmosDatabaseWrapper.cs | 21 ++- .../Internal/CosmosTransactionalBatchEntry.cs | 1 - .../CosmosTransactionalBatchWrapper.cs | 23 ++- .../Storage/Internal/ICosmosClientWrapper.cs | 4 +- .../ICosmosTransactionalBatchWrapper.cs | 4 +- .../Update/Internal/DocumentSource.cs | 5 +- .../CosmosRetryStrategyTest.cs | 166 ------------------ 8 files changed, 54 insertions(+), 195 deletions(-) delete mode 100644 test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index 8d099a8606a..31706f1bf2b 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -5,6 +5,7 @@ using System.Collections.ObjectModel; using System.Net; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Microsoft.Azure.Cosmos.Scripts; using Microsoft.EntityFrameworkCore.Cosmos.Diagnostics.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal; @@ -313,7 +314,7 @@ private static string GetPathFromRoot(IReadOnlyEntityType entityType) public virtual Task CreateItemAsync( string containerId, string documentId, - Stream document, + ReadOnlyMemory document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default) @@ -321,7 +322,7 @@ public virtual Task CreateItemAsync( private static async Task CreateItemOnceAsync( DbContext _, - (string ContainerId, string DocumentId, Stream Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, + (string ContainerId, string DocumentId, ReadOnlyMemory Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, CancellationToken cancellationToken = default) { var containerId = parameters.ContainerId; @@ -347,8 +348,14 @@ private static async Task CreateItemOnceAsync( } } + if (!MemoryMarshal.TryGetArray(parameters.Document, out var segment) || segment.Array == null) + { + throw new UnreachableException("ReadOnlyMemory should have an underlying array."); + } + + using var stream = new MemoryStream(segment.Array, segment.Offset, segment.Count); using var response = await container.CreateItemStreamAsync( - parameters.Document, + stream, partitionKeyValue, itemRequestOptions, cancellationToken) @@ -376,7 +383,7 @@ private static async Task CreateItemOnceAsync( public virtual Task ReplaceItemAsync( string collectionId, string documentId, - Stream document, + ReadOnlyMemory document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default) @@ -385,7 +392,7 @@ public virtual Task ReplaceItemAsync( private static async Task ReplaceItemOnceAsync( DbContext _, - (string ContainerId, string ResourceId, Stream Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, + (string ContainerId, string ResourceId, ReadOnlyMemory Document, IUpdateEntry Entry, ISessionTokenStorage SessionTokenStorage, CosmosClientWrapper Wrapper) parameters, CancellationToken cancellationToken = default) { var containerId = parameters.ContainerId; @@ -410,8 +417,14 @@ private static async Task ReplaceItemOnceAsync( } } + if (!MemoryMarshal.TryGetArray(parameters.Document, out var segment) || segment.Array == null) + { + throw new UnreachableException("ReadOnlyMemory should have an underlying array."); + } + + using var stream = new MemoryStream(segment.Array, segment.Offset, segment.Count); using var response = await container.ReplaceItemStreamAsync( - parameters.Document, + stream, parameters.ResourceId, partitionKeyValue, itemRequestOptions, diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs index e08d9bba257..4e7e95771f9 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs @@ -425,15 +425,14 @@ private IEnumerable CreateTransactions((Groupi foreach (var updateEntry in batch.UpdateEntries) { - // Stream is disposed by Transaction.ExecuteAsync - var stream = updateEntry.Operation != CosmosCudOperation.Delete ? updateEntry.DocumentSource.Serialize(updateEntry.Entry) : null; + var document = updateEntry.Operation != CosmosCudOperation.Delete ? updateEntry.DocumentSource.Serialize(updateEntry.Entry) : default; // With AutoTransactionBehavior.Always, AddToTransaction will always return true. - if (!AddToTransaction(transaction, updateEntry, stream)) + if (!AddToTransaction(transaction, updateEntry, document)) { yield return transaction; transaction = _cosmosClient.CreateTransactionalBatch(batch.Key.ContainerId, batch.Key.PartitionKeyValue, checkSize); - AddToTransaction(transaction, updateEntry, stream); + AddToTransaction(transaction, updateEntry, document); continue; } @@ -450,13 +449,13 @@ private IEnumerable CreateTransactions((Groupi } } - private bool AddToTransaction(ICosmosTransactionalBatchWrapper transaction, CosmosUpdateEntry updateEntry, Stream? stream) + private bool AddToTransaction(ICosmosTransactionalBatchWrapper transaction, CosmosUpdateEntry updateEntry, ReadOnlyMemory document) { var id = updateEntry.DocumentSource.GetId(updateEntry.Entry.SharedIdentityEntry ?? updateEntry.Entry); return updateEntry.Operation switch { - CosmosCudOperation.Create => transaction.CreateItem(id, stream!, updateEntry.Entry), - CosmosCudOperation.Update => transaction.ReplaceItem(id, stream!, updateEntry.Entry), + CosmosCudOperation.Create => transaction.CreateItem(id, document, updateEntry.Entry), + CosmosCudOperation.Update => transaction.ReplaceItem(id, document, updateEntry.Entry), CosmosCudOperation.Delete => transaction.DeleteItem(id, updateEntry.Entry), _ => throw new UnreachableException(), }; @@ -467,23 +466,23 @@ private async Task SaveAsync(CosmosUpdateEntry updateEntry, CancellationTo try { var id = updateEntry.DocumentSource.GetId(updateEntry.Entry.SharedIdentityEntry ?? updateEntry.Entry); - using var stream = updateEntry.Operation != CosmosCudOperation.Delete + var document = updateEntry.Operation != CosmosCudOperation.Delete ? updateEntry.DocumentSource.Serialize(updateEntry.Entry) - : null; + : default; return updateEntry.Operation switch { CosmosCudOperation.Create => await _cosmosClient.CreateItemAsync( updateEntry.CollectionId, id, - stream!, + document, updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), CosmosCudOperation.Update => await _cosmosClient.ReplaceItemAsync( updateEntry.CollectionId, id, - stream!, + document, updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchEntry.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchEntry.cs index 146f00de430..0fae2e3def4 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchEntry.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchEntry.cs @@ -11,7 +11,6 @@ namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; /// public class CosmosTransactionalBatchEntry { - /// /// 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 diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs index e56fc7ece9c..852f750b3a9 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionalBatchWrapper.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.InteropServices; using System.Text; namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; @@ -72,13 +73,13 @@ public CosmosTransactionalBatchWrapper( /// 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. /// - public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) + public bool CreateItem(string id, ReadOnlyMemory document, IUpdateEntry updateEntry) { var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { - var size = stream.Length + itemRequestOptionsLength + OperationSerializationOverheadOverEstimateInBytes; + var size = document.Length + itemRequestOptionsLength + OperationSerializationOverheadOverEstimateInBytes; if (_size + size > MaxSize && _size != 0) { @@ -87,6 +88,13 @@ public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) _size += size; } + if (!MemoryMarshal.TryGetArray(document, out var segment) || segment.Array == null) + { + throw new UnreachableException("ReadOnlyMemory should have an underlying array."); + } + + // Stream is disposed by disposing the response of TransactionalBatch.ExecuteAsync in CosmosClientWrapper. + var stream = new MemoryStream(segment.Array, segment.Offset, segment.Count); _transactionalBatch.CreateItemStream(stream, itemRequestOptions); _entries.Add(new CosmosTransactionalBatchEntry(updateEntry, CosmosCudOperation.Create, id)); @@ -99,13 +107,13 @@ public bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry) /// 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. ///
- public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEntry) + public bool ReplaceItem(string documentId, ReadOnlyMemory document, IUpdateEntry updateEntry) { var itemRequestOptions = CreateItemRequestOptions(updateEntry, out var itemRequestOptionsLength); if (_checkSize) { - var size = stream.Length + itemRequestOptionsLength + OperationSerializationOverheadOverEstimateInBytes + Encoding.UTF8.GetByteCount(documentId); + var size = document.Length + itemRequestOptionsLength + OperationSerializationOverheadOverEstimateInBytes + Encoding.UTF8.GetByteCount(documentId); if (_size + size > MaxSize && _size != 0) { @@ -114,6 +122,13 @@ public bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEnt _size += size; } + if (!MemoryMarshal.TryGetArray(document, out var segment) || segment.Array == null) + { + throw new UnreachableException("ReadOnlyMemory should have an underlying array."); + } + + // Stream is disposed by disposing the response of TransactionalBatch.ExecuteAsync in CosmosClientWrapper. + var stream = new MemoryStream(segment.Array, segment.Offset, segment.Count); _transactionalBatch.ReplaceItemStream(documentId, stream, itemRequestOptions); _entries.Add(new CosmosTransactionalBatchEntry(updateEntry, CosmosCudOperation.Update, documentId)); diff --git a/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs index 30f8b229734..a2bef3f6a36 100644 --- a/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs @@ -46,7 +46,7 @@ public interface ICosmosClientWrapper Task CreateItemAsync( string containerId, string documentId, - Stream document, + ReadOnlyMemory document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default); @@ -60,7 +60,7 @@ Task CreateItemAsync( Task ReplaceItemAsync( string collectionId, string documentId, - Stream document, + ReadOnlyMemory document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default); diff --git a/src/EFCore.Cosmos/Storage/Internal/ICosmosTransactionalBatchWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/ICosmosTransactionalBatchWrapper.cs index 50615843c52..abb49647956 100644 --- a/src/EFCore.Cosmos/Storage/Internal/ICosmosTransactionalBatchWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/ICosmosTransactionalBatchWrapper.cs @@ -43,7 +43,7 @@ public interface ICosmosTransactionalBatchWrapper /// 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. ///
- bool CreateItem(string id, Stream stream, IUpdateEntry updateEntry); + bool CreateItem(string id, ReadOnlyMemory document, IUpdateEntry updateEntry); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -59,7 +59,7 @@ public interface ICosmosTransactionalBatchWrapper /// 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. /// - bool ReplaceItem(string documentId, Stream stream, IUpdateEntry updateEntry); + bool ReplaceItem(string documentId, ReadOnlyMemory document, IUpdateEntry updateEntry); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs index bb336243b84..0172a1f1c32 100644 --- a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs +++ b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs @@ -65,7 +65,7 @@ public virtual string GetId(IUpdateEntry entry) /// 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. /// - public virtual Stream Serialize(IUpdateEntry entry) + public virtual ReadOnlyMemory Serialize(IUpdateEntry entry) { var internalEntry = (IInternalEntry)entry; var stream = new MemoryStream(); @@ -74,8 +74,7 @@ public virtual Stream Serialize(IUpdateEntry entry) WriteJsonObject(writer, internalEntry, internalEntry.StructuralType, null); } - stream.Position = 0; - return stream; + return new ReadOnlyMemory(stream.GetBuffer(), 0, (int)stream.Length); } private void WriteJsonObject( diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs deleted file mode 100644 index 71a30536167..00000000000 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosRetryStrategyTest.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Net; -using Microsoft.Azure.Cosmos; - -namespace Microsoft.EntityFrameworkCore; - -#nullable disable - -[CosmosCondition(CosmosCondition.IsNotLinuxEmulator)] -public class CosmosRetryTest(CosmosRetryTest.CosmosRetryFixture fixture) - : IClassFixture, IAsyncLifetime -{ - private const string DatabaseName = nameof(CosmosRetryTest); - - protected CosmosRetryFixture Fixture { get; } = fixture; - - [ConditionalTheory, InlineData(false), InlineData(true)] - public async Task Retry_for_create_stores_document(bool transactionalBatch) - { - var customer = new Customer { Id = 42, Name = "Theon" }; - using (var context = CreateContext(transactionalBatch)) - { - context.Add(customer); - - try - { - Fixture.RequestHandler.Reset(); - Fixture.RequestHandler.ShouldFailNextRequest = true; - await context.SaveChangesAsync(); - } - catch (DbUpdateException ex) when (ex.InnerException is CosmosException { StatusCode: HttpStatusCode.Conflict }) - { - // Ignored, because the request is actually executed and the error is only a mock, - // the document was already created and we get a conflict - } - - Assert.Equal(2, Fixture.RequestHandler.RequestCount); - } - - using (var context = CreateContext(transactionalBatch)) - { - var customerFromStore = await context.Set().SingleAsync(); - Assert.Equal(42, customerFromStore.Id); - Assert.Equal("Theon", customerFromStore.Name); - } - } - - [ConditionalTheory, InlineData(false), InlineData(true)] - public async Task Retry_for_update_stores_document(bool transactionalBatch) - { - var customer = new Customer { Id = 42, Name = "Theon" }; - - using (var context = CreateContext(transactionalBatch)) - { - context.Add(customer); - await context.SaveChangesAsync(); - } - - using (var context = CreateContext(transactionalBatch)) - { - var customerFromStore = await context.Set().SingleAsync(); - customerFromStore.Name = "Theon Greyjoy"; - - Fixture.RequestHandler.Reset(); - Fixture.RequestHandler.ShouldFailNextRequest = true; - await context.SaveChangesAsync(); - Assert.Equal(2, Fixture.RequestHandler.RequestCount); - } - - using (var context = CreateContext(transactionalBatch)) - { - var customerFromStore = await context.Set().SingleAsync(); - Assert.Equal("Theon Greyjoy", customerFromStore.Name); - } - } - - public class Customer - { - public int Id { get; set; } - public string Name { get; set; } - } - - private RetryStrategyContext CreateContext(bool transactionalBatch) - { - var context = Fixture.CreateContext(); - if (transactionalBatch) - { - context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; - } - return context; - } - - public async Task InitializeAsync() - { - Fixture.RequestHandler.Reset(); - using var context = Fixture.CreateContext(); - context.RemoveRange(await context.Customers.ToListAsync()); - await context.SaveChangesAsync(); - } - - public async Task DisposeAsync() - { - } - - public class CosmosRetryFixture : SharedStoreFixtureBase - { - protected override string StoreName - => DatabaseName; - - protected override ITestStoreFactory TestStoreFactory - => CosmosTestStoreFactory.Instance; - - public TooManyRequestsHandler RequestHandler { get; } = new TooManyRequestsHandler(); - - public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) - => base.AddOptions(builder) - .UseCosmos(b => b.HttpClientFactory(() => new HttpClient(RequestHandler))); - } - - public class RetryStrategyContext(DbContextOptions options) : PoolableDbContext(options) - { - public DbSet Customers { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder builder) - => builder.Entity( - b => b.HasPartitionKey(c => c.Id)); - } - - public class TooManyRequestsHandler : DelegatingHandler - { - public TooManyRequestsHandler() - : base(new HttpClientHandler - { - ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }) - { - } - - public int RequestCount { get; private set; } - - public bool ShouldFailNextRequest { get; set; } - - public void Reset() - { - ShouldFailNextRequest = false; - RequestCount = 0; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - RequestCount++; - var response = await base.SendAsync(request, cancellationToken); - if (ShouldFailNextRequest) - { - ShouldFailNextRequest = false; - - response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); - response.Headers.Add("x-ms-retry-after-ms", "1"); - } - return response; - - } - } -} From 7c4ed82a58bd670a8fb286041c68927d039641c4 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:26:28 +0200 Subject: [PATCH 26/29] Small exception message change --- .../Storage/Internal/CosmosTypeMappingSource.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index edf70fafa81..eff864b06c6 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -235,7 +235,7 @@ public sealed class CosmosJsonStringKeyedDictionaryReaderWriter(JsonVa public override IEnumerable> FromJsonTyped( ref Utf8JsonReaderManager manager, object? existingObject = null) - => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + => throw new NotImplementedException("JsonValueReader infrastructure for Dictionary is not supported on Cosmos."); // @TODO: #34567 /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -301,7 +301,7 @@ public sealed class CosmosJsonStringKeyedDictionaryNullableValueReaderWriter> FromJsonTyped( ref Utf8JsonReaderManager manager, object? existingObject = null) - => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + => throw new NotImplementedException("JsonValueReader infrastructure for Dictionary is not supported on Cosmos."); // @TODO: #34567 /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -367,7 +367,7 @@ public sealed class CosmosJsonStringKeyedDictionaryCollectionValueReaderWriter> FromJsonTyped( ref Utf8JsonReaderManager manager, object? existingObject = null) - => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + => throw new NotImplementedException("JsonValueReader infrastructure for Dictionary is not supported on Cosmos."); // @TODO: #34567 /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -434,7 +434,7 @@ public sealed class CosmosJsonStringKeyedDictionaryReferenceCollectionValueReade public override IEnumerable> FromJsonTyped( ref Utf8JsonReaderManager manager, object? existingObject = null) - => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + => throw new NotImplementedException("JsonValueReader infrastructure for Dictionary is not supported on Cosmos."); // @TODO: #34567 /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to From b7b79f5bf110ecd0d85ca63893a3a716963de452 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:00:38 +0200 Subject: [PATCH 27/29] Clean --- .../Storage/Internal/CosmosDatabaseWrapper.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs index 4e7e95771f9..5bb121ef0af 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs @@ -466,23 +466,19 @@ private async Task SaveAsync(CosmosUpdateEntry updateEntry, CancellationTo try { var id = updateEntry.DocumentSource.GetId(updateEntry.Entry.SharedIdentityEntry ?? updateEntry.Entry); - var document = updateEntry.Operation != CosmosCudOperation.Delete - ? updateEntry.DocumentSource.Serialize(updateEntry.Entry) - : default; - return updateEntry.Operation switch { CosmosCudOperation.Create => await _cosmosClient.CreateItemAsync( updateEntry.CollectionId, id, - document, + updateEntry.DocumentSource.Serialize(updateEntry.Entry), updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), CosmosCudOperation.Update => await _cosmosClient.ReplaceItemAsync( updateEntry.CollectionId, id, - document, + updateEntry.DocumentSource.Serialize(updateEntry.Entry), updateEntry.Entry, SessionTokenStorage, cancellationToken).ConfigureAwait(false), From a061ffd842fb121bf48b108a2d7e54d99de239f7 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:06:47 +0200 Subject: [PATCH 28/29] Move TypeMapping spec to FindMapping --- .../CosmosPropertyBuilderExtensions.cs | 3 --- .../Storage/Internal/ByteArrayConverter.cs | 2 +- .../Storage/Internal/CosmosTypeMappingSource.cs | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs index 5a50e25d5ee..ba3c2b815ee 100644 --- a/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs +++ b/src/EFCore.Cosmos/Extensions/CosmosPropertyBuilderExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; -using Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore; @@ -123,7 +122,6 @@ public static PropertyBuilder IsVectorProperty( { propertyBuilder.Metadata.SetVectorDistanceFunction(ValidateVectorDistanceFunction(distanceFunction)); propertyBuilder.Metadata.SetVectorDimensions(dimensions); - propertyBuilder.Metadata.SetTypeMapping(CosmosVectorTypeMapping.Create(propertyBuilder.Metadata.ClrType, new CosmosVectorType(distanceFunction, dimensions))); return propertyBuilder; } @@ -178,7 +176,6 @@ public static PropertyBuilder IsVectorProperty( propertyBuilder.Metadata.SetVectorDistanceFunction(ValidateVectorDistanceFunction(distanceFunction), fromDataAnnotation); propertyBuilder.Metadata.SetVectorDimensions(dimensions, fromDataAnnotation); - propertyBuilder.Metadata.SetTypeMapping(CosmosVectorTypeMapping.Create(propertyBuilder.Metadata.ClrType, new CosmosVectorType(distanceFunction, dimensions))); return propertyBuilder; } diff --git a/src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs b/src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs index fe63cd57d67..875d5cde061 100644 --- a/src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs +++ b/src/EFCore.Cosmos/Storage/Internal/ByteArrayConverter.cs @@ -56,7 +56,7 @@ public override object ReadJson( { if (reader.TokenType != JsonToken.StartArray) { - throw new Exception(reader.TokenType.ToString()); + throw new InvalidOperationException(CoreStrings.JsonReaderInvalidTokenType(reader.TokenType)); } var byteList = new List(); diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index eff864b06c6..0a21af180f5 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Cosmos.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal; using Microsoft.EntityFrameworkCore.Storage.Internal; using Microsoft.EntityFrameworkCore.Storage.Json; using Newtonsoft.Json.Linq; @@ -40,6 +41,21 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) } }.ToFrozenDictionary(); + /// + /// 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. + /// + public override CoreTypeMapping? FindMapping(IProperty property) + // A provider should typically not override this because using the property directly causes problems with Migrations where + // the property does not exist. However, since the Cosmos provider doesn't have Migrations, it should be okay to use the property + // directly. + => property.GetVectorDistanceFunction() is { } distanceFunction + && property.GetVectorDimensions() is { } dimensions + ? CosmosVectorTypeMapping.Create(property.ClrType, new CosmosVectorType(distanceFunction, dimensions)) + : base.FindMapping(property); + /// /// 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 From 4d63968eb7b7697fd077966a85837f65e98b3028 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:47:24 +0200 Subject: [PATCH 29/29] Use TryFindJsonCollectionMapping --- .../Internal/CosmosTypeMappingSource.cs | 37 ++++++++- .../Internal/CosmosVectorTypeMapping.cs | 77 +++++++++---------- 2 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index 0a21af180f5..5cd4eb78f47 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs @@ -53,9 +53,39 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) // directly. => property.GetVectorDistanceFunction() is { } distanceFunction && property.GetVectorDimensions() is { } dimensions - ? CosmosVectorTypeMapping.Create(property.ClrType, new CosmosVectorType(distanceFunction, dimensions)) + ? CreateVectorTypeMapping(property, new CosmosVectorType(distanceFunction, dimensions)) : base.FindMapping(property); + private CosmosVectorTypeMapping? CreateVectorTypeMapping(IProperty property, CosmosVectorType cosmosVectorType) + { + var clrType = property.ClrType; + var collectionType = clrType; + var isRom = clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>); + if (isRom) + { + collectionType = clrType.GetGenericArguments()[0].MakeArrayType(); + } + + var sequenceType = collectionType.GetSequenceType(); + var elementMappingInfo = new TypeMappingInfo(sequenceType); + + CoreTypeMapping? _ = null; + if (!TryFindJsonCollectionMapping(elementMappingInfo, collectionType, null, ref _, out var _, out var readerWriter)) + { + return null; + } + + var typeMapping = new CosmosVectorTypeMapping(clrType, cosmosVectorType, jsonValueReaderWriter: readerWriter); + if (isRom) + { + typeMapping = typeMapping.WithComposedConverter( + (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(sequenceType))!, + (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(sequenceType))!); + } + + return typeMapping; + } + /// /// 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 @@ -83,8 +113,9 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) { var elementMappingInfo = new TypeMappingInfo(memoryType); CoreTypeMapping? typeMapping = null; - TryFindJsonCollectionMapping(elementMappingInfo, memoryType.MakeArrayType(), null, ref typeMapping, out var _, out var readerWriter); - return new CosmosTypeMapping(clrType, jsonValueReaderWriter: readerWriter) + return !TryFindJsonCollectionMapping(elementMappingInfo, memoryType.MakeArrayType(), null, ref typeMapping, out var _, out var readerWriter) + ? null + : new CosmosTypeMapping(clrType, jsonValueReaderWriter: readerWriter) .WithComposedConverter( (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(memoryType))!, (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(memoryType))!); diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs index 7c004f31ef4..031f73c3d49 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs @@ -25,7 +25,7 @@ public class CosmosVectorTypeMapping : CosmosTypeMapping // Note that this default is not valid because dimensions cannot be zero. But since there is no reasonable // default dimensions size for a vector type, this is intentionally not valid rather than just being wrong. // The fundamental problem here is that type mappings are "required" to have some default now. - = Create(typeof(byte[]), new CosmosVectorType(DistanceFunction.Cosine, 0)); + = new(typeof(byte[]), new CosmosVectorType(DistanceFunction.Cosine, 0)); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -33,44 +33,43 @@ public class CosmosVectorTypeMapping : CosmosTypeMapping /// 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. /// - public static CosmosVectorTypeMapping Create(Type clrType, CosmosVectorType vectorType) + public CosmosVectorTypeMapping( + Type clrType, + CosmosVectorType vectorType, + ValueComparer? comparer = null, + ValueComparer? keyComparer = null, + CoreTypeMapping? elementMapping = null, + JsonValueReaderWriter? jsonValueReaderWriter = null) + : this( + new CoreTypeMappingParameters( + clrType, + converter: null, + comparer, + keyComparer, + elementMapping: elementMapping, + jsonValueReaderWriter: jsonValueReaderWriter), + vectorType) { - var collectionType = clrType; - var isRom = clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>); - if (isRom) - { - collectionType = clrType.GetGenericArguments()[0].MakeArrayType(); - } - - var elementType = collectionType.GetElementType()!; - - JsonValueReaderWriter? jsonValueReaderWriter = collectionType switch - { - Type t when t == typeof(byte[]) => new JsonCollectionOfStructsReaderWriter(JsonByteReaderWriter.Instance), - Type t when t == typeof(sbyte[]) => new JsonCollectionOfStructsReaderWriter(JsonSByteReaderWriter.Instance), - Type t when t == typeof(float[]) => new JsonCollectionOfStructsReaderWriter(JsonFloatReaderWriter.Instance), - _ => null - }; - - var parameters = new CoreTypeMappingParameters( - clrType, - null, - null, - null, - null, - jsonValueReaderWriter: jsonValueReaderWriter); - - if (isRom) - { - parameters = parameters.WithComposedConverter( - (ValueConverter)Activator.CreateInstance(typeof(ReadOnlyMemoryConverter<>).MakeGenericType(elementType))!, - (ValueComparer)Activator.CreateInstance(typeof(ReadOnlyMemoryComparer<>).MakeGenericType(elementType))!, - null, - null, - null); - } + } - return new CosmosVectorTypeMapping(parameters, vectorType); + /// + /// 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. + /// + public CosmosVectorTypeMapping(CosmosTypeMapping mapping, CosmosVectorType vectorType) + : this( + new CoreTypeMappingParameters( + mapping.ClrType, + // This is a hack to allow both arrays and ROM types without different function overloads or type mappings. + converter: mapping.Converter?.GetType() == typeof(BytesToStringConverter) ? null : mapping.Converter, + mapping.Comparer, + mapping.KeyComparer, + elementMapping: mapping.ElementTypeMapping, + jsonValueReaderWriter: mapping.JsonValueReaderWriter), + vectorType) + { } /// @@ -97,13 +96,13 @@ protected CosmosVectorTypeMapping(CoreTypeMappingParameters parameters, CosmosVe /// 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. /// - public override CoreTypeMapping WithComposedConverter( + public override CosmosVectorTypeMapping WithComposedConverter( ValueConverter? converter, ValueComparer? comparer = null, ValueComparer? keyComparer = null, CoreTypeMapping? elementMapping = null, JsonValueReaderWriter? jsonValueReaderWriter = null) - => new CosmosVectorTypeMapping( + => new( Parameters.WithComposedConverter(converter, comparer, keyComparer, elementMapping, jsonValueReaderWriter), VectorType);