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/Query/Internal/CosmosSerializationUtilities.cs b/src/EFCore.Cosmos/Query/Internal/CosmosSerializationUtilities.cs index 82841f88162..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 +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/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/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs index d885e7dd89c..31706f1bf2b 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs @@ -5,7 +5,7 @@ using System.Collections.ObjectModel; using System.Net; using System.Runtime.CompilerServices; -using System.Text; +using System.Runtime.InteropServices; using Microsoft.Azure.Cosmos.Scripts; using Microsoft.EntityFrameworkCore.Cosmos.Diagnostics.Internal; using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal; @@ -47,8 +47,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() { @@ -68,8 +66,7 @@ public CosmosClientWrapper( ISingletonCosmosClientWrapper singletonWrapper, IDbContextOptions dbContextOptions, IExecutionStrategy executionStrategy, - IDiagnosticsLogger commandLogger, - IDiagnosticsLogger databaseLogger) + IDiagnosticsLogger commandLogger) { var options = dbContextOptions.FindExtension(); @@ -77,25 +74,6 @@ 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; } /// @@ -335,25 +313,25 @@ private static string GetPathFromRoot(IReadOnlyEntityType entityType) /// public virtual Task CreateItemAsync( string containerId, - JToken document, + string documentId, + ReadOnlyMemory 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, ReadOnlyMemory 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; 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); @@ -370,6 +348,12 @@ 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( stream, partitionKeyValue, @@ -381,7 +365,7 @@ private static async Task CreateItemOnceAsync( response.Diagnostics.GetClientElapsedTime(), response.Headers.RequestCharge, response.Headers.ActivityId, - parameters.Document["id"]!.ToString(), + documentId, containerId, partitionKeyValue); @@ -399,7 +383,7 @@ private static async Task CreateItemOnceAsync( public virtual Task ReplaceItemAsync( string collectionId, string documentId, - JObject document, + ReadOnlyMemory document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default) @@ -408,17 +392,15 @@ 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, ReadOnlyMemory 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; 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); @@ -435,6 +417,12 @@ 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( stream, parameters.ResourceId, @@ -481,7 +469,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); @@ -539,7 +527,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); } /// @@ -578,9 +566,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 { @@ -590,7 +578,6 @@ private static ItemRequestOptions CreateItemRequestOptions(IUpdateEntry entry, b if (helper != null) { itemRequestOptions.IfMatchEtag = helper.IfMatchEtag; - itemRequestOptions.EnableContentResponseOnWrite = helper.EnableContentResponseOnWrite; } return itemRequestOptions; @@ -681,7 +668,7 @@ private static CosmosTransactionalBatchResult ProcessBatchResponse(string contai var entry = entries[i]; var item = response[i]; - ProcessWriteResponse(entry.Entry, (string)item.ETag, (Stream)item.ResourceStream); + ProcessWriteResponse(entry.Entry, item.ETag, item.ResourceStream); } return CosmosTransactionalBatchResult.Success; @@ -689,23 +676,15 @@ private static CosmosTransactionalBatchResult ProcessBatchResponse(string contai private static void ProcessWriteResponse(IUpdateEntry entry, string eTag, Stream? content) { - var etagProperty = entry.EntityType.GetETagProperty(); - if (etagProperty != null && entry.EntityState != EntityState.Deleted) + if (entry.EntityState == EntityState.Deleted) { - entry.SetStoreGeneratedValue(etagProperty, eTag); + return; } - var jObjectProperty = entry.EntityType.FindProperty(CosmosPartitionKeyInPrimaryKeyConvention.JObjectPropertyName); - if (jObjectProperty is { ValueGenerated: ValueGenerated.OnAddOrUpdate } - && content != null) + var etagProperty = entry.EntityType.GetETagProperty(); + if (etagProperty != 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); + entry.SetStoreGeneratedValue(etagProperty, eTag); } } diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs index 8a6ae3fee28..5bb121ef0af 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 @@ -458,15 +425,14 @@ 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 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; } @@ -483,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(), }; @@ -499,24 +465,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 +519,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 #34567 } return documentSource; @@ -625,7 +593,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/CosmosTimeOnlyTypeMapping.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs new file mode 100644 index 00000000000..77a0a4ffaaf --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeOnlyTypeMapping.cs @@ -0,0 +1,45 @@ +// 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 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 + /// 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) + { + } + + /// + /// 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 new file mode 100644 index 00000000000..86bb1c7844f --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTimeSpanTypeMapping.cs @@ -0,0 +1,45 @@ +// 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 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 + /// 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) + { + } + + /// + /// 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/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 f2f9fead506..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; @@ -22,7 +23,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 +35,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; } /// @@ -75,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, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + 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) { @@ -90,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)); @@ -102,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, _enableContentResponseOnWrite, out var itemRequestOptionsLength); + 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) { @@ -117,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)); @@ -131,7 +143,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 +170,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 +185,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/CosmosTypeMappingSource.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTypeMappingSource.cs index 4464928a038..5cd4eb78f47 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), CosmosTimeOnlyTypeMapping.Default }, + { typeof(TimeSpan), CosmosTimeSpanTypeMapping.Default }, { 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 @@ -48,14 +51,40 @@ public CosmosTypeMappingSource(TypeMappingSourceDependencies dependencies) // 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 - { - CosmosTypeMapping mapping - when property.GetVectorDistanceFunction() is { } distanceFunction + => property.GetVectorDistanceFunction() is { } distanceFunction && property.GetVectorDimensions() is { } dimensions - => new CosmosVectorTypeMapping(mapping, new CosmosVectorType(distanceFunction, dimensions)), - var other => other - }; + ? 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 @@ -82,7 +111,11 @@ 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; + 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))!); @@ -168,10 +201,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 +260,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 +267,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 { @@ -224,7 +282,7 @@ public sealed class PlaceholderJsonStringKeyedDictionaryReaderWriter(J 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 @@ -233,14 +291,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) - => throw new NotImplementedException("JsonValueReaderWriter infrastructure is not supported on Cosmos."); + { + 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("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 + /// 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("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 + /// 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("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 + /// 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..031f73c3d49 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosVectorTypeMapping.cs @@ -96,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); diff --git a/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/ICosmosClientWrapper.cs index 7c401a3c977..a2bef3f6a36 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, + ReadOnlyMemory document, IUpdateEntry updateEntry, ISessionTokenStorage sessionTokenStorage, CancellationToken cancellationToken = default); @@ -59,7 +60,7 @@ Task CreateItemAsync( Task ReplaceItemAsync( string collectionId, string documentId, - JObject 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/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..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; @@ -93,6 +92,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/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs index 2f5f1cfdd6d..0172a1f1c32 100644 --- a/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs +++ b/src/EFCore.Cosmos/Update/Internal/DocumentSource.cs @@ -2,12 +2,12 @@ // 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.Update.Internal; -using Newtonsoft.Json.Linq; namespace Microsoft.EntityFrameworkCore.Cosmos.Update.Internal; @@ -23,10 +23,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 +32,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 +65,165 @@ 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 ReadOnlyMemory 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); + return new ReadOnlyMemory(stream.GetBuffer(), 0, (int)stream.Length); + } - 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?.HandlesNullWrites == 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/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs index dffdabadae9..890ff565da1 100644 --- a/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/ConfigPatternsCosmosTest.cs @@ -187,7 +187,8 @@ 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)); protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance; 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/CosmosConcurrencyTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs index 3a576324a62..f69473226bf 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosConcurrencyTest.cs @@ -5,10 +5,14 @@ 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 ServiceProvider ServiceProvider { get; } = new ServiceCollection() + .AddEntityFrameworkCosmos() + .BuildServiceProvider(); + protected CosmosFixture Fixture { get; } = fixture; [ConditionalFact] @@ -54,14 +58,17 @@ 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) { +#pragma warning disable CS0618 // Type or member is obsolete o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); +#pragma warning restore CS0618 // Type or member is obsolete } - }) + }))) + .UseInternalServiceProvider(ServiceProvider) .Options; var customer = new Customer @@ -114,14 +121,17 @@ 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) { +#pragma warning disable CS0618 // Type or member is obsolete o.ContentResponseOnWriteEnabled(contentResponseOnWriteEnabled.Value); +#pragma warning restore CS0618 // Type or member is obsolete } - }) + }))) + .UseInternalServiceProvider(ServiceProvider) .Options; var customer = new PremiumCustomer @@ -238,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 diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs index 08fc076cb21..9392722784e 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosTransactionalBatchTest.cs @@ -279,41 +279,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) - { - 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', 1044994); - customer2.Name = new string('x', 1044994); - - if (oneByteOver) - { - customer1.Name += 'x'; - } - - await context.SaveChangesAsync(); - using var assertContext = Fixture.CreateContext(); - Assert.Equal(2, (await context.Customers.ToListAsync()).Count); - - if (oneByteOver) - { - Assert.Equal(2, Fixture.ListLoggerFactory.Log.Count(x => x.Id == CosmosEventId.ExecutedTransactionalBatch)); - } - else - { - 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() @@ -331,22 +296,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() { @@ -366,18 +315,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); @@ -385,15 +335,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)); } @@ -403,136 +357,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', 1097582); - customer2.Name = new string('x', 1097583); - - if (oneByteOver) - { - customer1.Name += 'x'; - customer2.Name += 'x'; - 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; - - 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) }; + Fixture.ListLoggerFactory.Clear(); - 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', 1097581 + (1_024 - customer1.Id.Length) * 2); - customer2.Name = new string('x', 1097581 + (1_024 - customer2.Id.Length) * 2); - - if (oneByteOver) - { - customer1.Name += 'x'; - customer2.Name += 'x'; - 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)); } } diff --git a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs index eabe49207ae..a20d4a3a28c 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EmbeddedDocumentsTest.cs @@ -164,7 +164,6 @@ await context.AddAsync( new Person { Id = 3, Addresses = new List
{ existingAddress1Person3, existingAddress2Person3 } }); await context.SaveChangesAsync(); - var people = await context.Set().ToListAsync(); Assert.Empty(people[0].Addresses); @@ -240,12 +239,7 @@ 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); existingAddress1Person3 = people[2].Addresses.First(); existingAddress2Person3 = people[2].Addresses.Last(); @@ -270,7 +264,6 @@ await context.AddAsync( { existingAddress2Person3.Notes.Add(new Note { Content = "City note" }); } - await context.SaveChangesAsync(); await AssertState(context, useIds); @@ -349,11 +342,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 1b35b3c9e4e..4be18117251 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/EndToEndCosmosTest.cs @@ -178,92 +178,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) { @@ -761,6 +675,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( @@ -1612,10 +1545,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/JsonTypesCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/JsonTypesCosmosTest.cs index 2b287e85924..bac2f8c1056 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.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/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", 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)); 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