Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Instance annotations using annotation container #1259

Open
wants to merge 3 commits into
base: release-8.x
Choose a base branch
from

Conversation

xuzhg
Copy link
Member

@xuzhg xuzhg commented Jun 10, 2024

Support instance annotation

OData model builder has the interface

image

If a customer can modify his model (C# class), he can use the model-based instance annotation container provided in OData model builder.

public class EntityTypeWithAnnotation
{
   public int Id { get; set; }
   public IODataInstanceAnnotationContainer InstanceAnnotations { get; set; }
 }

This PR is to:

  1. Enable Instance annotation deserialization
    When a request payload contains instance annotation for resource and property, we can read it and convert to the key/value pairs and save them into instance annotation container.

  2. Enable Instance annotation serialization
    When a serialize an object which contains instance annotation in the container, we can convert to ODataInstanceAnnotation and save them into InstanceAnnotation collection of ODataResourceBase or ODataPropertyInfo.

  3. We support to read property without value but with instance annotation

  4. We support to query the entity set which contains instance annotation.
    for example: ~/customers/1/name
    the payload could be:

{
    "@context":"$metadata#Customers",
     "@my.instanceAnnotation":"Smithe" 
    "value": [
    {
        ....
    },
    ....
  ]
}    
  1. We support to query the property directly which contains instance annotation.
    for example: ~/customers/1/name
    the payload could be:
{
    "@context":"$metadata#Customers(1)/Name",
     "@my.instanceAnnotation":"Smithe" 
    "value":"Smith",
}    

@@ -150,6 +154,20 @@ public override bool TrySetPropertyValue(string name, object value)
throw Error.ArgumentNull(nameof(name));
}

if (IsInstanceAnnotation(name, out PropertyInfo annotationContainerPropertyInfo))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am concerned about the performance impact of this check. It would be good to run some profiling to verify whether this call adds significant overhead. Here's why I'm concerned:

  • The method IsInstanceAnnotation is called each time we set a property value. Potentially this means we call it for each property at least once for each PUT/PATCH request that uses Delta<T>.
  • The IsInstanceAnnotation method scans all the properties. So if TrySetPropertyValue is called for each property, then we have an O(n^2) operation which could be costly if the entity has a lot of properties
  • We call IsInstanceAnnotation even if the _instanceAnnotationsContainer has already been found. I think we only expect one instance annotations container property in the entity class. If there are multiple, I think we should only select the first one. So we could add a condition if (_instanceAnnotationContainer == null && IsInstanceAnnotation(...)).
  • This approach will be most expensive when the class doesn't have an annotations container. Meaning the people who don't use the feature pay biggest price (should measure the cost to see whether this would actually be an issue in practice)

When adding new opt-in features, I think we should consider as much as possible ways to minimize or eliminate the cost for people who don't use the feature. Maybe we can consider computing only once whether this type has an instance annotations container and cache it somewhere where it's fast to lookup. Since Delta<T> is used during deserialization, is there a flag we can set during deserialization when we detect instance annotations? And use that flag to determine whether the contain exists? Not sure if that's a feasible approach.

if (desContainer == null)
{
_instanceAnnotationContainerPropertyInfo.SetValue(targetEntity, sourceContainer);
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the source container gets modified after this, (e.g. new items added to it), the changes will also affect the destination container since they'll be references to the same instance. Is this expected behaviour? Is it something we should worry about? Or is it a non-issue?

throw Error.ArgumentNull(nameof(model));
}

string[] identifier = annotationIdentifier.Split('#');

string[] identifier = annotationIdentifier.Split('#');

IEdmTerm term = model.FindTerm(identifier[0]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it sucks that we have to perform a new string allocation just so we can lookup a term using a substring. It would be great if model.FindTerm would support ReadOnlySpan<char> so we can avoid string allocation in such cases. We have an open issue for that: OData/odata.net#2801

terms = terms.Concat(refedTerms);
}

if (terms.Count() > 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

terms.Count() will scan the whole collection. If we want to find duplicates, maybe we should use terms.Take(2).Count().

throw new ODataException(Error.Format(SRResources.AmbiguousTypeNameFound, termName));
}

return terms.SingleOrDefault();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single ensures that the returned item is unique, but we have already checked for duplicates in the block above. I think it's redundant to have both checks.

Comment on lines +135 to +148
// TODO: Let's support namespace alias when we get requirement and ODL publics 'ReplaceAlias' extension method.
// identifier = model.ReplaceAlias(identifier);

string termName = identifier[0];
var terms = model.SchemaElements.OfType<IEdmTerm>()
.Where(e => string.Equals(termName, e.FullName(), StringComparison.OrdinalIgnoreCase));

foreach (var refModels in model.ReferencedModels)
{
var refedTerms = refModels.SchemaElements.OfType<IEdmTerm>()
.Where(e => string.Equals(termName, e.FullName(), StringComparison.OrdinalIgnoreCase));

terms = terms.Concat(refedTerms);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like the kind of thing that should be implemented in EdmLib and provided as single method call. It is also expensive to scan all the referenced models if we have no term. Is this meant to support case-insensitive lookups? Should we also do case-insensitive lookups or is that something that the customer enables via configuration?

In ODL we created a cache for case-insensitive lookups of model elements (including IEdmTerm) for use with the case-insensitive ODataUriResolver. We could consider refactoring the cache and using it in more scenario. It was designed to avoid the cost of scanning the schemas frequently, which was causing a lot of CPU usage in workloads:
OData/odata.net#2610

@@ -276,28 +277,30 @@ internal static void SetProperty(object resource, string propertyName, object va
internal static object ConvertValue(object oDataValue, ref IEdmTypeReference propertyType, IODataDeserializerProvider deserializerProvider,
ODataDeserializerContext readContext, out EdmTypeKind typeKind)
{
if (oDataValue == null)
if (oDataValue == null || oDataValue is ODataNullValue)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why didn't we check ODataNullValue before? Was this a bug?

}

InstanceAnnotationContainerAnnotation annotation =
edmModel.GetAnnotationValue<InstanceAnnotationContainerAnnotation>(edmType);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope we can figure out way to avoid this pattern because this has become a constant perf problem in OData. It's also a usability issue because customers wouldn't know which annotations need to be registered for which feature and we don't have good docs for it. I will investigate possible alternatives and share findings with the team.

@@ -26,6 +26,8 @@ namespace Microsoft.AspNetCore.OData.Extensions
/// </summary>
public static class HttpRequestExtensions
{
private static readonly string ODataInstanceAnnotationContainerKey = "odataInstanceAnnotation_14802D58-69EF-4B28-9BDC-963D3648F06A";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would const be better here since the value is known at compile-time? (I assume we'll never change this)

Suggested change
private static readonly string ODataInstanceAnnotationContainerKey = "odataInstanceAnnotation_14802D58-69EF-4B28-9BDC-963D3648F06A";
private const string ODataInstanceAnnotationContainerKey = "odataInstanceAnnotation_14802D58-69EF-4B28-9BDC-963D3648F06A";

// 05/01/2024: new scenario, if we specify an instance annotation using the collection value as:
// ""[email protected]"":[""Skyline"",7,""Beaver""],
// ODL generates 'ODataCollectionValue' without providing the 'TypeName' on ODataCollectionValue.
// So the above assumption could not be correct again.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the above assumption is no longer correct due to the changes introduced, then we should also update the above comment to reflect the new behaviour.

@@ -128,5 +132,78 @@ internal ODataDeserializerContext CloneWithoutType()
TimeZone = this.TimeZone
};
}

private CachedItem _cached = new CachedItem();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this cache only intended for use with the instance annotations container? If so, then maybe it would be better to give it a more specific name. Also, maybe it should moved up where the other private fields are located.

Comment on lines +162 to +164
string typeName = resourceWrapper.IsResourceValue ?
resourceWrapper.ResourceValue.TypeName :
resourceWrapper.Resource.TypeName;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think have to check the resourceWrapper.IsResourceValue so you can call the right property is error-prone and unintuitive. I think @corranrogue9 also had some concerns around this approach and proposed using subclasses. If we don't take that approach, I'd suggest we at least expose a resourceWrapper.GetTypeName() method that hides the details and make it less likely that the user will make a mistake.

Comment on lines +502 to +504
ICollection<ODataInstanceAnnotation> annotations = resourceWrapper.IsResourceValue ?
resourceWrapper.ResourceValue.InstanceAnnotations :
resourceWrapper.Resource.InstanceAnnotations;
Copy link
Contributor

@habbes habbes Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also expose a resourceWrapper.GetInstanceAnnotations() method. It's easy to make a mistake because it's not obvious that the developer should first check the resourceWrapper.IsResourceValue property or that ResourceValue and Resource do different things. This could be confusing and lead to errors.

Comment on lines +563 to +565
IEnumerable<ODataProperty> properties = resourceWrapper.IsResourceValue ?
resourceWrapper.ResourceValue.Properties :
resourceWrapper.Resource.Properties;
Copy link
Contributor

@habbes habbes Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern as https://github.com/OData/AspNetCoreOData/pull/1259/files#r1684303079. Consider a resourceWrapper.GetProperties() method or a different design for resource value and resource.

Comment on lines +688 to +690
string typeName = resourceWrapper.IsResourceValue ?
resourceWrapper.ResourceValue.TypeName :
resourceWrapper.Resource.TypeName;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +162 to +164
string typeName = resourceWrapper.IsResourceValue ?
resourceWrapper.ResourceValue.TypeName :
resourceWrapper.Resource.TypeName;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +502 to +504
ICollection<ODataInstanceAnnotation> annotations = resourceWrapper.IsResourceValue ?
resourceWrapper.ResourceValue.InstanceAnnotations :
resourceWrapper.Resource.InstanceAnnotations;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -566,15 +685,19 @@ public virtual void ApplyDeletedResource(object resource, ODataResourceWrapper r
object value = null;
if (resourceWrapper != null)
{
string typeName = resourceWrapper.IsResourceValue ?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -18,6 +19,8 @@
using Microsoft.AspNetCore.OData.Formatter.Value;
using Microsoft.AspNetCore.OData.Query.Wrapper;
using Microsoft.OData.Edm;
using Microsoft.OData.ModelBuilder;
using Microsoft.VisualBasic;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the VisualBasic namespace used for?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants