-
-
Notifications
You must be signed in to change notification settings - Fork 916
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
Rewrite ObjectExtensions.ToObject
with source generator
#3757
Comments
The current idea (draft and oversimplified). For this source code public class MyInput
{
public MyInput(string field1)
{
Field1 = field1;
}
public string Field1 { get; }
public string? Field2 { get; init; }
public string? Field3 { get; set; } = "default for Field3";
public string? Field4 { get; set; }
}
public class MyInputObjectGraphType : InputObjectGraphType<MyInput>
{
public MyInputObjectGraphType()
{
Field<NonNullGraphType<StringGraphType>>("Field1");
Field<StringGraphType>("Field2").DefaultValue("default for Field2");
Field<StringGraphType>("Field3");
Field<StringGraphType>("Field4");
}
} Generate this parsing code public MyInput Parse(IDictionary<string, object?> source)
{
if (source == null)
{
throw new Exception();
}
if (!source.TryGetValue("Field1", out object? field1))
{
throw new Exception();
}
return new MyInput((string)field1.GetPropertyValue(typeof(string))!)
{
Field2 = GetValueOrDefault(source, "Field2", "default for Field2"), // the default is from the DefaultValue method
Field3 = GetValueOrDefault(source, "Field3", "default for Field3"), // the default is from the field initializer
Field4 = GetValueOrDefault(source, "Field4", default(string)),
};
}
private static T GetValueOrDefault<T>(IDictionary<string, object?> source, string fieldName, T defaultValue) =>
source.TryGetValue(fieldName, out object? field)
? (T)field.GetPropertyValue(typeof(T))!
: defaultValue; This is just an example of the direction. The |
ObjectExtensions.ToObject with
source generatorObjectExtensions.ToObject
with source generator
It looks like public MyInput Parse(IDictionary<string, object?> source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
if (!source.TryGetValue("Field1", out object? field1))
{
throw new Exception();
}
var result = new MyInput(field1.GetPropertyValue<string>());
if (source.TryGetValue("Field2", out object? field2))
{
result.Field2 = field2.GetPropertyValue<string>();
}
else
{
result.Field2 = "default for Field2"; // the default is from the DefaultValue method
}
if (source.TryGetValue("Field3", out object? field3))
{
result.Field3 = field2.GetPropertyValue<string>();
}
if (source.TryGetValue("Field4", out object? field4))
{
result.Field4 = field4.GetPropertyValue<string>();
}
return result;
}
|
Why wouldn’t your prior sample work for init properties ? |
Because init props must be set durring the object creation. It means, first I have to check every init prop if it has a field in the |
Maybe that’s okay. Is the source generator opt in or always on by default? |
It can be opt-in with msbuild properties. For example, the new configuration binder source generator uses <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> But I think it should be more granular than just enable/disable. For example, I'd prefer to avoid side effects and be notified when
|
There is no need to create an analyzer or throw an exception. The code won't compile for |
Perhaps we should start by simplifying the reflection-based design for v8, then making the source-generated version of it. I could probably do the former part, and we could discuss. Then the functionality of the reflection-based code and source-generated code would match. |
Also, to not miss any field, we need the Strong typed API for building fields done as a prerequisite. Otherwise, it will be possible to generate code only for fields defined in the |
I've been messing around with dynamically compiled code, reviewing the existing mechanics, and thinking about the options here. Here's my thoughts so far:
I suggest we define these new semantics for behavior:
A simplified sample is below: public record TestClass2(string Prop1)
{
public int Prop2 { get; init; }
public int Prop3 { get; set; }
}
// source-generation and/or dynamic compilation equivalent
public object? ParseDictionary(IDictionary<string, object?> source)
{
var result = new Class1(source.TryGetValue("prop1", out object? value) ? (string)value.GetPropertyValue(typeof(string), Fields.Find("prop1")!.ResolvedType) : default(string))
{
Prop2 = source.TryGetValue("prop2", out object? value2) ? (int)value2.GetPropertyValue(typeof(int), Fields.Find("prop2")!.ResolvedType) : default(int)
};
if (source.TryGetValue("prop3", out object? value3))
{
result.Prop3 = (int)value3.GetPropertyValue(typeof(int), Fields.Find("prop3")!.ResolvedType);
}
return result;
} Notice in the above sample that there are still dictionary accesses to the @gao-artur What do you think? |
Should we just forbid the usage of
I think the compiled handler should implement the same "set only if provided" logic as a generated code. The
I think the dynamic compilation should replace the reflection with all required breaking changes in behavior. And the source generation should be opt-in for the users who accept its limitations for better cold start times and AOT support.
We already have all the required markers to discover the types that need the source generation: See my comments in an additional column.
As an additional note, it's not always possible to discover all the input type fields during the build. For example, if the field is defined in an external assembly. This means at the end of the generated code, we must check if there are not yet mapped fields in the dictionary and either throw an exception or fall back to the compiled expressions solution (for these fields only). This fallback is obviously not possible in AOT. So when compiled to AOT, we can run a schema visitor to ensure the generated code covers all the fields available in run-time (the generator can create some kind of metadata with a list of covered fields. The visitor will use this list for validation). |
Consider: class Class1
{
public string Prop1 { get; }
public Class1(string prop1)
{
this.Prop1 = prop1;
}
}
// or the following, which does not have a default constructor:
// record class Class1(string Prop1);
var c = System.Text.Json.JsonSerializer.Deserialize<Class1>("{}")!;
// works fine, passing null to the prop1 argument In this example, there is only a single constructor, and that constructor the only constructor requires |
I disagree a little here. Consider the following: record class Widget(string Name, string? Description);
// or:
public class Widget
{
public required string Name { get; init; }
public required string? Description { get; init; }
} In this case the proper way to use the class (via .NET) is the following, assuming the description is null: var widget = new Widget("Test", null);
// or:
var widget = new Widget { Name = "Test", Description = null }; So far so good. Now how would this be represented in GraphQL.NET ? public class WidgetType : InputObjectGraphType<Widget>
{
public WidgetType()
{
Field(x => x.Name);
Field(x => x.Description, true);
}
} Now from the view of the GraphQL client, both of these requests should be identical: query sample1 {
example(arg: { name: "Test" })
}
query sample2 {
example(arg: { name: "Test" description: null })
} So from the point of view of the GraphQL client, both queries should work (as Then, from the point of view of the Finally, given the above, I would apply the same logic to init-only properties. Why remove support because they are required to be set during construction? This is simply a requirement of the language. Think of GraphQL.NET as a translation layer between two languages. In this case .NET has no built-in understanding of But let's think a little further: when would it matter if public class Widget
{
public string Name { get; init; } = "DefaultName";
}
public class WidgetType : InputObjectGraphType<Widget>
{
public WidgetType()
{
Field(x => x.Name).DefaultValue("DefaultName");
}
} {
sample(widget: {}) # name will be DefaultName
} Here in this example, the The only situation where there is a true difference in behavior is when the .NET class defines a default value, but the GraphQL type does not. public class Widget
{
public string? Name { get; init; } = "UnspecifiedName";
}
public class WidgetType : InputObjectGraphType<Widget>
{
public WidgetType()
{
Field(x => x.Name, true); // nullable
}
} query sample1 {
sample(widget: {}) # name is UnspecifiedName
}
query sample2 {
sample(widget: { name: null }) # name is null
} In this example, the two sample queries "should" behave differently. (This could also be debated.) I feel this is a rare situation, and could easily be accommodated by specifying that in these situations, you must use In conclusion, if we were to simply set all constructor parameters and required/init-only properties defined in the input type, we would only have an issue in the scenario where all of these statements are true:
Note that dynamic/AOT compilation can read default values for optional constructor parameters from the signature. With this limited scope that we need to resolve, we have a few options:
What are the workarounds available to the developer for option 3?
Personally, I vote for option 3 above, where we always set init-only properties just as with required properties. I would expect the use case here to be so dramatically miniscule that the following benefits outweigh the alternative choices:
For all the same reasons, during dynamic/AOT compilation, I would not provide code paths for every constructor available. The use case is just too remote; even if there was a scenario that met the above unique requirements, now on top of the prior scenario requirements, it would have to be a class with multiple constructors. That seems to be extremely remote, and adds a considerable layer of complexity. |
Interesting. Honestly, I don't understand why. I have always seen
So, with STJ, the contractor is a "recommendation", but |
Ok then. In other words, we can say that
I think "behave differently" is a bad choice. It most likely will be unexpected and will lead to bugs. A similar problem was recently fixed in protobuf-net with a static analyzer that aligns defaults.
I think the best choice here is
I tested the following with STJ class Class1
{
public string Prop1 { get; }
public string Prop2 { get; }
public Class1(string prop1)
: this(prop1, "Prop2Default")
{
Prop1 = prop1;
}
public Class1(string prop1, string prop2)
{
Prop1 = prop1;
Prop2 = prop2;
}
}
var c = System.Text.Json.JsonSerializer.Deserialize<Class1>("""{ "Prop1": "p1" }""")!; And got an exception:
Should we have the same limitation with an exception? Silently choosing a constructor with the most parameters will lead to unexpected bugs. In the previous example, the |
I like that answer |
What about the nullable input field and not nullable required property? public class Widget
{
public required string Name { get; init; }
}
public class WidgetType : InputObjectGraphType<Widget>
{
public WidgetType()
{
Field(x => x.Name, true);
}
} Sounds like a conflict. I believe it's still possible to set it with dynamic compilation but not with a generated code. And even with dynamic compilation user will get NRE where it's not expected to happen. |
Generated code can use For value types, the value would have to be coerced to a non-null type. I would just coerce to |
Note that this is generally what happens when you use GetArgument currently: default if null. |
You can't have different graph type implementations with the same name, right? So the cache can be based on the type name. |
Yes, but we support multiple concurrent schemas on a single server. But typically |
Yeah, but as you said, public class HumanInputType : InputObjectGraphType<Human>
{
public HumanInputType()
{
Name = "HumanInput";
Field<NonNullGraphType<StringGraphType>>("name");
Field<StringGraphType>("homePlanet");
}
} But in this form, there is no partial class var input = new InputObjectGraphType<Human> { Name = "HumanInput", };
input.Field<NonNullGraphType<StringGraphType>>("name");
input.Field<StringGraphType>("homePlanet"); |
Can we use the schema type/name/instance as part of the cache key? |
I was thinking something like this within private Func<IDictionary<string, object?>, object> _parseDictionaryCompiled;
public virtual object ParseDictionary(IDictionary<string, object?> obj)
{
return (_parseDictionaryCompiled ??= ObjectExtensions.CompileToObject(this))(obj);
}
True... Hmm... Is it feasable that source generation could work at all in that scenario? It would have to scan "all" of the code to find what fields were defined with what names, and so on. Another idea: if we had the above example, perhaps
|
Presently, |
You mentioned the memory leak in the context of the scoped registration. In the scoped registration, this cache will be useless.
Theoretically - maybe. Practically - need to use dataflow analysis. I don't see myself doing this in the near future 😄 If |
Oh, I forgot, this is where source generation should really shine. (Updated notes above). Regardless of where the source generation takes place, it should make a huge impact over dynamic compilation in scoped schemas, which is extremely slow for first use, and still be better than reflection. Sure the cache is useless, but so is nearly all instance data for a scoped schema beyond the first execution. Scoped schemas really shouldn't be 'supported', but are too commonplace to remove. Dynamic compilation's benefits are that it works with any schema configuration, such as dynamic schemas, and can always produce an ideal code path for future executions with no lookups for fastest execution speed. You know what would be a REALLY cool analyzer - remove scoped dependencies from a graph type and put them into the field resolver. Something to help people transition from a scoped schema to a singleton schema. |
What I meant here, was that if source generation was in the |
I'd suggest we proceed along these lines:
[GenerateParseDictionary]
public partial class abc : InputObjectGraphType<Class1>
{
}
//or
public partial class abc : InputObjectGraphType<Class1>
{
[GenerateParseDictionary]
private partial object BaseParseDictionary(IDictionary<string, object?> dictionary);
public override object ParseDictionary(IDictionary<string, object?> dictionary)
{
var obj = BaseParseDictionary(dictionary);
// do custom stuff
return obj;
}
} This should cover all these scenarios:
Perhaps we also add a flag to scan all types for source-generation-eligible types and automatically add |
Very high risk for false-positive. Think multiple services in the same solution register the same dependency. Finding the registration used by the service exposing GraphQL schema is not trivial.
You don't really need this attribute. You can use
This will require the user to modify every single input type manually. It's not something that can be just recompiled if the source generator doesn't work in his scenario. Instead, we can add something like this to the private Func<IDictionary<string, object?>, object> _parser; It will be initialized with the default implementation, then the code generator can override it with the generated implementation. |
Yeah. Just thinking that we don't need anything perfect, just helper tools to assist, if possible, with the conversion. No warnings or errors. Consider the following example: public MyGraphType : ObjectGraphType<Person>
{
public MyGraphType(IMyService service)
{
Field(...).Resolve(ctx => service.Stuff());
}
} Perhaps a helper on the service (hold mouse over public MyGraphType : ObjectGraphType<Person>
{
public MyGraphType()
{
Field(...).Resolve(ctx =>
{
var service = ctx.RequestServices!.GetRequiredService<IMyService>();
return service.Stuff();
});
}
} If even it did a basic job like that, it could be extremely useful. The developer then would use the helper for each service in their constructor, and then make any final adjustments manually. So it's a tool-assisted conversion, rather than 100% manual or 100% automatic. |
Well, as of yet, I don't think we should automatically enable source generation. There is going to be issues we have not foreseen with source generation. For example, in the constructor, A global flag to enable it seems fine. |
I'd say it should be enabled with msbuild property in csproj file. |
@Shane32 I started working on this feature. I have a first POC, but much work is yet to be done, and much stuff to learn, but a little free time. I suggest continuing the v8 with the original plan and focusing the v9 solely on AOT support. I expect requests for .NET8.0 TFM very soon. Postponing it until all the Roslyn features are ready can take too long. |
Sounds good. I'm also going to clean up the reflection-based implementation and/or write a compiled-at-runtime implementation. |
Originally posted by @Shane32 in #3732 (comment)
The text was updated successfully, but these errors were encountered: