Skip to content

Add initial support for parameters with AsParameters attribute #47914

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

Merged
merged 18 commits into from
May 14, 2023

Conversation

captainsafia
Copy link
Member

@captainsafia captainsafia commented Apr 26, 2023

Part of #46336.

  • Update EndpointParameter type to support construction from IPropertySymbol and IParameterSymbol
  • Update parameter emission to support recursing into populated EndpointParameters
  • Move setting parameter-specific emitter context properties to EmitParameterPreparation
  • Update logic within EndpointParameter class to take INamedTypeSymbol or ImmutableArray<AttributeData> directly instead of deriving them from an IParameterSymbol

Note: this PR does't encompass all the details of AsParameters or migrate all tests. Notably missing is:

The following features are now supported:

  • Warning if a parameter marked with AsParameters is marked as nullable
  • Warning if the constructors for the type associated with a parameter marked with AsParameters are not valid
  • Warning if a nested AsParameter is encountered

@ghost ghost added the old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels label Apr 26, 2023
@mitchdenny
Copy link
Member

This is looking good. The questions I had about invalid constructors you've already called out. Do you think this is something that you want to merge and then iterate, or do you want to get to full fidelity first?

@captainsafia
Copy link
Member Author

This is looking good. The questions I had about invalid constructors you've already called out. Do you think this is something that you want to merge and then iterate, or do you want to get to full fidelity first?

I was intending to merge and iterator. At the moment, we flag the different types of unsupported constructors in the TryGetAsParametersConstructor call when we return false:

https://github.com/dotnet/aspnetcore/blob/2b3b0a4d89b6394655b1ead1dca0a44825c59019/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/EndpointParameter.cs#L447

In the handling, this marks the parameter with an Unknown parameter source which opts it into our generic "this parameter is not supported warning experience". We'll want to flow the state up to the diagnostic handling in the Endpoint model itself. The reason I want to pause in it is we'll want to bubble up diagnostics for the other scenarios mentioned in my bulleted list as well so I'd figure I'd handle all of the diagnostics-related scenarios in one PR.

Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

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

I don't really understand the scenario, so I'm just nitpicking. 😉

ProcessEndpointParameterSource(endpoint, property, attributeBuilder.ToImmutable(), wellKnownTypes);
}

private EndpointParameter(Endpoint endpoint, ITypeSymbol typeSymbol, string parameterName, WellKnownTypes wellKnownTypes)
Copy link
Member

Choose a reason for hiding this comment

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

Is this only called from the ctor above?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct, this contains the "core" implementation of the EndpointParameter that parameters sourced directly from the handler like so:

app.MapGet("/{name", (string name) => ...);

And those that are resolved as properties from an AsParameters attributed type resolve from:

app.MapGet("/{name", ([AsParameters] MyArgs args) => ...);

public class MyArgs
{
  string Name { get; set; }
}

Copy link
Member

Choose a reason for hiding this comment

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

From the outside, the logical boundary isn't all that clear and it looks like a candidate for inlining. If it's clear, in context, why it should be a separate method, then no objection.

}

var constructors = type.Constructors.Where(constructor => constructor.DeclaredAccessibility == Accessibility.Public && !constructor.IsStatic);
var numOfConstructors = constructors.Count();
Copy link
Member

Choose a reason for hiding this comment

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

Return early if there aren't any?

var lookupTable = new Dictionary<ParameterLookupKey, IPropertySymbol>();
foreach (var property in properties)
{
lookupTable.Add(new ParameterLookupKey(property.Name, property.Type), property);
Copy link
Member

Choose a reason for hiding this comment

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

I think indexers can have the same name. Maybe you meant to filter those out above?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good question that led me down an interesting rabbit hole!

I'm assuming that you're referring to indexers on types like in the case below:

public class ClassWithIndexer
{
    private int[] arr = new int[100];
    private Dictionary<string, int> map = new Dictionary<string, int>();
    public int this[int i]
    {
        get { return arr[i]; }
        set { arr[i] = value; }
    }

    public int this[string i]
    {
        get { return map[i]; }
        set { map[i] = value; }
    }
}

Looking to RDF as guidance, we observe two problems here:

It looks like the AsParameters implementation in RDF isn't supporting indexers at all. Amongst other things, it produces incorrect code including initializations like the following:

$args_local = .New Microsoft.AspNetCore.Http.Generators.Tests.ClassWithIndexer(){
            Item = $Item_local
        };

which disregard that the indexer cannot be initialized with an object initializer. It seems like support for indexers on AsParameters-enabled types wasn't considered as part of the original proposal so we'll have to think through how the implementation will work in both RDF and RDG. I've filed #48118 to track this.

Second is with regards to the scenario where multiple indexers are defined (both which emit the underlying Item property name). RDF has support for this in its argument checking. It'll through an exception at startup warning that duplicate argument names where found:

ArgumentException: An item with the same key has already been added. Key: Item

This is smelly code though because we're just throwing the unhandled exception from attempting to add to FactoryContext.TrackedParameters without informing the user why.

I'll noodle on how to handle this more elegantly in RDG but may approach this in a follow-up when we migrate the RequestDelegateThrowsWhenParameterNameConflicts test.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should just skip properties with parameters for now?

@captainsafia captainsafia force-pushed the safia/rdg-asparams branch from 454b654 to 26d174a Compare May 8, 2023 05:04
@captainsafia
Copy link
Member Author

👈🏽 for reviews here.

@@ -69,11 +69,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
codeWriter.EndBlockWithComma();
codeWriter.WriteLine("(del, options, inferredMetadataResult) =>");
codeWriter.StartBlock();
codeWriter.WriteLine(@"Debug.Assert(options != null, ""RequestDelegateFactoryOptions not found."");");
Copy link
Member

Choose a reason for hiding this comment

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

I'm not super excited about this. Do we Assert in other generated code already?

It feels like we are doing something wrong. Can the user actually get into this situation? And if they can, do they know what to do to get out of it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ha! @mitchdenny and I had a similar conversation about this yesterday.

Do we Assert in other generated code already?

We introduced this assertion here in b138bde. I'm not aware of the use Debug.Assert in other generated code (like the runtime's generators) but I'll attempt to justify why I think this is appropriate here.

