diff --git a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightPropertySerializer.cs b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightPropertySerializer.cs index f646358201..2e777d143d 100644 --- a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightPropertySerializer.cs +++ b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightPropertySerializer.cs @@ -90,6 +90,7 @@ internal void WriteTopLevelProperty(ODataProperty property) null /*owningType*/, true /* isTopLevel */, false /* allowStreamProperty */, + false /*omitNullValues */, this.CreateDuplicatePropertyNameChecker()); this.JsonLightValueSerializer.AssertRecursionDepthIsZero(); @@ -97,6 +98,26 @@ internal void WriteTopLevelProperty(ODataProperty property) }); } + /// + /// This is the backward-compatible method to write property names and value pairs + /// without omitting null property values. + /// + /// The of the resource (or null if not metadata is available). + /// The enumeration of properties to write out. + /// + /// Whether the properties are being written for complex value. Also used for detecting whether stream properties + /// are allowed as named stream properties should only be defined on ODataResource instances + /// + /// The DuplicatePropertyNameChecker to use. + internal void WriteProperties( + IEdmStructuredType owningType, + IEnumerable properties, + bool isComplexValue, + IDuplicatePropertyNameChecker duplicatePropertyNameChecker) + { + this.WriteProperties(owningType, properties, isComplexValue, /*omitNullValues*/ false, duplicatePropertyNameChecker); + } + /// /// Writes property names and value pairs. /// @@ -106,11 +127,13 @@ internal void WriteTopLevelProperty(ODataProperty property) /// Whether the properties are being written for complex value. Also used for detecting whether stream properties /// are allowed as named stream properties should only be defined on ODataResource instances /// + /// Whether to omit null property values. /// The DuplicatePropertyNameChecker to use. internal void WriteProperties( IEdmStructuredType owningType, IEnumerable properties, bool isComplexValue, + bool omitNullValues, IDuplicatePropertyNameChecker duplicatePropertyNameChecker) { if (properties == null) @@ -125,6 +148,7 @@ internal void WriteProperties( owningType, false /* isTopLevel */, !isComplexValue, + omitNullValues, duplicatePropertyNameChecker); } } @@ -177,6 +201,7 @@ private void WriteProperty( IEdmStructuredType owningType, bool isTopLevel, bool allowStreamProperty, + bool omitNullValues, IDuplicatePropertyNameChecker duplicatePropertyNameChecker) { WriterValidationUtils.ValidatePropertyNotNull(property); @@ -235,7 +260,7 @@ private void WriteProperty( if (value is ODataNullValue || value == null) { - this.WriteNullProperty(property); + this.WriteNullProperty(property, omitNullValues); return; } @@ -363,7 +388,8 @@ private void WriteStreamReferenceProperty(string propertyName, ODataStreamRefere /// /// The property to write out. private void WriteNullProperty( - ODataProperty property) + ODataProperty property, + bool omitNullValues) { this.WriterValidator.ValidateNullPropertyValue( this.currentPropertyInfo.MetadataType.TypeReference, property.Name, this.Model); @@ -373,7 +399,7 @@ private void WriteNullProperty( // TODO: Enable updating top-level properties to null #645 throw new ODataException("A null top-level property is not allowed to be serialized."); } - else if (!this.MessageWriterSettings.IgnoreNullValues) + else if (!omitNullValues) { this.JsonWriter.WriteName(property.Name); this.JsonLightValueSerializer.WriteNullValue(); diff --git a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightReader.cs b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightReader.cs index bdccce1323..7ff95fa04f 100644 --- a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightReader.cs +++ b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightReader.cs @@ -2232,6 +2232,9 @@ private void EndEntry() if (this.State == ODataReaderState.ResourceStart) { + // For non-delta response, need to restore omitted null values as required. + RestoreOmittedNullValues(); + this.EndEntry( new JsonLightResourceScope( ODataReaderState.ResourceEnd, @@ -2256,6 +2259,91 @@ private void EndEntry() } } + /// + /// For response de-serialization, restore the null values for properties that are omitted + /// by the omit-values=nulls preference header. + /// + private void RestoreOmittedNullValues() + { + // Restore omitted null value only when processing response and when + // Preference header omit-values=nulls is specified. + if (this.jsonLightInputContext.ReadingResponse + && this.jsonLightInputContext.MessageReaderSettings.NullValuesOmitted) + { + IODataJsonLightReaderResourceState resourceState = this.CurrentResourceState; + IEdmStructuredType edmStructuredType = resourceState.ResourceType; + + if (resourceState.SelectedProperties == SelectedPropertiesNode.Empty) + { + return; + } + else + { + if (resourceState.Resource != null && edmStructuredType != null) + { + ODataResourceBase resource = resourceState.Resource; + + IEnumerable selectedProperties; + if (resourceState.SelectedProperties == SelectedPropertiesNode.EntireSubtree) + { + selectedProperties = edmStructuredType.DeclaredProperties; + } + else + { // Partial subtree. Combine navigation properties and selected properties at the node with distinct. + selectedProperties = + resourceState.SelectedProperties.GetSelectedNavigationProperties(edmStructuredType) + as IEnumerable; + selectedProperties = selectedProperties.Concat( + resourceState.SelectedProperties.GetSelectedProperties(edmStructuredType).Values) + .Distinct(); + } + + foreach (IEdmProperty currentProperty in selectedProperties) + { + Debug.Assert(currentProperty.Type != null, "currentProperty.Type != null"); + if (!currentProperty.Type.IsNullable) + { + // Skip declared properties that are not null-able types. + continue; + } + + // Response should be generated using case sensitive, matching EDM defined by metadata. + // In order to find potential omitted properties, need to check + // resource properties read and navigation properties read. + if (!resource.Properties.Any( + p => p.Name.Equals(currentProperty.Name, StringComparison.Ordinal)) + && !resourceState.NavigationPropertiesRead.Contains(currentProperty.Name)) + { + // Add null value to omitted property declared in the type + ODataProperty property = new ODataProperty + { + Name = currentProperty.Name, + Value = new ODataNullValue() + }; + + // Mark as processed, will throw if duplicate is detected. + resourceState.PropertyAndAnnotationCollector.MarkPropertyAsProcessed(property.Name); + + ReadOnlyEnumerable readonlyEnumerable = + resource.Properties as ReadOnlyEnumerable; + if (readonlyEnumerable != null) + { + // For non-entity resource, add the property underneath the read-only enumerable. + resource.Properties.ConcatToReadOnlyEnumerable("Properties", property); + } + else + { + // For entity resource, concatenate the property to original enumerable. + resource.Properties = + resource.Properties.Concat(new List() {property}); + } + } + } + } + } + } + } + /// /// Add info resolved from context url to current scope. /// diff --git a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightWriter.cs b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightWriter.cs index ce8fb90a89..fcd654a61c 100644 --- a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightWriter.cs +++ b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightWriter.cs @@ -331,6 +331,7 @@ protected override void StartResource(ODataResource resource) this.ResourceType, resource.Properties, false /* isComplexValue */, + this.ShouldOmitNullValues(), this.DuplicatePropertyNameChecker); this.jsonLightResourceSerializer.JsonLightValueSerializer.AssertRecursionDepthIsZero(); @@ -1226,6 +1227,7 @@ private void WriteDeltaResourceProperties(IEnumerable properties) this.ResourceType, properties, false /* isComplexValue */, + false /* omitNullValues */, this.DuplicatePropertyNameChecker); this.jsonLightResourceSerializer.JsonLightValueSerializer.AssertRecursionDepthIsZero(); } diff --git a/src/Microsoft.OData.Core/Microsoft.OData.Core.cs b/src/Microsoft.OData.Core/Microsoft.OData.Core.cs index 588c149fe9..a0d042ae54 100644 --- a/src/Microsoft.OData.Core/Microsoft.OData.Core.cs +++ b/src/Microsoft.OData.Core/Microsoft.OData.Core.cs @@ -271,6 +271,9 @@ internal sealed class TextRes { internal const string HttpHeaderValueLexer_FailedToReadTokenOrQuotedString = "HttpHeaderValueLexer_FailedToReadTokenOrQuotedString"; internal const string HttpHeaderValueLexer_InvalidSeparatorAfterQuotedString = "HttpHeaderValueLexer_InvalidSeparatorAfterQuotedString"; internal const string HttpHeaderValueLexer_EndOfFileAfterSeparator = "HttpHeaderValueLexer_EndOfFileAfterSeparator"; + + internal const string HttpPreferenceHeader_InvalidValueForToken = "HttpPreferenceHeader_InvalidValueForToken"; + internal const string MediaType_EncodingNotSupported = "MediaType_EncodingNotSupported"; internal const string MediaTypeUtils_DidNotFindMatchingMediaType = "MediaTypeUtils_DidNotFindMatchingMediaType"; internal const string MediaTypeUtils_CannotDetermineFormatFromContentType = "MediaTypeUtils_CannotDetermineFormatFromContentType"; diff --git a/src/Microsoft.OData.Core/Microsoft.OData.Core.txt b/src/Microsoft.OData.Core/Microsoft.OData.Core.txt index 0aad37d499..42efa743e3 100644 --- a/src/Microsoft.OData.Core/Microsoft.OData.Core.txt +++ b/src/Microsoft.OData.Core/Microsoft.OData.Core.txt @@ -241,6 +241,8 @@ HttpHeaderValueLexer_FailedToReadTokenOrQuotedString=An error occurred when pars HttpHeaderValueLexer_InvalidSeparatorAfterQuotedString=An error occurred when parsing the HTTP header '{0}'. The header value '{1}' is incorrect at position '{2}' because '{3}' is not a valid separator after a quoted-string. HttpHeaderValueLexer_EndOfFileAfterSeparator=An error occurred when parsing the HTTP header '{0}'. The header value '{1}' is incorrect at position '{2}' because the header value should not end with the separator '{3}'. +HttpPreferenceHeader_InvalidValueForToken=The value '{0}' is not a valid value for OData token '{1}' in HTTP Prefer or Preference-Applied headers. + MediaType_EncodingNotSupported=The character set '{0}' is not supported. MediaTypeUtils_DidNotFindMatchingMediaType=A supported MIME type could not be found that matches the acceptable MIME types for the request. The supported type(s) '{0}' do not match any of the acceptable MIME types '{1}'. diff --git a/src/Microsoft.OData.Core/ODataConstants.cs b/src/Microsoft.OData.Core/ODataConstants.cs index 8027f37f23..f7927b18df 100644 --- a/src/Microsoft.OData.Core/ODataConstants.cs +++ b/src/Microsoft.OData.Core/ODataConstants.cs @@ -199,6 +199,19 @@ internal static class ODataInternalConstants /// The $deletedLink token indicates delta deleted link. internal const string ContextUriDeletedLink = UriSegmentSeparator + DeletedLink; + #endregion Context URL + + #region Preference Headers + /// + /// Valid value for OmitValuesPreferenceToken indicating nulls are omitted. + /// + internal const string OmitValuesNulls = "nulls"; + + /// + /// Valid value for OmitValuesPreferenceToken indicating defaults are omitted. + /// + internal const string OmitValuesDefaults = "defaults"; + #endregion Preference Headers } } diff --git a/src/Microsoft.OData.Core/ODataMessageReader.cs b/src/Microsoft.OData.Core/ODataMessageReader.cs index ede858649b..d28afb6f32 100644 --- a/src/Microsoft.OData.Core/ODataMessageReader.cs +++ b/src/Microsoft.OData.Core/ODataMessageReader.cs @@ -169,6 +169,17 @@ public ODataMessageReader(IODataResponseMessage responseMessage, ODataMessageRea this.model = model ?? GetModel(this.container); this.edmTypeResolver = new EdmTypeReaderResolver(this.model, this.settings.ClientCustomTypeResolver); + // Whether null values were omitted in the response. + this.settings.NullValuesOmitted = false; + string omitValuePreferenceApplied = responseMessage.PreferenceAppliedHeader().OmitValues; + if (omitValuePreferenceApplied != null) + { + // If the Preference-Applied header's omit-values parameter is present in the response, its value should + // be applied to the reader's setting. + this.settings.NullValuesOmitted = + omitValuePreferenceApplied.Equals(ODataConstants.OmitValuesNulls, StringComparison.OrdinalIgnoreCase); + } + // If the Preference-Applied header on the response message contains an annotation filter, we set the filter // to the reader settings if it's not already set, so that we would only read annotations that satisfy the filter. string annotationFilter = responseMessage.PreferenceAppliedHeader().AnnotationFilter; diff --git a/src/Microsoft.OData.Core/ODataMessageReaderSettings.cs b/src/Microsoft.OData.Core/ODataMessageReaderSettings.cs index 0e59409302..a9e7593945 100644 --- a/src/Microsoft.OData.Core/ODataMessageReaderSettings.cs +++ b/src/Microsoft.OData.Core/ODataMessageReaderSettings.cs @@ -196,6 +196,11 @@ public ODataMessageQuotas MessageQuotas /// internal bool ThrowOnUndeclaredPropertyForNonOpenType { get; private set; } + /// + /// True if null values are omitted, per the omit-values parameter in Preference header of the message. + /// + internal bool NullValuesOmitted { get; set; } + /// /// Creates a shallow copy of this . /// @@ -253,6 +258,7 @@ private void CopyFrom(ODataMessageReaderSettings other) this.MaxProtocolVersion = other.MaxProtocolVersion; this.ReadUntypedAsString = other.ReadUntypedAsString; this.ShouldIncludeAnnotation = other.ShouldIncludeAnnotation; + this.NullValuesOmitted = other.NullValuesOmitted; this.validations = other.validations; this.ThrowOnDuplicatePropertyNames = other.ThrowOnDuplicatePropertyNames; this.ThrowIfTypeConflictsWithMetadata = other.ThrowIfTypeConflictsWithMetadata; diff --git a/src/Microsoft.OData.Core/ODataMessageWriter.cs b/src/Microsoft.OData.Core/ODataMessageWriter.cs index d0a5788699..b13010ba53 100644 --- a/src/Microsoft.OData.Core/ODataMessageWriter.cs +++ b/src/Microsoft.OData.Core/ODataMessageWriter.cs @@ -151,6 +151,14 @@ public ODataMessageWriter(IODataResponseMessage responseMessage, ODataMessageWri WriterValidationUtils.ValidateMessageWriterSettings(this.settings, this.writingResponse); this.message = new ODataResponseMessage(responseMessage, /*writing*/ true, this.settings.EnableMessageStreamDisposal, /*maxMessageSize*/ -1); + // Set the Preference-Applied header's parameter omit-values=nulls per writer settings. + // For response writing, source of specifying omit-null-values preference is the settings object + // from caller(the OData service). + if (this.settings.OmitNullValues) + { + responseMessage.PreferenceAppliedHeader().OmitValues = ODataConstants.OmitValuesNulls; + } + // If the Preference-Applied header on the response message contains an annotation filter, we set the filter // to the writer settings so that we would only write annotations that satisfy the filter. string annotationFilter = responseMessage.PreferenceAppliedHeader().AnnotationFilter; diff --git a/src/Microsoft.OData.Core/ODataMessageWriterSettings.cs b/src/Microsoft.OData.Core/ODataMessageWriterSettings.cs index 51332a093b..8cd056fdf0 100644 --- a/src/Microsoft.OData.Core/ODataMessageWriterSettings.cs +++ b/src/Microsoft.OData.Core/ODataMessageWriterSettings.cs @@ -178,12 +178,12 @@ public ODataUri ODataUri internal bool ThrowOnDuplicatePropertyNames { get; private set; } /// - /// Don't serialize null values + /// Whether to omit null values when writing response. /// /// - /// Default valus is false, that means serialize null values. + /// Default value is false, that means serialize null values. /// - public bool IgnoreNullValues { get; set; } + public bool OmitNullValues { get; set; } /// /// Returns whether ThrowOnUndeclaredPropertyForNonOpenType validation setting is enabled. @@ -406,7 +406,7 @@ private void CopyFrom(ODataMessageWriterSettings other) this.shouldIncludeAnnotation = other.shouldIncludeAnnotation; this.useFormat = other.useFormat; this.Version = other.Version; - this.IgnoreNullValues = other.IgnoreNullValues; + this.OmitNullValues = other.OmitNullValues; this.validations = other.validations; this.ThrowIfTypeConflictsWithMetadata = other.ThrowIfTypeConflictsWithMetadata; diff --git a/src/Microsoft.OData.Core/ODataPreferenceHeader.cs b/src/Microsoft.OData.Core/ODataPreferenceHeader.cs index 82a905504b..4c2aa87948 100644 --- a/src/Microsoft.OData.Core/ODataPreferenceHeader.cs +++ b/src/Microsoft.OData.Core/ODataPreferenceHeader.cs @@ -6,8 +6,10 @@ namespace Microsoft.OData { + using System; using System.Collections.Generic; using System.Diagnostics; + using System.Diagnostics.CodeAnalysis; using System.Globalization; /// @@ -61,6 +63,11 @@ public class ODataPreferenceHeader /// private const string ODataTrackChangesPreferenceToken = "odata.track-changes"; + /// + /// The omit-values preference token. + /// + private const string OmitValuesPreferenceToken = "omit-values"; + /// /// The Prefer header name. /// @@ -243,6 +250,40 @@ public string AnnotationFilter } } + /// + /// Property to get and set the "omit-values" preference to the "Prefer" header on the underlying IODataRequestMessage or + /// the "Preference-Applied" header on the underlying IODataResponseMessage. + /// For the preference or applied preference of omitting null values, use string "nulls". + /// + public string OmitValues + { + get + { + HttpHeaderValueElement omitValues = this.Get(OmitValuesPreferenceToken); + return omitValues?.Value; + } + + [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Need lower case string here.")] + set + { + ExceptionUtils.CheckArgumentStringNotEmpty(value, "OmitValues"); + + string normalizedValue = value.Trim().ToLowerInvariant(); + if (string.Equals(normalizedValue, ODataConstants.OmitValuesNulls, StringComparison.Ordinal)) + { + this.Set(new HttpHeaderValueElement(OmitValuesPreferenceToken, ODataConstants.OmitValuesNulls, EmptyParameters)); + } + else if (string.Equals(normalizedValue, ODataConstants.OmitValuesDefaults, StringComparison.Ordinal)) + { + throw new NotSupportedException("omit-values=defaults is not supported yet."); + } + else + { + throw new ODataException(Strings.HttpPreferenceHeader_InvalidValueForToken(value, OmitValuesPreferenceToken)); + } + } + } + /// /// Property to get and set the "respond-async" preference to the "Prefer" header on the underlying IODataRequestMessage or /// the "Preference-Applied" header on the underlying IODataResponseMessage. diff --git a/src/Microsoft.OData.Core/ODataWriterCore.cs b/src/Microsoft.OData.Core/ODataWriterCore.cs index a5523c3df7..59fa9d85ab 100644 --- a/src/Microsoft.OData.Core/ODataWriterCore.cs +++ b/src/Microsoft.OData.Core/ODataWriterCore.cs @@ -1144,6 +1144,19 @@ protected void ValidateNoDeltaLinkForExpandedResourceSet(ODataResourceSet resour } } + /// + /// Returns whether properties of null values should be omitted when serializing the payload. + /// + /// Return true to omit null-value properties; false otherwise. + protected bool ShouldOmitNullValues() + { + // Omit null values preference should only affect response payload, and should not + // be applied to writing delta payload. + return this.outputContext.WritingResponse + && !this.writingDelta + && this.outputContext.MessageWriterSettings.OmitNullValues; + } + /// /// Verifies that calling WriteStart resourceSet is valid. /// diff --git a/src/Microsoft.OData.Core/Parameterized.Microsoft.OData.Core.cs b/src/Microsoft.OData.Core/Parameterized.Microsoft.OData.Core.cs index 84df52e28c..f09599549f 100644 --- a/src/Microsoft.OData.Core/Parameterized.Microsoft.OData.Core.cs +++ b/src/Microsoft.OData.Core/Parameterized.Microsoft.OData.Core.cs @@ -270,7 +270,7 @@ internal static string ODataWriterCore_CannotWriteTopLevelResourceSetWithResourc return Microsoft.OData.TextRes.GetString(Microsoft.OData.TextRes.ODataWriterCore_CannotWriteTopLevelResourceSetWithResourceWriter); } } - + /// /// A string like "Cannot write a top-level resource with a writer that was created to write a top-level resource set." /// @@ -1639,6 +1639,14 @@ internal static string HttpHeaderValueLexer_EndOfFileAfterSeparator(object p0, o return Microsoft.OData.TextRes.GetString(Microsoft.OData.TextRes.HttpHeaderValueLexer_EndOfFileAfterSeparator, p0, p1, p2, p3); } + /// + /// A string like "The value '{0}' is not a valid value for OData token '{1}' in HTTP Prefer or Preference-Applied headers." + /// + internal static string HttpPreferenceHeader_InvalidValueForToken(object p0, object p1) + { + return Microsoft.OData.TextRes.GetString(Microsoft.OData.TextRes.HttpPreferenceHeader_InvalidValueForToken, p0, p1); + } + /// /// A string like "The character set '{0}' is not supported." /// diff --git a/src/Microsoft.OData.Core/SelectedPropertiesNode.cs b/src/Microsoft.OData.Core/SelectedPropertiesNode.cs index 70c82b29c9..dbc37b072c 100644 --- a/src/Microsoft.OData.Core/SelectedPropertiesNode.cs +++ b/src/Microsoft.OData.Core/SelectedPropertiesNode.cs @@ -38,6 +38,9 @@ internal sealed class SelectedPropertiesNode /// An empty set of navigation properties to return when nothing is selected. private static readonly IEnumerable EmptyNavigationProperties = Enumerable.Empty(); + /// An empty dictionary of EDM properties to return when nothing is selected. + private static readonly Dictionary EmptyEdmProperties = new Dictionary(StringComparer.Ordinal); + /// The type of the current node. private readonly SelectionType selectionType; @@ -253,7 +256,7 @@ internal SelectedPropertiesNode GetSelectedPropertiesForNavigationProperty(IEdmS } // We cannot determine the selected navigation properties without the user model. This means we won't be computing the missing navigation properties. - // For reading we will report what's on the wire and for writing we just write what the user explicitely told us to write. + // For reading we will report what's on the wire and for writing we just write what the user explicitly told us to write. if (structuredType == null) { return EntireSubtree; @@ -299,7 +302,7 @@ internal IEnumerable GetSelectedNavigationProperties(IEd } // We cannot determine the selected navigation properties without the user model. This means we won't be computing the missing navigation properties. - // For reading we will report what's on the wire and for writing we just write what the user explicitely told us to write. + // For reading we will report what's on the wire and for writing we just write what the user explicitly told us to write. if (structuredType == null) { return EmptyNavigationProperties; @@ -335,11 +338,43 @@ internal IEnumerable GetSelectedNavigationProperties(IEd return selectedNavigationProperties.Distinct(); } + /// + /// Gets all selected properties at the current node. + /// + /// The current structured type. + /// The selected properties at this node level. + internal IDictionary GetSelectedProperties(IEdmStructuredType structuredType) + { + if (this.selectionType == SelectionType.Empty) + { + return EmptyEdmProperties; + } + + // We cannot determine the selected properties without the user model. This means we won't be computing the missing properties. + if (structuredType == null) + { + return EmptyEdmProperties; + } + + if (this.selectionType == SelectionType.EntireSubtree || this.hasWildcard) + { + return structuredType.DeclaredProperties.ToDictionary(sp => sp.Name, StringComparer.Ordinal); + } + + Debug.Assert(this.selectedProperties != null, "selectedProperties != null"); + + IDictionary selectedEdmProperties = this.selectedProperties + .Select(structuredType.FindProperty) + .ToDictionary(p => p.Name, StringComparer.Ordinal); + + return selectedEdmProperties; + } + /// /// Gets the selected stream properties for the current node. /// /// The current entity type. - /// The selected stream properties. + /// The selected stream properties at this node level. internal IDictionary GetSelectedStreamProperties(IEdmEntityType entityType) { if (this.selectionType == SelectionType.Empty) diff --git a/test/FunctionalTests/Microsoft.OData.Core.Tests/IntegrationTests/Reader/JsonLight/PropertyAndValueJsonLightReaderIntegrationTests.cs b/test/FunctionalTests/Microsoft.OData.Core.Tests/IntegrationTests/Reader/JsonLight/PropertyAndValueJsonLightReaderIntegrationTests.cs index 3a8714590e..d6f934ff14 100644 --- a/test/FunctionalTests/Microsoft.OData.Core.Tests/IntegrationTests/Reader/JsonLight/PropertyAndValueJsonLightReaderIntegrationTests.cs +++ b/test/FunctionalTests/Microsoft.OData.Core.Tests/IntegrationTests/Reader/JsonLight/PropertyAndValueJsonLightReaderIntegrationTests.cs @@ -20,6 +20,8 @@ namespace Microsoft.OData.Tests.IntegrationTests.Reader.JsonLight { public class PropertyAndValueJsonLightReaderIntegrationTests { + private static string omitValuesNulls = "omit-values=" + ODataConstants.OmitValuesNulls; + [Fact] public void ReadingPayloadInt64SingleDoubleDecimalWithoutSuffix() { @@ -106,7 +108,7 @@ public void ReadingPayloadInt64SingleDoubleDecimalWithoutSuffix() } [Fact] - public void ReadNullableCollectionValue() + public void ReadNullableCollectionValueExpanded() { EdmModel model = new EdmModel(); EdmEntityType entityType = new EdmEntityType("NS", "MyTestEntity"); @@ -128,25 +130,30 @@ public void ReadNullableCollectionValue() "}"; IEdmModel mainModel = TestUtils.WrapReferencedModelsToMainModel("EntityNs", "MyContainer", model); - ODataResource entry = null; - this.ReadEntryPayload(mainModel, payload, entitySet, entityType, reader => { entry = entry ?? reader.Item as ODataResource; }); - Assert.NotNull(entry); - var intCollection = entry.Properties.FirstOrDefault( - s => string.Equals( - s.Name, - "NullableIntNumbers", - StringComparison.OrdinalIgnoreCase)).Value.As(); - var list = new List(); - foreach (var val in intCollection.Items) + foreach (bool isNullValuesOmitted in new bool[] {false, true}) { - list.Add(val as int?); - } + ODataResource entry = null; + this.ReadEntryPayload(mainModel, payload, entitySet, entityType, + reader => { entry = entry ?? reader.Item as ODataResource; }, isNullValuesOmitted); + Assert.NotNull(entry); + + var intCollection = entry.Properties.FirstOrDefault( + s => string.Equals( + s.Name, + "NullableIntNumbers", + StringComparison.OrdinalIgnoreCase)).Value.As(); + var list = new List(); + foreach (var val in intCollection.Items) + { + list.Add(val as int?); + } - Assert.Equal(0, list[0]); - Assert.Null(list[1]); - Assert.Equal(1, (int)list[2]); - Assert.Equal(2, (int)list[3]); + Assert.Equal(0, list[0]); + Assert.Null(list[1]); + Assert.Equal(1, (int) list[2]); + Assert.Equal(2, (int) list[3]); + } } [Fact] @@ -176,53 +183,60 @@ public void ReadOpenCollectionPropertyValue() "}"; IEdmModel mainModel = TestUtils.WrapReferencedModelsToMainModel("EntityNs", "MyContainer", model); - ODataResource entry = null; - List complexCollection = new List(); - ODataNestedResourceInfo nestedOpenCollection = null; - bool startComplexCollection = false; - this.ReadEntryPayload(mainModel, payload, entitySet, entityType, - reader => - { - switch (reader.State) - { - case ODataReaderState.NestedResourceInfoStart: - startComplexCollection = true; - break; - case ODataReaderState.ResourceEnd: - if(startComplexCollection) - { - complexCollection.Add(reader.Item as ODataResource); - } - entry = reader.Item as ODataResource; - break; - case ODataReaderState.NestedResourceInfoEnd: - startComplexCollection = false; - nestedOpenCollection = reader.Item as ODataNestedResourceInfo; - break; - } - }); - Assert.NotNull(entry); - var intCollection = entry.Properties.FirstOrDefault( - s => string.Equals( - s.Name, - "OpenPrimitiveCollection", - StringComparison.OrdinalIgnoreCase)).Value.As(); - var list = new List(); - foreach (var val in intCollection.Items) + foreach (bool isNullValuesOmitted in new bool[] {false, true}) { - list.Add(val as int?); - } + ODataResource entry = null; + List complexCollection = new List(); + ODataNestedResourceInfo nestedOpenCollection = null; + bool startComplexCollection = false; + this.ReadEntryPayload(mainModel, payload, entitySet, entityType, + reader => + { + switch (reader.State) + { + case ODataReaderState.NestedResourceInfoStart: + startComplexCollection = true; + break; + case ODataReaderState.ResourceEnd: + if (startComplexCollection) + { + complexCollection.Add(reader.Item as ODataResource); + } + entry = reader.Item as ODataResource; + break; + case ODataReaderState.NestedResourceInfoEnd: + startComplexCollection = false; + nestedOpenCollection = reader.Item as ODataNestedResourceInfo; + break; + } + }, + nullValuesOmitted: isNullValuesOmitted); + Assert.NotNull(entry); + + var intCollection = entry.Properties.FirstOrDefault( + s => string.Equals( + s.Name, + "OpenPrimitiveCollection", + StringComparison.OrdinalIgnoreCase)).Value.As(); + var list = new List(); + foreach (var val in intCollection.Items) + { + list.Add(val as int?); + } - Assert.Equal(0, list[0]); - Assert.Equal(1, (int)list[1]); - Assert.Equal(2, (int)list[2]); + Assert.Equal(0, list[0]); + Assert.Equal(1, (int) list[1]); + Assert.Equal(2, (int) list[2]); - Assert.Equal("OpenComplexTypeCollection", nestedOpenCollection.Name); + Assert.Equal("OpenComplexTypeCollection", nestedOpenCollection.Name); - foreach (var val in complexCollection) - { - val.Properties.FirstOrDefault(s => string.Equals(s.Name, "CLongId", StringComparison.OrdinalIgnoreCase)).Value.ShouldBeEquivalentTo(1L, "value should be in correct type."); + foreach (var val in complexCollection) + { + val.Properties.FirstOrDefault( + s => string.Equals(s.Name, "CLongId", StringComparison.OrdinalIgnoreCase)) + .Value.ShouldBeEquivalentTo(1L, "value should be in correct type."); + } } } @@ -353,6 +367,64 @@ public void ReadingTypeDefinitionPayloadJsonLight() address.Properties.FirstOrDefault(s => string.Equals(s.Name, "CountryRegion", StringComparison.OrdinalIgnoreCase)).Value.Should().Be("China"); } + [Fact] + public void ReadingTypeDefinitionPayloadJsonLightWithOmittedNullValues() + { + EdmEntityType entityType; + EdmEntitySet entitySet; + EdmModel model = BuildEdmModelForOmittedNullValuesTestCases(out entityType, out entitySet); + + const string payload = @" +{ + ""@odata.context"":""http://www.example.com/$metadata#EntityNs.MyContainer.People/$entity"", + ""@odata.id"":""http://mytest"", + ""Id"":0, + ""Weight"":60.5, + ""Address"":{""CountryRegion"":""US"", ""City"":""Redmond""} +}"; + + List entries = new List(); + ODataNestedResourceInfo navigationLink = null; + this.ReadEntryPayload(model, payload, entitySet, entityType, + reader => + { + switch (reader.State) + { + case ODataReaderState.ResourceStart: + entries.Add(reader.Item as ODataResource); + break; + case ODataReaderState.NestedResourceInfoStart: + navigationLink = (ODataNestedResourceInfo)reader.Item; + break; + default: + break; + } + }, + nullValuesOmitted: true); + + Assert.Equal(2, entries.Count); + + ODataResource person = entries[0]; + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)) + .Value.Should().Be(0); + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Weight", StringComparison.Ordinal)) + .Value.Should().Be(60.5); + // omitted value should be restored to null. + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Education", StringComparison.Ordinal)) + .Value.Should().BeNull(); + + ODataResource address = entries[1]; + address.Properties.FirstOrDefault(s => string.Equals(s.Name, "CountryRegion", StringComparison.Ordinal)) + .Value.Should().Be("US"); + address.Properties.FirstOrDefault(s => string.Equals(s.Name, "City", StringComparison.Ordinal)) + .Value.Should().Be("Redmond"); + // Omitted value should be restored to null. + address.Properties.FirstOrDefault(s => string.Equals(s.Name, "ZipCode", StringComparison.Ordinal)) + .Value.Should().BeNull(); + + navigationLink.Name.Should().Be("Address"); + } + [Fact] public void ReadingTypeDefinitionPayloadWithTypeAnnotationJsonLight() { @@ -773,6 +845,246 @@ public void ReadingNullValueForDeclaredCollectionPropertyInComplexTypeShouldFail read.ShouldThrow().WithMessage("A null value was found for the property named 'CountriesOrRegions', which has the expected type 'Collection(Edm.String)[Nullable=False]'. The expected type 'Collection(Edm.String)[Nullable=False]' does not allow null values."); } + [Fact] + public void ReadingPropertyInTopLevelOrInComplexTypeShouldRestoreOmittedNullValues() + { + EdmEntityType entityType; + EdmEntitySet entitySet; + EdmModel model = BuildEdmModelForOmittedNullValuesTestCases(out entityType, out entitySet); + + const string payload =@"{ + ""@odata.context"":""http://www.example.com/$metadata#EntityNs.MyContainer.People/$entity"", + ""@odata.id"":""http://mytest"", + ""Id"":0, + ""Education"":{""Id"":1} + }"; + + List entries = new List(); + this.ReadEntryPayload(model, payload, entitySet, entityType, + reader => + { + switch (reader.State) + { + case ODataReaderState.ResourceStart: + entries.Add(reader.Item as ODataResource); + break; + default: + break; + } + }, + nullValuesOmitted: true); + + Assert.Equal(2, entries.Count); + + ODataResource edu = entries.FirstOrDefault(s => string.Equals(s.TypeName, "NS.Edu", StringComparison.Ordinal)); + edu.Should().NotBeNull(); + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)).Value.Should().Be(1); + // null-able collection should be restored. + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "Campuses", StringComparison.Ordinal)).Value.Should().BeNull(); + // null-able property value should be restored. + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "SchoolName", StringComparison.Ordinal)).Value.Should().BeNull(); + + ODataResource person = entries.FirstOrDefault(s => string.Equals(s.TypeName, "NS.Person", StringComparison.Ordinal)); + person.Should().NotBeNull(); + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)).Value.Should().Be(0); + // omitted null-able property should be restored as null. + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Height", StringComparison.Ordinal)).Value.Should().BeNull(); + // missing non-null-able property should not be restored. + person.Properties.Any(s => string.Equals(s.Name, "Weight", StringComparison.Ordinal)).Should().BeFalse(); + } + + [Fact] + public void ReadingPropertyInTopLevelOrInComplexTypeShouldRestoreOmittedNullValuesWithSelectedPropertiesEntireSubTree() + { + EdmEntityType entityType; + EdmEntitySet entitySet; + EdmModel model = BuildEdmModelForOmittedNullValuesTestCases(out entityType, out entitySet); + + // null-able property Height is not selected, thus should not be restored. + // null-able property Address is selected, thus should be restored. + // Property Education is null-able. + const string payloadWithQueryOption = @"{ + ""@odata.context"":""http://www.example.com/$metadata#EntityNs.MyContainer.People(Id,Education,Address)/$entity"", + ""@odata.id"":""http://mytest"", + ""Id"":0, + ""Education"":{""Id"":1} + }"; + const string payloadWithWildcardInQueryOption = @"{ + ""@odata.context"":""http://www.example.com/$metadata#EntityNs.MyContainer.People(Id,Education/*,Address)/$entity"", + ""@odata.id"":""http://mytest"", + ""Id"":0, + ""Education"":{""Id"":1} + }"; + + foreach (string payload in new string[] {payloadWithQueryOption, payloadWithWildcardInQueryOption}) + { + List entries = new List(); + List navigationLinks = new List(); + this.ReadEntryPayload(model, payload, entitySet, entityType, + reader => + { + switch (reader.State) + { + case ODataReaderState.ResourceStart: + entries.Add(reader.Item as ODataResource); + break; + case ODataReaderState.NestedResourceInfoStart: + navigationLinks.Add(reader.Item as ODataNestedResourceInfo); + break; + default: + break; + } + }, + nullValuesOmitted: true); + + entries.Count.Should().Be(2); + navigationLinks.Count.Should().Be(1); + navigationLinks.FirstOrDefault(s => string.Equals(s.Name, "Education", StringComparison.Ordinal)) + .Should().NotBeNull(); + + // Education + ODataResource edu = + entries.FirstOrDefault(s => string.Equals(s.TypeName, "NS.Edu", StringComparison.Ordinal)); + edu.Should().NotBeNull(); + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)) + .Value.Should().Be(1); + // null-able collection should be restored. + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "Campuses", StringComparison.Ordinal)) + .Value.Should().BeNull(); + // null-able property value should be restored. + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "SchoolName", StringComparison.Ordinal)) + .Value.Should().BeNull(); + + // Person + ODataResource person = + entries.FirstOrDefault(s => string.Equals(s.TypeName, "NS.Person", StringComparison.Ordinal)); + person.Should().NotBeNull(); + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)) + .Value.Should().Be(0); + // selected null-able property/navigationLink should be restored as null. + person.Properties.FirstOrDefault(s => string.Equals(s.Name, "Address", StringComparison.Ordinal)) + .Value.Should().BeNull(); + // null-able but not selected properties and not-null-able properties should not be restored. + person.Properties.Any(s => string.Equals(s.Name, "Height", StringComparison.Ordinal)).Should().BeFalse(); + person.Properties.Any(s => string.Equals(s.Name, "Weight", StringComparison.Ordinal)).Should().BeFalse(); + } + } + + [Fact] + public void ReadingPropertyInTopLevelOrInComplexTypeShouldRestoreOmittedNullValuesWithSelectedPropertiesPartialSubTree() + { + EdmEntityType entityType; + EdmEntitySet entitySet; + EdmModel model = BuildEdmModelForOmittedNullValuesTestCases(out entityType, out entitySet); + + // null-able property Height is not selected, thus should not be restored. + // null-able property Address is selected, thus should be restored. + // Property Education is null-able. + const string payloadWithWildcardInQueryOption = @"{ + ""@odata.context"":""http://www.example.com/$metadata#EntityNs.MyContainer.People(Id,%20Education/Id,%20Education/SchoolName,%20Address)/$entity"", + ""@odata.id"":""http://mytest"", + ""Id"":0, + ""Education"":{""Id"":1} + }"; + + foreach (string payload in new string[] { payloadWithWildcardInQueryOption }) + { + List entries = new List(); + List navigationLinks = new List(); + this.ReadEntryPayload(model, payload, entitySet, entityType, + reader => + { + switch (reader.State) + { + case ODataReaderState.ResourceStart: + entries.Add(reader.Item as ODataResource); + break; + case ODataReaderState.NestedResourceInfoStart: + navigationLinks.Add(reader.Item as ODataNestedResourceInfo); + break; + default: + break; + } + }, + nullValuesOmitted: true); + + entries.Count.Should().Be(2); + navigationLinks.Count.Should().Be(1); + navigationLinks.FirstOrDefault(s => string.Equals(s.Name, "Education", StringComparison.Ordinal)) + .Should().NotBeNull(); + + // Education + ODataResource edu = + entries.FirstOrDefault(s => string.Equals(s.TypeName, "NS.Edu", StringComparison.Ordinal)); + edu.Should().NotBeNull(); + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)) + .Value.Should().Be(1); + // null-able property value should be restored. + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "SchoolName", StringComparison.Ordinal)) + .Value.Should().BeNull(); + // not selected property should not be restored. + edu.Properties.Any(s => string.Equals(s.Name, "Campuses", StringComparison.Ordinal)).Should().BeFalse(); + } + } + + [Fact] + public void ReadingPropertyInTopLevelOrInComplexTypeShouldRestoreOmittedNullValuesWithSelectedPropertiesPartialSubTreeAndBaseTypeProperty() + { + EdmEntityType entityType; + EdmEntitySet entitySet; + EdmModel model = BuildEdmModelForOmittedNullValuesTestCases(out entityType, out entitySet); + + // null-able property Height is not selected, thus should not be restored. + // null-able property Address is selected, thus should be restored. + // Property Education is null-able. + const string payloadWithWildcardInQueryOption = @"{ + ""@odata.context"":""http://www.example.com/$metadata#EntityNs.MyContainer.People(Id,%20Education/Id,%20Education/OrgId,%20Address)/$entity"", + ""@odata.id"":""http://mytest"", + ""Id"":0, + ""Education"":{""Id"":1} + }"; + + foreach (string payload in new string[] { payloadWithWildcardInQueryOption }) + { + List entries = new List(); + List navigationLinks = new List(); + this.ReadEntryPayload(model, payload, entitySet, entityType, + reader => + { + switch (reader.State) + { + case ODataReaderState.ResourceStart: + entries.Add(reader.Item as ODataResource); + break; + case ODataReaderState.NestedResourceInfoStart: + navigationLinks.Add(reader.Item as ODataNestedResourceInfo); + break; + default: + break; + } + }, + nullValuesOmitted: true); + + entries.Count.Should().Be(2); + navigationLinks.Count.Should().Be(1); + navigationLinks.FirstOrDefault(s => string.Equals(s.Name, "Education", StringComparison.Ordinal)) + .Should().NotBeNull(); + + // Education + ODataResource edu = + entries.FirstOrDefault(s => string.Equals(s.TypeName, "NS.Edu", StringComparison.Ordinal)); + edu.Should().NotBeNull(); + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "Id", StringComparison.Ordinal)) + .Value.Should().Be(1); + // selected null-able property from base type should be restored to null if omitted. + edu.Properties.FirstOrDefault(s => string.Equals(s.Name, "OrgId", StringComparison.Ordinal)) + .Value.Should().BeNull(); + // not selected property should not be restored. + edu.Properties.Any(s => string.Equals(s.Name, "SchoolName", StringComparison.Ordinal)).Should().BeFalse(); + edu.Properties.Any(s => string.Equals(s.Name, "Campuses", StringComparison.Ordinal)).Should().BeFalse(); + } + } + [Fact] public void ReadingNullValueForDynamicCollectionPropertyInOpenStructuralTypeShouldWork() { @@ -840,13 +1152,23 @@ public void ReadDateTimeOffsetWithCustomFormat() birthday.Value.Should().Be(new DateTimeOffset(2012, 4, 12, 18, 43, 10, TimeSpan.Zero)); } - private void ReadEntryPayload(IEdmModel userModel, string payload, EdmEntitySet entitySet, IEdmEntityType entityType, Action action, bool isIeee754Compatible = true, IServiceProvider container = null) + private void ReadEntryPayload(IEdmModel userModel, string payload, EdmEntitySet entitySet, IEdmEntityType entityType, + Action action, + bool isIeee754Compatible = true, + IServiceProvider container = null, + bool nullValuesOmitted = false) { var message = new InMemoryMessage() { Stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)), Container = container}; string contentType = isIeee754Compatible ? "application/json;odata.metadata=minimal;IEEE754Compatible=true" : "application/json;odata.metadata=minimal;IEEE754Compatible=false"; message.SetHeader("Content-Type", contentType); + + if (nullValuesOmitted) + { + message.SetHeader("Preference-Applied", omitValuesNulls); + } + var readerSettings = new ODataMessageReaderSettings { EnableMessageStreamDisposal = true }; readerSettings.Validations &= ~ValidationKinds.ThrowOnUndeclaredPropertyForNonOpenType; using (var msgReader = new ODataMessageReader((IODataResponseMessage)message, readerSettings, userModel)) @@ -858,5 +1180,66 @@ private void ReadEntryPayload(IEdmModel userModel, string payload, EdmEntitySet } } } + + private EdmModel BuildEdmModelForOmittedNullValuesTestCases(out EdmEntityType entityType, out EdmEntitySet entitySet) + { + EdmModel model = new EdmModel(); + + entityType = new EdmEntityType("NS", "Person"); + entityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + + EdmTypeDefinition weightType = new EdmTypeDefinition("NS", "Wgt", EdmPrimitiveTypeKind.Double); + EdmTypeDefinitionReference weightTypeRef = new EdmTypeDefinitionReference(weightType, false); + entityType.AddStructuralProperty("Weight", weightTypeRef); + + EdmTypeDefinition heightType = new EdmTypeDefinition("NS", "Ht", EdmPrimitiveTypeKind.Double); + EdmTypeDefinitionReference heightTypeRef = new EdmTypeDefinitionReference(heightType, true); + entityType.AddStructuralProperty("Height", heightTypeRef); + + EdmComplexType addressType = new EdmComplexType("NS", "OpnAddr"); + EdmComplexTypeReference addressTypeRef = new EdmComplexTypeReference(addressType, true); + + EdmTypeDefinition countryRegionType = new EdmTypeDefinition("NS", "CR", EdmPrimitiveTypeKind.String); + EdmTypeDefinitionReference countryRegionTypeRef = new EdmTypeDefinitionReference(countryRegionType, false); + addressType.AddStructuralProperty("CountryRegion", countryRegionTypeRef); + + // Address/City + EdmTypeDefinition cityType = new EdmTypeDefinition("NS", "Cty", EdmPrimitiveTypeKind.String); + EdmTypeDefinitionReference cityTypeRef = new EdmTypeDefinitionReference(cityType, false); + addressType.AddStructuralProperty("City", cityTypeRef); + + EdmTypeDefinition zipCodeType = new EdmTypeDefinition("NS", "ZC", EdmPrimitiveTypeKind.String); + EdmTypeDefinitionReference zipCodeTypeRef = new EdmTypeDefinitionReference(zipCodeType, true); + addressType.AddStructuralProperty("ZipCode", zipCodeTypeRef); + + entityType.AddStructuralProperty("Address", addressTypeRef); + + // Education + EdmComplexType organizationType = new EdmComplexType("NS", "Org"); + organizationType.AddStructuralProperty("OrgId", EdmPrimitiveTypeKind.Int32); + organizationType.AddStructuralProperty("OrgCategory", EdmPrimitiveTypeKind.String); + + EdmComplexType educationType = new EdmComplexType("NS", "Edu", organizationType); + EdmComplexTypeReference educationTypeRef = new EdmComplexTypeReference(educationType, true); + + // top level null-able complex type properties + educationType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); // same property name but inside different type. + educationType.AddStructuralProperty("Campuses", + new EdmCollectionTypeReference(new EdmCollectionType(EdmCoreModel.Instance.GetString(true)))); + educationType.AddStructuralProperty("SchoolName", EdmPrimitiveTypeKind.String); + + entityType.AddStructuralProperty("Education", educationTypeRef); + + model.AddElement(weightType); + model.AddElement(heightType); + model.AddElement(addressType); + model.AddElement(entityType); + + EdmEntityContainer container = new EdmEntityContainer("EntityNs", "MyContainer"); + entitySet = container.AddEntitySet("People", entityType); + model.AddElement(container); + + return model; + } } } diff --git a/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightDeltaWriterTests.cs b/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightDeltaWriterTests.cs index cc07bbf4fb..2ec094e928 100644 --- a/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightDeltaWriterTests.cs +++ b/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightDeltaWriterTests.cs @@ -60,6 +60,22 @@ public class ODataJsonLightDeltaWriterTests } }; + private readonly ODataResource customerUpdatedWithNullValues = new ODataResource + { + Id = new Uri("Customers('BOTTM')", UriKind.Relative), + Properties = new List + { + new ODataProperty { Name = "ContactName", Value = null }, + }, + TypeName = "MyNS.Customer", + SerializationInfo = new ODataResourceSerializationInfo + { + NavigationSourceEntityTypeName = "MyNS.Customer", + NavigationSourceKind = EdmNavigationSourceKind.EntitySet, + NavigationSourceName = "Customers" + } + }; + private readonly ODataDeltaDeletedLink linkToOrder10643 = new ODataDeltaDeletedLink(new Uri("Customers('ALFKI')", UriKind.Relative), new Uri("Orders('10643')", UriKind.Relative), "Orders"); private readonly ODataDeltaLink linkToOrder10645 = new ODataDeltaLink(new Uri("Customers('BOTTM')", UriKind.Relative), new Uri("Orders('10645')", UriKind.Relative), "Orders"); @@ -1358,7 +1374,7 @@ public void WriteNestedDeletedEntryInDeletedEntry() new ODataDeletedResource() { Reason = DeltaDeletedEntryReason.Deleted, - Properties = new ODataProperty[] + Properties = new ODataProperty[] { new ODataProperty() { Name = "Name", Value = "Scissors" }, new ODataProperty() { Name = "Id", Value = 1 } @@ -2033,7 +2049,7 @@ public void WriteTopLevelDeletedEntityFromDifferentSet() this.TestPayload().Should().Be("{\"@context\":\"http://host/service/$metadata#Customers/$delta\",\"@count\":5,\"@deltaLink\":\"Customers?$expand=Orders&$deltatoken=8015\",\"value\":[{\"@id\":\"Customers('BOTTM')\",\"ContactName\":\"Susan Halvenstern\"},{\"@context\":\"http://host/service/$metadata#Orders/$deletedEntity\",\"@removed\":{\"reason\":\"deleted\"},\"@id\":\"Orders(10643)\"}]}"); } - + [Fact] public void WriteTopLevelDeletedEntityFromDifferentSetWithoutInfo() { @@ -2051,6 +2067,155 @@ public void WriteTopLevelDeletedEntityFromDifferentSetWithoutInfo() this.TestPayload().Should().Be("{\"@context\":\"http://host/service/$metadata#Customers/$delta\",\"@count\":5,\"@deltaLink\":\"Customers?$expand=Orders&$deltatoken=8015\",\"value\":[{\"@id\":\"Customers('BOTTM')\",\"ContactName\":\"Susan Halvenstern\"},{\"@context\":\"http://host/service/$metadata#Orders/$deletedEntity\",\"@removed\":{\"reason\":\"deleted\"},\"@id\":\"Orders(10643)\"}]}"); } + /// + /// When using regular OData writer with required writingDelta = true to write + /// containing , property value of null + /// should always be written in response (regardless of omitNullValues preference setting). + /// + [Fact] + public void WriteTopLevelEntityWithNullPropertyValues() + { + this.TestInit(this.GetModel()); + + + foreach (bool omitNullValues in new bool[] {true, false}) + { + using (this.stream = new MemoryStream()) + { + // Create resource set writer with specified omitNullValues setting + ODataJsonLightWriter writer = new ODataJsonLightWriter( + CreateJsonLightOutputContext(this.stream, this.GetModel(), omitNullValues, false, null, ODataVersion.V401), + this.GetCustomers(), + this.GetCustomerType(), + true, + false, + /*writingDelta*/ true); + writer.WriteStart(feedWithoutInfo); + writer.WriteStart(customerUpdatedWithNullValues); + writer.WriteEnd(); // customer + writer.WriteEnd(); // delta resource set + writer.Flush(); + + this.TestPayload().Should().Be( + "{\"@context\":\"http://host/service/$metadata#Customers/$delta\",\"@count\":5,\"@deltaLink\":\"Customers?$expand=Orders&$deltatoken=8015\",\"value\":[{\"@id\":\"Customers('BOTTM')\",\"ContactName\":null}]}"); + } + } + } + + /// + /// When using with required writingDelta = true to write + /// containing , property value of null + /// should always be written in response (regardless of omitNullValues preference setting). + /// + [Fact] + public void WriteEntityWithNullPropertyValueInDeltaFeed() + { + this.TestInit(this.GetModel()); + + ODataDeltaResourceSet feed = new ODataDeltaResourceSet(); + + ODataResource orderEntry = new ODataResource() + { + SerializationInfo = new ODataResourceSerializationInfo + { + NavigationSourceEntityTypeName = "MyNS.Order", + NavigationSourceKind = EdmNavigationSourceKind.EntitySet, + NavigationSourceName = "Orders" + }, + Properties = new ODataProperty[] + { + new ODataProperty() { Name = "Id", Value = 1 } + }, + }; + + ODataNestedResourceInfo shippingAddressInfo = new ODataNestedResourceInfo + { + Name = "ShippingAddress", + IsCollection = false + }; + + ODataResource shippingAddress = new ODataResource + { + Properties = new List + { + new ODataProperty { Name = "City", Value = "Shanghai" }, + new ODataProperty { Name = "PostalCode", Value = null } + } + }; + + var result = new ODataQueryOptionParser(this.GetModel(), this.GetCustomerType(), this.GetCustomers(), new Dictionary { { "$expand", "Orders($select=ShippingAddress)" }}).ParseSelectAndExpand(); + + ODataUri odataUri = new ODataUri() + { + ServiceRoot = new Uri("http://host/service"), + SelectAndExpand = result + }; + + foreach (bool omitNullValues in new bool[] {true, false}) + { + using (this.stream = new MemoryStream()) + { + var outputContext = CreateJsonLightOutputContext(this.stream, this.GetModel(), omitNullValues, + false, odataUri, ODataVersion.V401); + ODataJsonLightDeltaWriter writer = new ODataJsonLightDeltaWriter(outputContext, this.GetProducts(), + this.GetProductType()); + writer.WriteStart(feed); + writer.WriteStart(orderEntry); + writer.WriteStart(shippingAddressInfo); + writer.WriteStart(shippingAddress); + writer.WriteEnd(); // shippingAddress + writer.WriteEnd(); // shippingAddressInfo + writer.WriteEnd(); // Order + writer.WriteEnd(); // Feed + writer.Flush(); + + this.TestPayload().Should().Be( + "{\"@context\":\"http://host/service/$metadata#Products(Orders(ShippingAddress))/$delta\",\"value\":[{\"@context\":\"http://host/service/$metadata#Orders/$entity\",\"Id\":1,\"ShippingAddress\":{\"City\":\"Shanghai\",\"PostalCode\":null}}]}"); + } + } + } + + /// + /// When using regular OData writer with required writingDelta = true to write , + /// property value of null should always be written in response (regardless of omitNullValues preference setting). + /// + [Fact] + public void WriteDeletedResourceWithPropertyValueNull() + { + this.TestInit(this.GetModel()); + ODataDeletedResource deletedEntity = new ODataDeletedResource() + { + Reason = DeltaDeletedEntryReason.Changed, + TypeName = "MyNS.PhysicalProduct", + Properties = new[] + { + new ODataProperty {Name = "Id", Value = new ODataPrimitiveValue(1)}, + new ODataProperty {Name = "Name", Value = new ODataPrimitiveValue("car")}, + new ODataProperty {Name = "Material", Value = null} + } + }; + + foreach (bool omitNullValues in new bool[] {true, false}) + { + using (this.stream = new MemoryStream()) + { + ODataDeltaResourceSet feed = new ODataDeltaResourceSet(); + ODataJsonLightWriter writer = new ODataJsonLightWriter( + CreateJsonLightOutputContext(this.stream, this.GetModel(), omitNullValues, false, null, + ODataVersion.V401), + this.GetProducts(), this.GetProductType(), true, false, /*writingDelta*/true); + writer.WriteStart(feed); + writer.WriteStart(deletedEntity); + writer.WriteEnd(); + writer.WriteEnd(); + writer.Flush(); + + this.TestPayload().Should().Be( + "{\"@context\":\"http://host/service/$metadata#Products/$delta\",\"value\":[{\"@removed\":{\"reason\":\"changed\"},\"@type\":\"#MyNS.PhysicalProduct\",\"Id\":1,\"Name\":\"car\",\"Material\":null}]}"); + } + } + } + [Fact] public void WriteEntityFromDifferentSetToEntitySetShouldFail() { @@ -2425,9 +2590,20 @@ private string TestPayload() return (new StreamReader(stream)).ReadToEnd(); } - private static ODataJsonLightOutputContext CreateJsonLightOutputContext(MemoryStream stream, IEdmModel userModel, bool fullMetadata = false, ODataUri uri = null, ODataVersion version = ODataVersion.V4) + private static ODataJsonLightOutputContext CreateJsonLightOutputContext(MemoryStream stream, IEdmModel userModel, + bool fullMetadata = false, ODataUri uri = null, ODataVersion version = ODataVersion.V4) { - var settings = new ODataMessageWriterSettings { Version = version, ShouldIncludeAnnotation = ODataUtils.CreateAnnotationFilter("*") }; + return CreateJsonLightOutputContext(stream, userModel, /*omitNullValues*/ false, fullMetadata, uri, version); + } + + private static ODataJsonLightOutputContext CreateJsonLightOutputContext(MemoryStream stream, IEdmModel userModel, bool omitNullValues, bool fullMetadata = false, ODataUri uri = null, ODataVersion version = ODataVersion.V4) + { + var settings = new ODataMessageWriterSettings + { + Version = version, + ShouldIncludeAnnotation = ODataUtils.CreateAnnotationFilter("*"), + OmitNullValues = omitNullValues + }; settings.SetServiceDocumentUri(new Uri("http://host/service")); if (uri != null) { diff --git a/test/FunctionalTests/Microsoft.OData.Core.Tests/SelectedPropertiesNodeTests.cs b/test/FunctionalTests/Microsoft.OData.Core.Tests/SelectedPropertiesNodeTests.cs index edb60729a0..74cd149ff1 100644 --- a/test/FunctionalTests/Microsoft.OData.Core.Tests/SelectedPropertiesNodeTests.cs +++ b/test/FunctionalTests/Microsoft.OData.Core.Tests/SelectedPropertiesNodeTests.cs @@ -113,6 +113,15 @@ public void WildcardShouldSelectAllPropertiesParsingTest() SelectedPropertiesNode.Create("*").Should().HaveStreams(this.cityType, "Photo").And.HaveNavigations(this.cityType, "Districts").And.HaveChild(this.cityType, "Districts", c => c.Should().BeSameAsEmpty()); } + [Fact] + public void WildcardShouldSelectAllProperties() + { + SelectedPropertiesNode.Create("*").Should() + .HaveProperties(this.cityType, "Photo", "Districts") + .And.HaveNavigations(this.cityType, "Districts") + .And.HaveChild(this.cityType, "Districts", c => c.Should().BeSameAsEmpty()); + } + [Fact] public void SingleStreamPropertyWithNormalProperty() { @@ -141,6 +150,12 @@ public void SpecifyingTheSameNavigationTwiceShouldNotCauseDuplicates() public void SpecifyingAWildCardShouldNotCauseDuplicates() { SelectedPropertiesNode.Create("Districts,*,Photo").Should().HaveStreams(this.cityType, "Photo").And.HaveNavigations(this.cityType, "Districts").And.HaveChild(this.cityType, "Districts", c => c.Should().BeSameAsEntireSubtree()); + + SelectedPropertiesNode.Create("Districts,*,Photo").Should() + .HaveStreams(this.cityType, "Photo") + .And.HaveProperties(this.cityType, "Photo", "Districts") + .And.HaveNavigations(this.cityType, "Districts") + .And.HaveChild(this.cityType, "Districts", c => c.Should().BeSameAsEntireSubtree()); } [Fact] @@ -166,6 +181,7 @@ public void DeepAndWideSelection1() // 2) 'Districts/*' should not override 'Districts' SelectedPropertiesNode.Create("*,Districts,Districts/*").Should() .HaveStreams(this.cityType, "Photo") + .And.HaveProperties(this.cityType, "Photo", "Districts") .And.HaveNavigations(this.cityType, "Districts") .And.HaveChild(this.cityType, "Districts", c => c.Should().BeSameAsEntireSubtree()); } @@ -180,6 +196,7 @@ public void DeepAndWideSelection2() "Districts", c => c.Should() .HaveStreams(this.districtType, "Thumbnail") + .And.HaveProperties(this.districtType, "Id", "Zip", "Thumbnail", "City") .And.HaveChild(this.districtType, "City", c2 => c2.Should().BeSameAsEntireSubtree())); } @@ -193,6 +210,7 @@ public void WildcardAfterNavigationShouldNotSelectTheEntireTree() "Districts", c => c.Should() .HaveStreams(this.districtType, "Thumbnail") + .And.HaveProperties(this.districtType, "Id", "Zip", "Thumbnail", "City") .And.HaveNavigations(this.districtType, "City")); } @@ -332,7 +350,10 @@ public void CombiningPropertyWithWildcardShouldBeWildcard() { SelectedPropertiesNode left = SelectedPropertiesNode.Create("*"); SelectedPropertiesNode right = SelectedPropertiesNode.Create("Fake"); - this.VerifyCombination(left, right, n => n.Should().HaveStreams(this.cityType, "Photo").And.HaveNavigations(this.cityType, "Districts")); + this.VerifyCombination(left, right, + n => n.Should().HaveStreams(this.cityType, "Photo") + .And.HaveProperties(this.cityType, "Districts", "Photo") + .And.HaveNavigations(this.cityType, "Districts")); } [Fact] @@ -343,6 +364,7 @@ public void CombiningPartialNodesShouldCombineProperties() Action verify = n => n.Should() .HaveStreams(this.cityType, "Photo") + .And.HaveProperties(this.cityType, "Photo") .And.HaveNavigations(this.cityType, "Districts") .And.HaveChild(this.cityType, "Districts", c => c.Should().HaveOnlyStreams(this.districtType, "Thumbnail")); @@ -362,6 +384,7 @@ public void CombiningDeepPartialNodesShouldCombineRecursively() "Districts", c => c.Should() .HaveStreams(this.districtType, "Thumbnail") + .And.HaveProperties(this.districtType, "Thumbnail") .And.HaveNavigations(this.districtType, "City") .And.HaveChild(this.districtType, "City", c2 => c2.Should().HaveOnlyNavigations(this.cityType, "Districts"))); @@ -536,6 +559,12 @@ internal SelectedPropertiesNodeAssertions(SelectedPropertiesNode node) : base(no { } + internal AndConstraint HaveProperties(IEdmEntityType entityType, params string[] propertyNames) + { + this.Subject.As().GetSelectedProperties(entityType).Keys.Should().BeEquivalentTo(propertyNames); + return new AndConstraint(this); + } + internal AndConstraint HaveStreams(IEdmEntityType entityType, params string[] streamPropertyNames) { this.Subject.As().GetSelectedStreamProperties(entityType).Keys.Should().BeEquivalentTo(streamPropertyNames); diff --git a/test/FunctionalTests/Tests/DataOData/Tests/OData.Common.Tests/PublicApi/PublicApi.bsl b/test/FunctionalTests/Tests/DataOData/Tests/OData.Common.Tests/PublicApi/PublicApi.bsl index 206c6fe62e..db78a5239d 100644 --- a/test/FunctionalTests/Tests/DataOData/Tests/OData.Common.Tests/PublicApi/PublicApi.bsl +++ b/test/FunctionalTests/Tests/DataOData/Tests/OData.Common.Tests/PublicApi/PublicApi.bsl @@ -4670,6 +4670,7 @@ public class Microsoft.OData.ODataPreferenceHeader { string AnnotationFilter { public get; public set; } bool ContinueOnError { public get; public set; } System.Nullable`1[[System.Int32]] MaxPageSize { public get; public set; } + string OmitValues { public get; public set; } bool RespondAsync { public get; public set; } System.Nullable`1[[System.Boolean]] ReturnContent { public get; public set; } bool TrackChanges { public get; public set; } @@ -5139,10 +5140,10 @@ public sealed class Microsoft.OData.ODataMessageWriterSettings { System.Uri BaseUri { public get; public set; } bool EnableCharactersCheck { public get; public set; } bool EnableMessageStreamDisposal { public get; public set; } - bool IgnoreNullValues { public get; public set; } string JsonPCallback { public get; public set; } Microsoft.OData.ODataMessageQuotas MessageQuotas { public get; public set; } Microsoft.OData.ODataUri ODataUri { public get; public set; } + bool OmitNullValues { public get; public set; } Microsoft.OData.ValidationKinds Validations { public get; public set; } System.Nullable`1[[Microsoft.OData.ODataVersion]] Version { public get; public set; } diff --git a/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.IgnoreNullPropertiesInEntryTest.approved.txt b/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.IgnoreNullPropertiesInEntryTest.approved.txt index 3578c7bcee..a1f0970148 100644 --- a/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.IgnoreNullPropertiesInEntryTest.approved.txt +++ b/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.IgnoreNullPropertiesInEntryTest.approved.txt @@ -16,7 +16,7 @@ Model Present: true Combination: 5; TestConfiguration = Format: JsonLight, Request: True, Synchronous: True Model Present: true -{"@odata.context":"http://odata.org/test/$metadata#CustomerSet/$entity","ID":44} +{"@odata.context":"http://odata.org/test/$metadata#CustomerSet/$entity","ID":44,"Hobby":null} Combination: 6; TestConfiguration = Format: JsonLight, Request: False, Synchronous: True Model Present: true @@ -24,7 +24,7 @@ Model Present: true Combination: 7; TestConfiguration = Format: JsonLight, Request: True, Synchronous: False Model Present: true -{"@odata.context":"http://odata.org/test/$metadata#CustomerSet/$entity","ID":44} +{"@odata.context":"http://odata.org/test/$metadata#CustomerSet/$entity","ID":44,"Hobby":null} Combination: 8; TestConfiguration = Format: JsonLight, Request: False, Synchronous: False Model Present: true diff --git a/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.cs b/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.cs index 3056e96869..11aae8bd24 100644 --- a/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.cs +++ b/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/JsonLight/JsonLightEntryWriterTests.cs @@ -127,7 +127,7 @@ public void PayloadOrderTest() { DebugDescription = "TypeName at the beginning, ID and ETag at the end, ID and ETag are not written and are ignored at the end", Items = new[] { new ODataResource() { TypeName = "TestModel.NonMLEType" } - .WithAnnotation(new WriteEntryCallbacksAnnotation + .WithAnnotation(new WriteEntryCallbacksAnnotation { BeforeWriteStartCallback = (entry) => { entry.Id = null; entry.ETag = null; }, BeforeWriteEndCallback = (entry) => { entry.Id = new Uri("urn:id"); entry.ETag = "etag"; } @@ -143,7 +143,7 @@ public void PayloadOrderTest() new EntryPayloadTestCase { DebugDescription = "Everything at the beginning", - Items = new[] { new ODataResource() { + Items = new[] { new ODataResource() { TypeName = "TestModel.MLEType", Id = new Uri("urn:id"), ETag = "etag", @@ -183,7 +183,7 @@ public void PayloadOrderTest() new EntryPayloadTestCase { DebugDescription = "TypeName, Id, ETag and ReadLinks at the beginning, the rest at the end", - Items = new[] { new ODataResource() { + Items = new[] { new ODataResource() { TypeName = "TestModel.MLEType", Id = new Uri("urn:id"), ETag = "etag", @@ -432,8 +432,8 @@ public void ActionAndFunctionPayloadOrderTest() }); } - [TestMethod, Variation(Description = "Test skipping null values when writing JSON Lite entries with IgnoreNullValues = true.")] - public void IgnoreNullPropertiesInEntryTest() + [TestMethod, Variation(Description = "Test skipping null values when writing JSON Lite entries with OmitNullValues = true.")] + public void OmitNullPropertiesInEntryTest() { EdmModel model = new EdmModel(); var container = new EdmEntityContainer("TestModel", "TestContainer"); @@ -517,7 +517,7 @@ public void IgnoreNullPropertiesInEntryTest() this.WriterTestConfigurationProvider.JsonLightFormatConfigurationsWithIndent, (testDescriptor, testConfiguration) => { - testConfiguration.MessageWriterSettings.IgnoreNullValues = true; + testConfiguration.MessageWriterSettings.OmitNullValues = true; TestWriterUtils.WriteAndVerifyODataEdmPayload(testDescriptor, testConfiguration, this.Assert, this.Logger); }); } @@ -545,9 +545,9 @@ public void OpenPropertiesInEntryTest() new EntryPayloadTestCase { DebugDescription = "Customer instance with open primitive property.", - Items = new[] { new ODataResource() - { - TypeName = "TestModel.OpenCustomerType", + Items = new[] { new ODataResource() + { + TypeName = "TestModel.OpenCustomerType", Properties = new ODataProperty[] { new ODataProperty { Name = "Age", Value = (long)42 } @@ -563,9 +563,9 @@ public void OpenPropertiesInEntryTest() new EntryPayloadTestCase { DebugDescription = "Customer instance with open spatial property.", - Items = new[] { new ODataResource() - { - TypeName = "TestModel.OpenCustomerType", + Items = new[] { new ODataResource() + { + TypeName = "TestModel.OpenCustomerType", Properties = new ODataProperty[] { new ODataProperty { Name = "Location", Value = pointValue } @@ -659,9 +659,9 @@ public void SpatialPropertiesInEntryTest() new EntryPayloadTestCase { DebugDescription = "Customer instance with spatial property (expected and payload type don't match).", - Items = new[] { new ODataResource() - { - TypeName = "TestModel.CustomerType", + Items = new[] { new ODataResource() + { + TypeName = "TestModel.CustomerType", Properties = new ODataProperty[] { new ODataProperty { Name = "Location1", Value = pointValue } @@ -687,9 +687,9 @@ public void SpatialPropertiesInEntryTest() new EntryPayloadTestCase { DebugDescription = "Customer instance with spatial property (expected and payload type match).", - Items = new[] { new ODataResource() - { - TypeName = "TestModel.CustomerType", + Items = new[] { new ODataResource() + { + TypeName = "TestModel.CustomerType", Properties = new ODataProperty[] { new ODataProperty { Name = "Location2", Value = pointValue } @@ -776,7 +776,7 @@ public void TopLevelOpenComplexProperties() tc => new JsonWriterTestExpectedResults(this.Settings.ExpectedResultSettings) { Json = string.Format( - CultureInfo.InvariantCulture, + CultureInfo.InvariantCulture, testCase.Json, JsonLightWriterUtils.GetMetadataUrlPropertyForProperty(testCase.EntityType.FullTypeName()) + ","), FragmentExtractor = (result) => result.RemoveAllAnnotations(true) @@ -796,7 +796,7 @@ public void TopLevelOpenComplexProperties() { TestWriterUtils.WriteAndVerifyODataEdmPayload(testDescriptor, testConfiguration, this.Assert, this.Logger); }); - + } private sealed class EntryPayloadTestCase { diff --git a/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/TestWriterUtils.cs b/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/TestWriterUtils.cs index 4c1a4f7a4d..4bbe0a1ff0 100644 --- a/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/TestWriterUtils.cs +++ b/test/FunctionalTests/Tests/DataOData/Tests/OData.Writer.Tests/TestWriterUtils.cs @@ -166,6 +166,29 @@ internal static void WriteActionAndVerifyODataPayload(Action( this PayloadWriterTestDescriptor testDescriptor, WriterTestConfiguration testConfiguration, bool alwaysSpecifyOwningContainer = false, - BaselineLogger baselineLogger = null, + BaselineLogger baselineLogger = null, Action writeAction = null) { T property = testDescriptor.PayloadItems.Single(); @@ -679,9 +702,9 @@ private static void WritePayload(ODataMessageWriterTestWrapper messageWriter, OD /// A boolean flag indicating whether to flush the writer after writing. /// The payload items to write. /// - /// The list of is interpreted in the following way: + /// The list of is interpreted in the following way: /// for every non-null item we call WriteStart; for every null item we call WriteEnd. - /// null items can be omitted at the end of the list, e.g., a list of a feed and an entry + /// null items can be omitted at the end of the list, e.g., a list of a feed and an entry /// item would be 'auto-completed' with two null entries. /// internal static void WritePayload(ODataMessageWriterTestWrapper messageWriter, ODataWriter writer, bool flush, IList items, int throwUserExceptionAt = -1)