Skip to content

Commit

Permalink
Add IParseableInputObject with SG support
Browse files Browse the repository at this point in the history
  • Loading branch information
pekkah committed Jan 17, 2024
1 parent 8c2ad3a commit b2ef73c
Show file tree
Hide file tree
Showing 18 changed files with 500 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\GraphQL.Extensions.Experimental\GraphQL.Extensions.Experimental.csproj" />
<ProjectReference Include="..\..\src\GraphQL.Language\GraphQL.Language.csproj" />
<ProjectReference Include="..\..\src\GraphQL.Server\GraphQL.Server.csproj" />
<ProjectReference Include="..\..\src\GraphQL\GraphQL.csproj" />
Expand Down
82 changes: 80 additions & 2 deletions samples/GraphQL.Samples.SG.InputType/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Mvc;

using Tanka.GraphQL;
using Tanka.GraphQL.Extensions.Experimental.OneOf;
using Tanka.GraphQL.Server;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
Expand All @@ -18,8 +21,20 @@
// add types from current namespace
types.AddGlobalTypes();
});

options.PostConfigure(configure =>
{
// add oneOf input type
configure.Builder.Schema.AddOneOf();
configure.Builder.Add($$"""
extend input {{nameof(OneOfInput)}} @oneOf
""");
});
});

// add validation rule for @oneOf directive
builder.Services.AddDefaultValidatorRule(OneOfDirective.OneOfValidationRule());

WebApplication app = builder.Build();
app.UseWebSockets();

Expand Down Expand Up @@ -68,6 +83,43 @@ public static Message Post([FromArguments]InputMessage input, [FromServices]Db d
db.Messages.Add(message);
return message;
}

/// <summary>
/// A command pattern like mutation with @oneOf input type
/// </summary>
/// <remarks>
/// @oneOf - directive is provided by Tanka.GraphQL.Extensions.Experimental
/// Spec PR: https://github.com/graphql/graphql-spec/pull/825
/// </remarks>
/// <param name="input"></param>
/// <param name="db"></param>
/// <returns></returns>
public static Result? Execute([FromArguments] OneOfInput input, [FromServices] Db db)
{
if (input.Add is not null)
{
var message = new Message
{
Id = Guid.NewGuid().ToString(),
Text = input.Add.Text
};

db.Messages.Add(message);
return new Result()
{
Id = message.Id
};
}

if (input.Remove is null)
throw new ArgumentNullException(nameof(input.Remove), "This should not happen as the validation rule should ensure one of these are set");

db.Messages.RemoveAll(m => m.Id == input.Remove.Id);
return new Result()
{
Id = input.Remove.Id
};
}
}

[ObjectType]
Expand All @@ -78,13 +130,39 @@ public class Message
public required string Text { get; set; }
}

[ObjectType]
public class Result
{
public string Id { get; set; }
}

[InputType]
public class InputMessage
public partial class InputMessage
{
public string Text { get; set; } = string.Empty;
}

[InputType]
public partial class OneOfInput
{
public AddInput? Add { get; set; }

public RemoveInput? Remove { get; set; }
}

[InputType]
public partial class AddInput
{
public string Text { get; set; }
}

[InputType]
public partial class RemoveInput
{
public string Id { get; set; }
}

public class Db
{
public ConcurrentBag<Message> Messages { get; } = new();
public List<Message> Messages { get; } = new();
}
99 changes: 86 additions & 13 deletions src/GraphQL.Server.SourceGenerators/InputTypeEmitter.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
using Microsoft.CodeAnalysis;
using System.Runtime.Serialization;

using Microsoft.CodeAnalysis;
using System.Text;
using System.Text.Json;

using Microsoft.CodeAnalysis.CSharp;

namespace Tanka.GraphQL.Server.SourceGenerators;

public class InputTypeEmitter
public class InputTypeEmitter(SourceProductionContext context)
{
public const string ObjectTypeTemplate = """
public const string InputObjectTypeTemplate =
"""
/// <auto-generated/>
#nullable enable
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
Expand All @@ -31,28 +38,94 @@ public static class {{name}}InputTypeExtensions
}
}
{{parseableImplementation}}
#nullable restore
""";

public static string ParseMethodTemplate(string name, string parseMethod) =>
$$"""
public partial class {{name}}: IParseableInputObject
{
public void Parse(IReadOnlyDictionary<string, object?> argumentValue)
{
{{parseMethod}}
}
}
""";

public SourceProductionContext Context { get; }
public static string TrySetProperty(string fieldName, string name, string type) =>
$$"""
// {{name}} is an scalar type
if (argumentValue.TryGetValue("{{fieldName}}", out var {{fieldName}}Value))
{
{{name}} = {{fieldName}}Value as {{type}};
}
""";

public InputTypeEmitter(SourceProductionContext context)
{
Context = context;
}
public static string TrySetPropertyObjectValue(string fieldName, string name, string type) =>
$$"""
// {{name}} is an input object type
if (argumentValue.TryGetValue("{{fieldName}}", out var {{fieldName}}Value))
{
if ({{fieldName}}Value is null)
{
{{name}} = null;
}
else
{
if ({{fieldName}}Value is not IReadOnlyDictionary<string, object?> dictionaryValue)
throw new InvalidOperationException($"{{fieldName}} is not IReadOnlyDictionary<string, object?>");
{{name}} = new {{type}}();
if ({{name}} is not IParseableInputObject parseable)
throw new InvalidOperationException($"{{name}} is not IParseableInputObject");
parseable.Parse(dictionaryValue);
}
}
""";

public SourceProductionContext Context { get; } = context;

public void Emit(InputTypeDefinition definition)
{
var typeSDL = BuildTypeSdl(definition);

var typeSdl = BuildTypeSdl(definition);
var builder = new StringBuilder();
string ns = string.IsNullOrEmpty(definition.Namespace) ? "" : $"{definition.Namespace}";
builder.AppendLine(ObjectTypeTemplate
builder.AppendLine(InputObjectTypeTemplate
.Replace("{{namespace}}", string.IsNullOrEmpty(ns) ? "" : $"namespace {ns};")
.Replace("{{name}}", definition.TargetType)
.Replace("{{typeSDL}}", typeSDL)
.Replace("{{typeSDL}}", typeSdl)
.Replace("{{parseableImplementation}}", BuildParseMethod(definition))
);

Context.AddSource($"{ns}{definition.TargetType}InputType.g.cs", builder.ToString());
var sourceText = CSharpSyntaxTree.ParseText(builder.ToString())
.GetRoot()
.NormalizeWhitespace()
.ToFullString();

Context.AddSource($"{ns}{definition.TargetType}InputType.g.cs", sourceText);
}

private string BuildParseMethod(InputTypeDefinition definition)
{
var builder = new IndentedStringBuilder();
foreach (ObjectPropertyDefinition property in definition.Properties)
{
var typeName = property.ReturnType.Replace("?", "");
var fieldName = JsonNamingPolicy.CamelCase.ConvertName(property.Name);
if (property.ReturnTypeObject is not null)
{
builder.AppendLine(TrySetPropertyObjectValue(fieldName, property.Name, typeName));
}
else
{
builder.AppendLine(TrySetProperty(fieldName, property.Name, typeName));
}
}

return ParseMethodTemplate(definition.TargetType, builder.ToString());
}

private string BuildTypeSdl(InputTypeDefinition definition)
Expand Down
47 changes: 38 additions & 9 deletions src/GraphQL.Server.SourceGenerators/InputTypeParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,9 @@

namespace Tanka.GraphQL.Server.SourceGenerators;

public class InputTypeParser
public class InputTypeParser(GeneratorAttributeSyntaxContext context)
{
public GeneratorAttributeSyntaxContext Context { get; }

public InputTypeParser(GeneratorAttributeSyntaxContext context)
{
Context = context;
}
public GeneratorAttributeSyntaxContext Context { get; } = context;

public InputTypeDefinition ParseInputTypeDefinition(ClassDeclarationSyntax classDeclaration)
{
Expand All @@ -36,17 +31,18 @@ private List<ObjectPropertyDefinition> ParseMembers(ClassDeclarationSyntax clas

foreach (MemberDeclarationSyntax memberDeclarationSyntax in classDeclaration
.Members
.Where(m => CSharpExtensions.Any((SyntaxTokenList)m.Modifiers, SyntaxKind.PublicKeyword)))
.Where(m => ((SyntaxTokenList)m.Modifiers).Any(SyntaxKind.PublicKeyword)))
{
if (memberDeclarationSyntax.IsKind(SyntaxKind.PropertyDeclaration))
{
var propertyDeclaration = (PropertyDeclarationSyntax)memberDeclarationSyntax;
var typeSymbol = Context.SemanticModel.GetTypeInfo(propertyDeclaration.Type).Type;
var propertyDefinition = new ObjectPropertyDefinition()
{
Name = propertyDeclaration.Identifier.Text,
ReturnType = propertyDeclaration.Type.ToString(),
ClosestMatchingGraphQLTypeName = GetClosestMatchingGraphQLTypeName(TypeHelper.UnwrapTaskType(propertyDeclaration.Type)),
IsNullable = TypeHelper.IsTypeNullable(propertyDeclaration.Type),
ReturnTypeObject = typeSymbol != null ? TryParseInputTypeDefinition(typeSymbol): null
};
properties.Add(propertyDefinition);
}
Expand All @@ -55,6 +51,39 @@ private List<ObjectPropertyDefinition> ParseMembers(ClassDeclarationSyntax clas
return properties;
}

private InputTypeDefinition? TryParseInputTypeDefinition(ITypeSymbol namedTypeSymbol)
{
if (namedTypeSymbol.TypeKind != TypeKind.Class)
return null;

if (namedTypeSymbol.SpecialType is not SpecialType.None)
return null;

var properties = GetPublicProperties(namedTypeSymbol)
.Select(property => new ObjectPropertyDefinition()
{
Name = property.Name,
ReturnType = property.Type.ToString(),
ClosestMatchingGraphQLTypeName = GetClosestMatchingGraphQLTypeName(property.Type),
})
.ToList();

return new InputTypeDefinition() { Properties = properties };

static IEnumerable<IPropertySymbol> GetPublicProperties(ITypeSymbol typeSymbol)
{
return typeSymbol.GetMembers()
.Where(member => member.Kind == SymbolKind.Property)
.Cast<IPropertySymbol>()
.Where(property => property.DeclaredAccessibility == Accessibility.Public);
}
}

private string GetClosestMatchingGraphQLTypeName(ITypeSymbol typeSymbol)
{
var typeName = TypeHelper.GetGraphQLTypeName(typeSymbol);
return typeName;
}


private string GetClosestMatchingGraphQLTypeName(TypeSyntax typeSyntax)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public record ObjectPropertyDefinition

public string ReturnType { get; init; }

Check warning on line 7 in src/GraphQL.Server.SourceGenerators/ObjectPropertyDefinition.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'ReturnType' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

public bool IsNullable { get; init; }
public string ClosestMatchingGraphQLTypeName { get; set; }

Check warning on line 9 in src/GraphQL.Server.SourceGenerators/ObjectPropertyDefinition.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'ClosestMatchingGraphQLTypeName' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

public InputTypeDefinition? ReturnTypeObject { get; set; }
}
1 change: 0 additions & 1 deletion src/GraphQL.Server.SourceGenerators/ObjectTypeParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ private static (List<ObjectPropertyDefinition> Properties, List<ObjectMethodDefi
Name = propertyDeclaration.Identifier.Text,
ReturnType = propertyDeclaration.Type.ToString(),
ClosestMatchingGraphQLTypeName = GetClosestMatchingGraphQLTypeName(context.SemanticModel, TypeHelper.UnwrapTaskType(propertyDeclaration.Type)),
IsNullable = TypeHelper.IsTypeNullable(propertyDeclaration.Type),
};
properties.Add(propertyDefinition);
}
Expand Down
20 changes: 15 additions & 5 deletions src/GraphQL/Fields/ArgumentBinderFeature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

namespace Tanka.GraphQL.Fields;

public interface IParseableInputObject
{
void Parse(IReadOnlyDictionary<string, object?> argumentValue);
}

public class ArgumentBinderFeature : IArgumentBinderFeature
{
public bool HasArgument(ResolverContextBase context, string name)
Expand All @@ -17,12 +22,11 @@ public bool HasArgument(ResolverContextBase context, string name)
object? argument = context.ArgumentValues[name];

if (argument is null)
return default(T?);

return default;
if (argument is not IReadOnlyDictionary<string, object?> inputObjectArgumentValue)
throw new InvalidOperationException("Argument is not an input object");



var target = new T();

BindInputObject<T>(inputObjectArgumentValue, target);
Expand All @@ -34,7 +38,7 @@ public bool HasArgument(ResolverContextBase context, string name)
object? argument = context.ArgumentValues[name];

if (argument is null)
return default(IEnumerable<T?>?);
return default;

if (argument is not IEnumerable<IReadOnlyDictionary<string, object?>?> inputObjectArgumentValue)
throw new InvalidOperationException("Argument is not an input object list");
Expand All @@ -59,6 +63,12 @@ public bool HasArgument(ResolverContextBase context, string name)

public static void BindInputObject<T>(IReadOnlyDictionary<string, object?> inputObject, T target)
{
if (target is IParseableInputObject parseable)
{
parseable.Parse(inputObject);
return;
}

IReadOnlyDictionary<string, IPropertyAdapter<T>> properties = PropertyAdapterFactory.GetPropertyAdapters<T>();

//todo: do we need the input object fields in here for validation
Expand Down
Loading

0 comments on commit b2ef73c

Please sign in to comment.