Can the user actually get into this situation?

Nope, not if they are using RDG.

The nullable parameters in the API signatures of MetadataPopulator and RequestDelegateFactoryFunc exists primarily because the RequestDelegateFactory has public APIs that allow uses to construct delegates and populate metadata standalone without going through our endpoint resolution logic (see this and this. Internally, these APIs use fallbacks if null values are provided for the nullable parameters, as seen here.

With the generated code, the more specific Map overloads can only be called during endpoint resolution when RequestDelegateFactoryOptions are guaranteed to be set for route handlers (ref, ref).

If there's every a scenario where null values are passed to the generated code, that means we did something wrong in our framework. There's not really anything the user can do to address that issue. IMO, the Debug.Assert here reflects that this is bookkeeping we're responsible for as framework authors, not a nullability requirement that the end-user violated.

Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to just disable nullability checking in these files?

Copy link
Member Author

Choose a reason for hiding this comment

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

Would it make sense to just disable nullability checking in these files?

The nullability checks provide value when executing the requiredness checks that we have built in as part of parameter binding. Minimal APIs has a feature where in if you don't annotate a type as nullable, code-gen some logic that validates that it is provided (kind of like the Required attribute in System.ComponentModel.DataAnnotations). For example, the code below will bind the age value from the route parameter but check that the route parameter has been provided before invoking the users handler.

app.MapGet("/birthday/{age}", (int age) => ...)

Being able to leverage nullable flow analysis in generated code to ensure that we've done the appropriate requiredness checks is more valuable than not having it for the sake of avoiding two assertions, IMO.

Copy link
Member

Choose a reason for hiding this comment

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

I would just ! away the nullability checks on this one line:

-var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices;
+var serviceProvider = options!.ServiceProvider ?? options!.EndpointBuilder!.ApplicationServices;

If the user built for Release and somehow got into a null situation here, the behavior is the same. If they are in Debug and get asserts in here, they have no idea what to do either. Either way, it being a bug in our code.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep. My preference towards using Debug.Assert vs. the null forgiving operator is that Debug.Assert is that we fail upfront in our debug builds if we're in an invalid state instead of waiting until the options are used. It makes more sense to be to alert about this inconsistency earlier.

Also, for users reading the generated code when in debug mode, I think the Debug.Assert communicates more clearly what is going on here than the null-forgiving operator (which personally gives me the ick when I see it in code that I don't own and don't understand why the developer made that guarantee).

Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

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

More sniping from the peanut gallery.

{
if (parameterSymbol is { ContainingSymbol: IMethodSymbol constructor })
{
var parameterType = $"typeof({parameterSymbol.Type.ToDisplayString()})";
Copy link
Member

Choose a reason for hiding this comment

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

Remind me what ToDisplayString does if the type is something like class @class { }?

Copy link
Member Author

Choose a reason for hiding this comment

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

It looks like the @ is stripped from the type symbol's identifier.

Copy link
Member

@amcasey amcasey May 15, 2023

Choose a reason for hiding this comment

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

That sounds bad. Won't you end up with typeof(class), which is a syntax error?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah! I misinterpreted your comment a little bit. I thought your inquiry was around the use of the @ prefix on class names in general (which ends up producing something like typeof(MyClass) which is valid) but you were referring to the literal use of class @class.

My initial reaction to this is that it's the kind of edge case that I'd be comfortable not addressing specifically in RDG.

My second reaction is to explore what the implementation complexity would look like with this and see if it is worth pursuing given cost/reward. It might very well be we have to pass a formatter to ToDisplayString here to get the desired behavior.

My third reaction is wondering if the current behavior (compiler errors on generated code) is a valid experience for users who run into these kinds of buggy experiences.

In any case, this is worth tracking in a follow-up issue if you'd like to file one?

Copy link
Member

Choose a reason for hiding this comment

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

I would expect this to come up in a large variety of source generators, so I would guess there's a standard solution (maybe a DisplayString variant or an escaping API).

Copy link
Member

Choose a reason for hiding this comment

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

How does it fail if someone does do that? I guess their build fails and the mitigation is currently to rename their type?

I'll file it.

Copy link
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

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

I'm not going to wade into the Debug.Assert vs ! discussion. Both sides make good points, but the asserts are consistent with what we've done so far.

@captainsafia captainsafia merged commit ce9e1ae into main May 14, 2023
@captainsafia captainsafia deleted the safia/rdg-asparams branch May 14, 2023 15:34
@ghost ghost added this to the 8.0-preview5 milestone May 14, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants