-
Notifications
You must be signed in to change notification settings - Fork 161
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
base: release-8.x
Are you sure you want to change the base?
Conversation
Enable Instance annotation deserialization Enable property without value
@@ -150,6 +154,20 @@ public override bool TrySetPropertyValue(string name, object value) | |||
throw Error.ArgumentNull(nameof(name)); | |||
} | |||
|
|||
if (IsInstanceAnnotation(name, out PropertyInfo annotationContainerPropertyInfo)) |
There was a problem hiding this comment.
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 eachPUT
/PATCH
request that usesDelta<T>
. - The
IsInstanceAnnotation
method scans all the properties. So ifTrySetPropertyValue
is called for each property, then we have anO(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 conditionif (_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; |
There was a problem hiding this comment.
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('#'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we only use identifier[0]
, maybe we should use Substring
instead of Split
so that we allocate at most one new string.
|
||
string[] identifier = annotationIdentifier.Split('#'); | ||
|
||
IEdmTerm term = model.FindTerm(identifier[0]); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
// 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); | ||
} |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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)
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. |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
string typeName = resourceWrapper.IsResourceValue ? | ||
resourceWrapper.ResourceValue.TypeName : | ||
resourceWrapper.Resource.TypeName; |
There was a problem hiding this comment.
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.
ICollection<ODataInstanceAnnotation> annotations = resourceWrapper.IsResourceValue ? | ||
resourceWrapper.ResourceValue.InstanceAnnotations : | ||
resourceWrapper.Resource.InstanceAnnotations; |
There was a problem hiding this comment.
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.
IEnumerable<ODataProperty> properties = resourceWrapper.IsResourceValue ? | ||
resourceWrapper.ResourceValue.Properties : | ||
resourceWrapper.Resource.Properties; |
There was a problem hiding this comment.
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.
string typeName = resourceWrapper.IsResourceValue ? | ||
resourceWrapper.ResourceValue.TypeName : | ||
resourceWrapper.Resource.TypeName; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
string typeName = resourceWrapper.IsResourceValue ? | ||
resourceWrapper.ResourceValue.TypeName : | ||
resourceWrapper.Resource.TypeName; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ICollection<ODataInstanceAnnotation> annotations = resourceWrapper.IsResourceValue ? | ||
resourceWrapper.ResourceValue.InstanceAnnotations : | ||
resourceWrapper.Resource.InstanceAnnotations; |
There was a problem hiding this comment.
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 ? |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
Support instance annotation
OData model builder has the interface
If a customer can modify his model (C# class), he can use the model-based instance annotation container provided in OData model builder.
This PR is to:
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.
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.
We support to read property without value but with instance annotation
We support to query the entity set which contains instance annotation.
for example: ~/customers/1/name
the payload could be:
for example: ~/customers/1/name
the payload could be: