diff --git a/ShopifySharp.GraphQL.Parser.CLI/GenerateCommand.cs b/ShopifySharp.GraphQL.Parser.CLI/GenerateCommand.cs index 34914523..95e1356e 100644 --- a/ShopifySharp.GraphQL.Parser.CLI/GenerateCommand.cs +++ b/ShopifySharp.GraphQL.Parser.CLI/GenerateCommand.cs @@ -16,19 +16,29 @@ public async Task ExecuteAsync(GenerateOptions options, CancellationToken c return 1; } - if (!Directory.Exists(options.Output)) - Directory.CreateDirectory(options.Output); + if (!Directory.Exists(options.TypesPath)) + Directory.CreateDirectory(options.TypesPath); + + if (!Directory.Exists(options.QueryBuildersPath)) + Directory.CreateDirectory(options.QueryBuildersPath); var casingType = options.CasingType switch { "camel" => Casing.Camel, _ => Casing.Pascal }; - var destination = FileSystemDestination.NewDirectory(options.Output); + var typesDestination = FileSystemDestination.NewDirectory(options.TypesPath); + var queryBuildersDestination = FileSystemDestination.NewDirectory(options.QueryBuildersPath); var mem = (await File.ReadAllTextAsync(options.GraphqlFilePath, cancellationToken)).AsMemory(); - await Parser.ParseAndWriteAsync(destination, casingType, options.Nullability == true, mem, CancellationToken.None); + await Parser.ParseAndWriteAsync( + typesDestination, + queryBuildersDestination, + casingType, + options.Nullability == true, + mem, + CancellationToken.None); return 0; } diff --git a/ShopifySharp.GraphQL.Parser.CLI/GenerateOptions.cs b/ShopifySharp.GraphQL.Parser.CLI/GenerateOptions.cs index d0890ec5..7f8646b3 100644 --- a/ShopifySharp.GraphQL.Parser.CLI/GenerateOptions.cs +++ b/ShopifySharp.GraphQL.Parser.CLI/GenerateOptions.cs @@ -7,8 +7,11 @@ namespace ShopifySharp.GraphQL.Parser.CLI; [Verb("parse", aliases: ["generate"], HelpText = "Reads a .graphql file and generates C# classes, enums and input types compatible with ShopifySharp.")] public record GenerateOptions { - [Option('o', "output", Required = true, HelpText = "Output directory for generated C# files")] - public required string Output { get; init; } + [Option('t', "types-dir", Required = true, HelpText = "Output directory for generated type files")] + public required string TypesPath { get; init; } + + [Option('b', "builders-dir", Required = true, HelpText = "Output directory for generated QueryBuilder files")] + public required string QueryBuildersPath { get; init; } [Value(0, MetaName = "", HelpText = "Path to the GraphQL schema file to convert. Both JSON and GraphQL are accepted.")] public required string GraphqlFilePath { get; init; } diff --git a/ShopifySharp.GraphQL.Parser.Tests/DomainTests.fs b/ShopifySharp.GraphQL.Parser.Tests/DomainTests.fs index 76a98e45..85661da6 100644 --- a/ShopifySharp.GraphQL.Parser.Tests/DomainTests.fs +++ b/ShopifySharp.GraphQL.Parser.Tests/DomainTests.fs @@ -1,7 +1,6 @@ module ShopifySharp.GraphQL.Parser.Tests.DomainTests open System.Threading -open FakeItEasy open Faqt open Faqt.Operators open GraphQLParser.Visitors @@ -9,6 +8,13 @@ open Xunit open ShopifySharp.GraphQL.Parser type DomainTests() = + let schema = """ + type TestType { + id: ID! + name: String + } + """ + let document = GraphQLParser.Parser.Parse(schema) [] [] @@ -35,7 +41,7 @@ type DomainTests() = member _.``ParserContext.CancellationToken should return correct token``() = // Setup let cancellationToken = CancellationToken() - let sut = ParserContext(Pascal, false, cancellationToken) + let sut = ParserContext(Pascal, false, document, cancellationToken) // Act & Assert %sut.CancellationToken.Should().Be(cancellationToken) @@ -53,7 +59,7 @@ type DomainTests() = | "camel" -> Camel | _ -> failwithf $"Unhandled {nameof casingType} value \"{casingType}\"" let cancellationToken = CancellationToken.None - let sut = ParserContext(casing, false, cancellationToken) + let sut = ParserContext(casing, false, document, cancellationToken) // Act & Assert %sut.CasingType.Should().Be(casing) @@ -65,7 +71,7 @@ type DomainTests() = ) = // Setup let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, assumeNullability, cancellationToken) + let sut = ParserContext(Pascal, assumeNullability, document, cancellationToken) let context = sut :> IParsedContext // Act & Assert @@ -75,7 +81,7 @@ type DomainTests() = member _.``ParserContext SetVisitedType should add type to collection``() = // Setup let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) + let sut = ParserContext(Pascal, false, document, cancellationToken) let testClass = VisitedTypes.Class { Name = "TestClass" XmlSummary = [||] @@ -95,59 +101,11 @@ type DomainTests() = | VisitedTypes.Class c -> %c.Name.Should().Be("TestClass") | _ -> failwith "Expected Class type" - [] - member _.``ParserContext AddUnionRelationship should add relationship``() = - // Setup - let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) - let context = sut :> IParsedContext - let unionName = "TestUnion" - let unionCases = [| "Case1"; "Case2" |] - - // Act - sut.AddUnionRelationship unionName unionCases - - // Assert - for unionCase in unionCases do - %context.TypeIsKnownUnionCase(unionCase).Should().BeTrue() - - [] - member _.``ParserContext TryFindUnionRelationship should return relationship when exists``() = - // Setup - let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) - let context = sut :> IParsedContext - let unionName = "TestUnion" - let unionCase = "TestCase" - - sut.AddUnionRelationship unionName [| unionCase |] - - // Act - let result = context.TryFindUnionRelationship unionCase - - // Assert - %result.Should().BeSome() - %result.Value.UnionTypeName.Should().Be(unionName) - %result.Value.UnionCaseName.Should().Be(unionCase) - - [] - member _.``ParserContext TryFindUnionRelationship should return None when not exists``() = - // Setup - let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) - let context = sut :> IParsedContext - - // Act - let result = context.TryFindUnionRelationship "NonExistentCase" - - // Assert - %result.Should().BeNone() - [] member _.``ParserContext AddNamedType should add type``() = // Setup let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) + let sut = ParserContext(Pascal, false, document, cancellationToken) let context = sut :> IParsedContext let namedType = NamedType.Class "TestClass" @@ -161,7 +119,7 @@ type DomainTests() = member _.``ParserContext IsNamedType should return false when type not added``() = // Setup let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) + let sut = ParserContext(Pascal, false, document, cancellationToken) let context = sut :> IParsedContext let namedType = NamedType.Class "TestClass" @@ -172,7 +130,7 @@ type DomainTests() = member _.``ParserContext TypeIsKnownUnionCase should return false when case not added``() = // Setup let cancellationToken = CancellationToken.None - let sut = ParserContext(Pascal, false, cancellationToken) + let sut = ParserContext(Pascal, false, document, cancellationToken) let context = sut :> IParsedContext // Act & Assert diff --git a/ShopifySharp.GraphQL.Parser.Tests/IntegrationTests.fs b/ShopifySharp.GraphQL.Parser.Tests/IntegrationTests.fs index 8f6de4cd..d6a24570 100644 --- a/ShopifySharp.GraphQL.Parser.Tests/IntegrationTests.fs +++ b/ShopifySharp.GraphQL.Parser.Tests/IntegrationTests.fs @@ -70,6 +70,7 @@ type IntegrationTests() = | VisitedTypes.Enum e -> e.Name | VisitedTypes.InputObject io -> io.Name | VisitedTypes.UnionType u -> u.Name + | Operation o -> o.Name ) %typeNames.Should().Contain("INode") diff --git a/ShopifySharp.GraphQL.Parser.Tests/ParserTests.fs b/ShopifySharp.GraphQL.Parser.Tests/ParserTests.fs index 374d64d1..cd6df94f 100644 --- a/ShopifySharp.GraphQL.Parser.Tests/ParserTests.fs +++ b/ShopifySharp.GraphQL.Parser.Tests/ParserTests.fs @@ -82,6 +82,7 @@ type ParserTests() = | VisitedTypes.Enum e -> e.Name | VisitedTypes.InputObject io -> io.Name | VisitedTypes.UnionType u -> u.Name + | Operation o -> o.Name ) %typeNames.Should().Contain("User") %typeNames.Should().Contain("UserStatus") @@ -114,6 +115,7 @@ type ParserTests() = | VisitedTypes.Enum e -> e.Name | VisitedTypes.InputObject io -> io.Name | VisitedTypes.UnionType u -> u.Name + | VisitedTypes.Operation o -> o.Name ) %typeNames.Should().Contain("INode") diff --git a/ShopifySharp.GraphQL.Parser.Tests/VisitorTests.fs b/ShopifySharp.GraphQL.Parser.Tests/VisitorTests.fs index 8e0a6478..4bdc618f 100644 --- a/ShopifySharp.GraphQL.Parser.Tests/VisitorTests.fs +++ b/ShopifySharp.GraphQL.Parser.Tests/VisitorTests.fs @@ -25,9 +25,6 @@ type VisitorTests() = ) = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - // Create a simple GraphQL schema with the type let schema = $""" type {typeName} {{ @@ -36,6 +33,8 @@ type VisitorTests() = }} """ let document = parseDocument(schema) + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -58,15 +57,14 @@ type VisitorTests() = member _.``Visitor should handle GraphQL interface definition``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ interface Node { id: ID! } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -90,9 +88,6 @@ type VisitorTests() = member _.``Visitor should handle GraphQL enum definition``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ enum UserStatus { ACTIVE @@ -101,6 +96,8 @@ type VisitorTests() = } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -127,9 +124,6 @@ type VisitorTests() = member _.``Visitor should handle GraphQL input object definition``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ input UserInput { name: String! @@ -138,6 +132,8 @@ type VisitorTests() = } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -164,21 +160,20 @@ type VisitorTests() = member _.``Visitor should handle GraphQL union type definition``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type User { id: ID! } - + type Product { id: ID! } - + union SearchResult = User | Product """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -195,17 +190,14 @@ type VisitorTests() = ) %unionType.Should().BeSome() - %unionType.Value.Types.Should().HaveLength(2) - %unionType.Value.Types.Should().Contain("User") - %unionType.Value.Types.Should().Contain("Product") + %unionType.Value.Cases.Should().HaveLength(2) + %unionType.Value.Cases.Should().ContainExactlyOneItemMatching(fun x -> x.Name = "User") + %unionType.Value.Cases.Should().ContainExactlyOneItemMatching(fun x -> x.Name = "Product") [] member _.``Visitor should handle type with deprecated field``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type User { id: ID! @@ -215,6 +207,8 @@ type VisitorTests() = } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -238,9 +232,6 @@ type VisitorTests() = member _.``Visitor should handle type with field descriptions``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type User { "The unique identifier for the user" @@ -250,6 +241,8 @@ type VisitorTests() = } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -278,15 +271,14 @@ type VisitorTests() = ) = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = $""" type TestType {{ field: {scalarType} }} """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -308,20 +300,19 @@ type VisitorTests() = member _.``Visitor should handle type implementing interface``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ interface Node { id: ID! } - + type User implements Node { id: ID! name: String } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -342,29 +333,28 @@ type VisitorTests() = member _.``Visitor should handle connection type with both nodes and edges fields``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type UserConnection { nodes: [User] edges: [UserEdge] pageInfo: PageInfo } - + type User { id: ID! } - + type UserEdge { node: User } - + type PageInfo { hasNextPage: Boolean } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -389,24 +379,23 @@ type VisitorTests() = member _.``Visitor should handle connection type with only nodes field``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type UserConnection { nodes: [User] pageInfo: PageInfo } - + type User { id: ID! } - + type PageInfo { hasNextPage: Boolean } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -431,28 +420,27 @@ type VisitorTests() = member _.``Visitor should handle connection type with only edges field``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type UserConnection { edges: [UserEdge] pageInfo: PageInfo } - + type UserEdge { node: User } - + type User { id: ID! } - + type PageInfo { hasNextPage: Boolean } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -477,20 +465,19 @@ type VisitorTests() = member _.``Visitor should handle connection type with neither nodes nor edges fields``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type UserConnection { pageInfo: PageInfo totalCount: Int } - + type PageInfo { hasNextPage: Boolean } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -515,29 +502,28 @@ type VisitorTests() = member _.``Visitor should handle connection type with case insensitive field names``() = // Setup let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) - let sut = Visitor() - let schema = """ type UserConnection { NODES: [User] Edges: [UserEdge] pageInfo: PageInfo } - + type User { id: ID! } - + type UserEdge { node: User } - + type PageInfo { hasNextPage: Boolean } """ let document = parseDocument schema + let context = ParserContext(Pascal, false, document, cancellationToken) + let sut = Visitor() // Act let task = sut.VisitAsync(document, context) @@ -553,7 +539,7 @@ type VisitorTests() = %connectionType.Should().BeSome() match connectionType.Value.KnownInheritedType with - | Some (Connection (ConnectionWithNodesAndEdges (nodesType, edgesType))) -> - %true.Should().BeTrue() // Test passes - case insensitive matching works + | Some (Connection (ConnectionWithNodesAndEdges _)) -> + %true.Should().BeTrue() // Test passes - case-insensitive matching works | _ -> %false.Should().BeTrue() // Should have ConnectionWithNodesAndEdges diff --git a/ShopifySharp.GraphQL.Parser.Tests/WriterTests.fs b/ShopifySharp.GraphQL.Parser.Tests/WriterTests.fs index 032e84ae..195fa16b 100644 --- a/ShopifySharp.GraphQL.Parser.Tests/WriterTests.fs +++ b/ShopifySharp.GraphQL.Parser.Tests/WriterTests.fs @@ -10,10 +10,18 @@ open ShopifySharp.GraphQL.Parser open ShopifySharp.GraphQL.Parser.Writer type WriterTests() = + let schema = """ + type TestType { + id: ID! + name: String + } + """ + let document = GraphQLParser.Parser.Parse(schema) let createTestContext casing assumeNullability = let cancellationToken = CancellationToken.None - let context = ParserContext(casing, assumeNullability, cancellationToken) + + let context = ParserContext(casing, assumeNullability, document, cancellationToken) // Add some test types let testClass = VisitedTypes.Class { @@ -186,7 +194,7 @@ type WriterTests() = let tempFile = Path.GetTempFileName() let destination = SingleFile tempFile let cancellationToken = CancellationToken.None - let context = ParserContext(Pascal, false, cancellationToken) + let context = ParserContext(Pascal, false, document, cancellationToken) try // Act @@ -263,7 +271,7 @@ type WriterTests() = let tempFile = Path.GetTempFileName() let destination = SingleFile tempFile let cancellationTokenSource = new CancellationTokenSource() - let context = ParserContext(Pascal, false, cancellationTokenSource.Token) + let context = ParserContext(Pascal, false, document, cancellationTokenSource.Token) cancellationTokenSource.Cancel() try diff --git a/ShopifySharp.GraphQL.Parser/AstNodeMapper.fs b/ShopifySharp.GraphQL.Parser/AstNodeMapper.fs new file mode 100644 index 00000000..e6cbc862 --- /dev/null +++ b/ShopifySharp.GraphQL.Parser/AstNodeMapper.fs @@ -0,0 +1,351 @@ +namespace ShopifySharp.GraphQL.Parser + +open System +open System.Runtime.CompilerServices +open GraphQLParser +open GraphQLParser.AST +open FSharp.Span.Utils.SafeLowLevelOperators +open FSharp.Span.Utils +open Utils + +module AstNodeMapper = + type private Presence + = Present of fieldType: GraphQLType + | NotPresent + + type private FieldsDefinition = + | InputFields of inputFields: GraphQLInputFieldsDefinition + | ObjectFields of xFields: GraphQLFieldsDefinition + + let mapValueTypeToString (isNamedType: NamedType -> bool) = function + | FieldValueType.ULong -> "ulong" + | FieldValueType.Long -> "long" + | FieldValueType.Int -> "int" + | FieldValueType.Decimal -> "decimal" + | FieldValueType.Float -> "float" + | FieldValueType.Boolean -> "bool" + | FieldValueType.String -> "string" + | FieldValueType.DateTime -> "DateTime" + | FieldValueType.DateOnly -> "DateOnly" + | FieldValueType.TimeSpan -> "TimeSpan" + | FieldValueType.GraphObjectType graphObjectTypeName -> + if isNamedType (NamedType.Interface graphObjectTypeName) + then mapStrToInterfaceName graphObjectTypeName + else graphObjectTypeName + + let rec unwrapFieldType = function + | ValueType valueType -> valueType + | NullableType valueType -> unwrapFieldType valueType + | NonNullableType valueType -> unwrapFieldType valueType + | CollectionType collectionType -> unwrapFieldType collectionType + + let rec mapFieldTypeToString (isNamedType: NamedType -> bool) assumeNullability (valueType: FieldType) (collectionHandling: FieldTypeCollectionHandling) = + let maybeWriteNullability isNullable fieldStr = + fieldStr + (if isNullable then "?" else "") + + let rec unwrapType isRecursing = function + | ValueType valueType + | NonNullableType (ValueType valueType) -> + mapValueTypeToString isNamedType valueType + |> maybeWriteNullability (not isRecursing && assumeNullability) + | NullableType (ValueType valueType) -> + mapValueTypeToString isNamedType valueType + |> maybeWriteNullability true + | NonNullableType (CollectionType collectionType) // We unwrap this one twice because CollectionTypes are all (NonNullable (CollectionType Type)) in GraphQL + | CollectionType collectionType -> + let mappedType = unwrapType true collectionType + match collectionHandling with + | KeepCollection -> $"ICollection<{mappedType}>" + | UnwrapCollection -> mappedType + |> maybeWriteNullability (not isRecursing && assumeNullability) + | NonNullableType nonNullableType -> + unwrapType true nonNullableType + | NullableType nullableType -> + unwrapFieldType nullableType + |> mapValueTypeToString isNamedType + |> maybeWriteNullability true + + unwrapType false valueType + + let rec private mapGraphTypeToName (fieldType: GraphQLType): string = + match fieldType with + | :? GraphQLNamedType as namedType -> + namedType.Name.StringValue + | :? GraphQLListType as listType -> + mapGraphTypeToName listType.Type + | :? GraphQLNonNullType as nonNullType -> + mapGraphTypeToName nonNullType.Type + | _ -> + raise (SwitchExpressionException fieldType) + + let private typeMap: Map = Map.ofList [ + "UnsignedInt64", FieldValueType.ULong + "Money", FieldValueType.Decimal + "Decimal", FieldValueType.Decimal + "Float", FieldValueType.Decimal + "DateTime", FieldValueType.DateTime // GraphQL DateTimes are always UTC + "Date", FieldValueType.DateOnly + "UtcOffset", FieldValueType.TimeSpan + "URL", FieldValueType.String + "HTML", FieldValueType.String + "JSON", FieldValueType.String + "FormattedString", FieldValueType.String + "ARN", FieldValueType.String + "StorefrontID", FieldValueType.String + "Color", FieldValueType.String + "BigInt", FieldValueType.Long + "String", FieldValueType.String + "Boolean", FieldValueType.Boolean + "Integer", FieldValueType.Int + "Int", FieldValueType.Int + "ID", FieldValueType.String + ] + + let rec private mapGraphTypeToFieldType (fieldType: GraphQLType): FieldType = + match fieldType with + | :? GraphQLNamedType as namedType -> + Map.tryFind namedType.Name.StringValue typeMap + |> Option.defaultWith (fun _ -> FieldValueType.GraphObjectType namedType.Name.StringValue) + |> FieldType.ValueType + | :? GraphQLListType as listType -> + mapGraphTypeToFieldType listType.Type + |> FieldType.CollectionType + | :? GraphQLNonNullType as nonNullType -> + mapGraphTypeToFieldType nonNullType.Type + |> FieldType.NonNullableType + | _ -> + raise (SwitchExpressionException fieldType) + + let private removeNewLines (value: char readonlyspan): string = + let newlineChar = '\n'; + let spanValue = value; + let hasNewLine = spanValue.Contains(newlineChar) + + if not hasNewLine then + value.ToString() + else + let destination: char span = stackalloc spanValue.Length + value.Replace(destination, newlineChar, ' '); + destination.ToString(); + + let private getDeprecationMessage (directives: GraphQLDirectives | null): string option = + if isNull directives + then None + else directives.Items + |> Seq.tryFind (fun i -> i.Name.StringValue = "deprecated") + |> Option.bind (fun deprecation -> + deprecation.Arguments.Items + |> Seq.tryPick (fun arg -> + if arg.Name.StringValue = "reason" && arg.Value.Kind = ASTNodeKind.StringValue + then Some (arg.Value :?> GraphQLStringValue) + else None + ) + |> Option.map (fun reason -> removeNewLines reason.Value.Span) + ) + + let private mapDescriptionToXmlSummary (description: GraphQLDescription): string[] = + if isNull description then + Array.empty + else + // Split the description on each new line + let segments = description.Value.Span.ToString().Split([|Environment.NewLine|], StringSplitOptions.RemoveEmptyEntries) + + [| + yield "/// " + for segment in segments do + yield $"/// {segment}" + yield "/// " + |] + + let private mapToArguments (argument: GraphQLArgumentsDefinition | null): FieldOrOperationArgument[] = + if isNull argument then + Array.empty + else + argument.Items + |> Array.ofSeq + |> Array.map (fun argument -> + { Name = argument.Name.StringValue + Deprecation = getDeprecationMessage argument.Directives + XmlSummary = mapDescriptionToXmlSummary argument.Description + ValueType = mapGraphTypeToFieldType argument.Type }) + + let private mapToFields (fieldsDefinition: FieldsDefinition): Field[] = + let createField (fieldType: FieldType) name directives description arguments = + { Name = name + XmlSummary = mapDescriptionToXmlSummary description + Deprecation = getDeprecationMessage directives + Arguments = arguments + ValueType = fieldType } + + match fieldsDefinition with + | InputFields inputFields -> + inputFields.Items + |> Array.ofSeq + |> Array.map (fun field -> + let fieldType = mapGraphTypeToFieldType field.Type + createField fieldType field.Name.StringValue field.Directives field.Description Array.empty) + | ObjectFields objectFields -> + objectFields.Items + |> Array.ofSeq + |> Array.map (fun field -> + let fieldType = mapGraphTypeToFieldType field.Type + createField fieldType field.Name.StringValue field.Directives field.Description (mapToArguments field.Arguments)) + + let private getBestConnectionTypeInterfaceName (fields: GraphQLFieldsDefinition): ConnectionType = + let check (nodesFieldPresence: Presence, edgesFieldPresence: Presence) (field: GraphQLFieldDefinition) = + if nodesFieldPresence.IsPresent && edgesFieldPresence.IsPresent + then nodesFieldPresence, edgesFieldPresence + else match field.Name.StringValue.ToLowerInvariant() with + | "nodes" -> Present field.Type, edgesFieldPresence + | "edges" -> nodesFieldPresence, Present field.Type + | _ -> nodesFieldPresence, edgesFieldPresence + + let nodeFieldPresence, edgesFieldPresence = + fields.Items + |> Seq.fold check (NotPresent, NotPresent) + + match nodeFieldPresence, edgesFieldPresence with + | Present nodesFieldType, Present edgesFieldType -> + ConnectionWithNodesAndEdges (mapGraphTypeToFieldType nodesFieldType, mapGraphTypeToFieldType edgesFieldType) + | Present nodesFieldType, NotPresent -> + ConnectionWithNodes (mapGraphTypeToFieldType nodesFieldType) + | NotPresent, Present edgesFieldType -> + ConnectionWithEdges (mapGraphTypeToFieldType edgesFieldType) + | NotPresent, NotPresent -> + ConnectionType.Connection + + let private mapToUnionTypeMemberNames (unionMemberTypes: GraphQLUnionMemberTypes): string[] = + unionMemberTypes.Items + |> Array.ofSeq + |> Array.map _.Name.StringValue + + let private strToInterfaceName = + sprintf "I%s" + + let rec private mapToInheritedTypeNames (implementsInterface: GraphQLImplementsInterfaces): string[] = + if isNull implementsInterface then + Array.empty + else + implementsInterface.Items + |> Array.ofSeq + |> Array.map (_.Name.StringValue >> strToInterfaceName) + + let mapObjectTypeDefinition (objectTypeDefinition: GraphQLObjectTypeDefinition): Class = + let objectTypeName = objectTypeDefinition.Name.StringValue + + let classInheritedType = + if objectTypeName.EndsWith("Edge", StringComparison.Ordinal) then + Some Edge + else if objectTypeName.EndsWith("Connection", StringComparison.OrdinalIgnoreCase) then + getBestConnectionTypeInterfaceName objectTypeDefinition.Fields + |> Connection + |> Some + else + None + + { Name = objectTypeDefinition.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary objectTypeDefinition.Description + Deprecation = getDeprecationMessage objectTypeDefinition.Directives + Fields = mapToFields (ObjectFields objectTypeDefinition.Fields) + KnownInheritedType = classInheritedType + InheritedTypeNames = mapToInheritedTypeNames objectTypeDefinition.Interfaces } + + let mapInterfaceTypeDefinition (interfaceTypeDefinition: GraphQLInterfaceTypeDefinition): Interface = + { Name = strToInterfaceName interfaceTypeDefinition.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary interfaceTypeDefinition.Description + Deprecation = getDeprecationMessage interfaceTypeDefinition.Directives + Fields = mapToFields (ObjectFields interfaceTypeDefinition.Fields) + InheritedTypeNames = mapToInheritedTypeNames interfaceTypeDefinition.Interfaces } + + let mapEnumCases (enumValuesDefinition: GraphQLEnumValuesDefinition): VisitedEnumCase[] = + enumValuesDefinition.Items + |> Array.ofSeq + |> Array.map (fun enumCase -> + { Name = enumCase.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary enumCase.Description + Deprecation = getDeprecationMessage enumCase.Directives + Value = match enumCase.EnumValue.Name.StringValue with + | value when String.IsNullOrWhiteSpace value -> None + | value when value = enumCase.Name.StringValue -> None + | value -> Some value }) + + let mapEnumTypeDefinition (enumTypeDefinition: GraphQLEnumTypeDefinition): VisitedEnum = + { Name = enumTypeDefinition.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary enumTypeDefinition.Description + Deprecation = getDeprecationMessage enumTypeDefinition.Directives + Cases = mapEnumCases enumTypeDefinition.Values } + + let mapInputObjectTypeDefinition (inputObjectTypeDefinition: GraphQLInputObjectTypeDefinition): InputObject = + { Name = inputObjectTypeDefinition.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary inputObjectTypeDefinition.Description + Deprecation = getDeprecationMessage inputObjectTypeDefinition.Directives + Fields = mapToFields (InputFields inputObjectTypeDefinition.Fields) } + + let rec mapUnionTypeDefinition (context: IParsedContext) (unionTypeDefinition: GraphQLUnionTypeDefinition): UnionType = + let unionCaseNodes = [| + for unionCaseName in unionTypeDefinition.Types do + match context.TryFindDocumentNode unionCaseName.Name.Value with + | None -> failwith $"Could not find union case with name {unionCaseName.Name.StringValue}" + | Some caseNode -> yield caseNode + |] + + { Name = unionTypeDefinition.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary unionTypeDefinition.Description + Deprecation = getDeprecationMessage unionTypeDefinition.Directives + Cases = Array.map (map context) unionCaseNodes } + + and tryMap (context: IParsedContext) (node: ASTNode): VisitedTypes option = + match node with + | :? GraphQLUnionTypeDefinition as unionType -> + VisitedTypes.UnionType (mapUnionTypeDefinition context unionType) + |> Some + | :? GraphQLInputObjectTypeDefinition as input -> + VisitedTypes.InputObject (mapInputObjectTypeDefinition input) + |> Some + | :? GraphQLEnumTypeDefinition as enum -> + VisitedTypes.Enum (mapEnumTypeDefinition enum) + |> Some + | :? GraphQLInterfaceTypeDefinition as interface' -> + VisitedTypes.Interface (mapInterfaceTypeDefinition interface') + |> Some + | :? GraphQLObjectTypeDefinition as objectType -> + VisitedTypes.Class (mapObjectTypeDefinition objectType) + |> Some + | _ -> + None + + and map (context: IParsedContext) (node: ASTNode): VisitedTypes = + match tryMap context node with + | Some mappedType -> mappedType + | None -> raise (SwitchExpressionException(node.GetType())) + + let mapRootFieldDefinition (fieldDefinition: GraphQLFieldDefinition) (context: IParsedContext): QueryOrMutation = + let returnTypeName = + mapGraphTypeToName fieldDefinition.Type + |> _.ToCharArray() + |> ROM + + let returnType = + if typeMap.ContainsKey (returnTypeName.ToString()) then + mapGraphTypeToFieldType fieldDefinition.Type + |> ReturnType.FieldType + else + let returnType = context.Document.Definitions.Find(function + | :? GraphQLInterfaceTypeDefinition as interfaceType -> interfaceType.Name.Value = returnTypeName + | :? GraphQLObjectTypeDefinition as objectType -> objectType.Name.Value = returnTypeName + | :? GraphQLUnionTypeDefinition as unionType -> unionType.Name.Value = returnTypeName + | :? GraphQLEnumTypeDefinition as enumType -> enumType.Name.Value = returnTypeName + | :? GraphQLName as graphTypeName -> graphTypeName.Value = returnTypeName + | _ -> false) + + if isNull returnType then + failwith $"Failed to locate return type {fieldDefinition.Type}" + + map context returnType + |> ReturnType.VisitedType + + { Name = fieldDefinition.Name.StringValue + XmlSummary = mapDescriptionToXmlSummary fieldDefinition.Description + Deprecation = getDeprecationMessage fieldDefinition.Directives + Arguments = mapToArguments fieldDefinition.Arguments + ReturnType = returnType } diff --git a/ShopifySharp.GraphQL.Parser/Domain.fs b/ShopifySharp.GraphQL.Parser/Domain.fs index dacc79e2..cb76634d 100644 --- a/ShopifySharp.GraphQL.Parser/Domain.fs +++ b/ShopifySharp.GraphQL.Parser/Domain.fs @@ -1,6 +1,8 @@ namespace ShopifySharp.GraphQL.Parser open System.Collections.Generic +open GraphQLParser +open GraphQLParser.AST open GraphQLParser.Visitors type Casing @@ -10,6 +12,19 @@ type Casing type Indentation = Outdented | Indented + | DoubleIndented + | TripleIndented + with + override x.ToString() = + match x with + | Outdented -> "" + | Indented -> "\t" + | DoubleIndented -> "\t\t" + | TripleIndented -> "\t\t\t" + static member (+) (x, str: string) = + x.ToString() + str + static member (+) (x: Indentation, y: Indentation) = + x.ToString() + y.ToString() type FieldTypeCollectionHandling = UnwrapCollection @@ -57,7 +72,7 @@ type ClassInheritedType = type IVisitedType = interface end -type FieldArgument = +type FieldOrOperationArgument = { Name: string XmlSummary: string[] Deprecation: string option @@ -67,7 +82,7 @@ type Field = { Name: string XmlSummary: string[] Deprecation: string option - Arguments: FieldArgument[] + Arguments: FieldOrOperationArgument[] ValueType: FieldType } type Interface = @@ -87,13 +102,6 @@ type Class = InheritedTypeNames: string [] } with interface IVisitedType -type UnionType = - { Name: string - XmlSummary: string[] - Deprecation: string option - Types: string[] } - with interface IVisitedType - type InputObject = { Name: string XmlSummary: string[] @@ -114,22 +122,51 @@ type VisitedEnum = Cases: VisitedEnumCase[] } with interface IVisitedType -type UnionRelationship = - { UnionTypeName: string - UnionCaseName: string } - type InterfaceRelationship = { InterfaceName: string ImplementationName: string } -type VisitedTypes = +type UnionType = + { Name: string + XmlSummary: string[] + Deprecation: string option + Cases: VisitedTypes[] } + with interface IVisitedType +and QueryOrMutation = + { Name: string + XmlSummary: string[] + Deprecation: string option + Arguments: FieldOrOperationArgument[] + ReturnType: ReturnType } + with interface IVisitedType +and [] ReturnType = + | VisitedType of VisitedTypes + | FieldType of FieldType +and VisitedTypes = | Class of class': Class | Interface of interface': Interface | Enum of enum': VisitedEnum | InputObject of inputObject: InputObject | UnionType of unionType: UnionType + | Operation of operation: QueryOrMutation + with + member x.Name = + match x with + | VisitedTypes.Class class' -> class'.Name + | VisitedTypes.Interface interface' -> interface'.Name + | VisitedTypes.Enum enum' -> enum'.Name + | VisitedTypes.InputObject inputObject' -> inputObject'.Name + | VisitedTypes.UnionType unionType' -> unionType'.Name + | VisitedTypes.Operation operation -> operation.Name + member x.Deprecation = + match x with + | VisitedTypes.Class class' -> class'.Deprecation + | VisitedTypes.Interface interface' -> interface'.Deprecation + | VisitedTypes.Enum enum' -> enum'.Deprecation + | VisitedTypes.InputObject inputObject -> inputObject.Deprecation + | VisitedTypes.UnionType unionType -> unionType.Deprecation + | VisitedTypes.Operation operation -> operation.Deprecation -[] type NamedType = | Class of name: string | Interface of name: string @@ -147,14 +184,16 @@ type NamedType = type IParsedContext = abstract member CasingType: Casing with get abstract member AssumeNullability: bool with get + abstract member Document: GraphQLDocument with get abstract member TypeIsKnownUnionCase: unionCaseName: string -> bool abstract member IsNamedType: namedType: NamedType -> bool - abstract member TryFindUnionRelationship: unionCaseName: string -> UnionRelationship option abstract member GetInterfaceImplementationTypeNames: interfaceName: string -> string[] + abstract member TryFindGraphObjectType: graphObjectTypeName: string -> VisitedTypes option + abstract member TryFindDocumentNode: name: ROM -> ASTNode option -type ParserContext(casingType, assumeNullability, ct) = +type ParserContext(casingType, assumeNullability, document, ct) = let visitedTypes: HashSet = HashSet() - let unionRelationships: HashSet = HashSet() + let knownUnionCaseNames: HashSet = HashSet() let interfaceRelationships: HashSet = HashSet() let namedTypes: HashSet = HashSet() let (~%) comp = ignore comp @@ -171,12 +210,9 @@ type ParserContext(casingType, assumeNullability, ct) = member this.SetVisitedType (type': VisitedTypes): unit = %visitedTypes.Add type' - member _.AddUnionRelationship unionName unionCaseNames: unit = - for unionCase in unionCaseNames do - { UnionTypeName = unionName - UnionCaseName = unionCase } - |> unionRelationships.Add - |> ignore + member _.AddUnionCases (unionCases: VisitedTypes[]) : unit = + for case in unionCases do + %knownUnionCaseNames.Add case.Name member _.AddInterfaceRelationship implementationName interfaceNames: unit = for interfaceName in interfaceNames do @@ -193,16 +229,18 @@ type ParserContext(casingType, assumeNullability, ct) = member _.AssumeNullability: bool = assumeNullability + member _.Document: GraphQLDocument = document + member ctx.TypeIsKnownUnionCase unionCaseName: bool = - (ctx :> IParsedContext).TryFindUnionRelationship unionCaseName - |> Option.isSome + knownUnionCaseNames + |> Seq.exists (fun r -> r = unionCaseName) member _.IsNamedType name: bool = namedTypes.Contains name - member _.TryFindUnionRelationship unionCaseName: UnionRelationship option = - unionRelationships - |> Seq.tryFind (fun r -> r.UnionCaseName = unionCaseName) + member _.TryFindGraphObjectType graphObjectTypeName: VisitedTypes option = + visitedTypes + |> Seq.tryFind (fun namedType -> string namedType = graphObjectTypeName) member this.GetInterfaceImplementationTypeNames interfaceName = interfaceRelationships @@ -210,5 +248,13 @@ type ParserContext(casingType, assumeNullability, ct) = |> Seq.map _.ImplementationName |> Array.ofSeq + member _.TryFindDocumentNode nodeName = + let item = document.Definitions.Find(fun node -> + match box node with + | :? INamedNode as namedNode when namedNode.Name.Value = nodeName -> true + | _ -> false + ) + Option.ofObj item + interface IASTVisitorContext with member _.CancellationToken = ct diff --git a/ShopifySharp.GraphQL.Parser/FileSystem.fs b/ShopifySharp.GraphQL.Parser/FileSystem.fs new file mode 100644 index 00000000..5c5aac72 --- /dev/null +++ b/ShopifySharp.GraphQL.Parser/FileSystem.fs @@ -0,0 +1,94 @@ +namespace ShopifySharp.GraphQL.Parser + +open System +open System.IO +open System.Linq +open System.Text +open System.Threading.Tasks +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CSharp +open Microsoft.CodeAnalysis.CSharp.Syntax + +[] +module FileSystem = + let writeFileToPath filePath (fileText: string) cancellationToken: ValueTask = + ValueTask(task { + if File.Exists(filePath) then + File.Delete(filePath) + + let directory = Path.GetDirectoryName filePath + + if directory <> "" && not (Directory.Exists directory) then + Directory.CreateDirectory directory + |> ignore + + do! File.WriteAllTextAsync(filePath, fileText, cancellationToken) + }) + + let private parseCsharpStringToGeneratedFiles (csharpCode: string) cancellationToken: ValueTask = + ValueTask(task { + let! csharpTree = CSharpSyntaxTree.ParseText(csharpCode).GetRootAsync(cancellationToken) + let syntaxRoot = csharpTree :?> CompilationUnitSyntax + let usings = syntaxRoot.Usings; + let externals = syntaxRoot.Externs + + let rootMembers = syntaxRoot.Members |> Array.ofSeq + let rootTypes = rootMembers |> Array.filter (fun m -> m :? BaseTypeDeclarationSyntax) + + let namespaces = syntaxRoot.Members.OfType() |> Array.ofSeq + let fileScopedNamespaces = syntaxRoot.Members.OfType() |> Array.ofSeq + + let allTypes = + if fileScopedNamespaces.Length > 0 then + // Handle file-scoped namespaces - collect ALL types (both from namespace and root) + let nsTypes = + fileScopedNamespaces + |> Array.collect (fun ns -> + let types = ns.Members.OfType() |> Array.ofSeq + types |> Array.map (fun t -> (ns :> BaseNamespaceDeclarationSyntax, t))) + + // Also collect types that ended up at root level due to parsing issues + let rootLevelTypes = rootTypes |> Array.map (fun t -> + // Use the file-scoped namespace for root-level types + (fileScopedNamespaces[0] :> BaseNamespaceDeclarationSyntax, t :?> BaseTypeDeclarationSyntax)) + Array.concat [nsTypes; rootLevelTypes] + else + // Handle regular namespaces + namespaces + |> Array.collect (fun ns -> + let types = ns.Members.OfType() |> Array.ofSeq + types |> Array.map (fun t -> (ns, t))) + + return + allTypes + |> Array.map (fun (ns, type') -> + let unit = SyntaxFactory.CompilationUnit() + .WithExterns(externals) + .WithUsings(usings) + .AddMembers(ns.WithMembers(SyntaxFactory.SingletonList(type'))) + .NormalizeWhitespace(eol = Environment.NewLine) + + { FileName = type'.Identifier.Text + ".cs" + FileText = unit.ToFullString() } + ) + }) + + let private parseCsharpCodeAndWriteToDirectoryPath directoryPath (csharpCodeStringBuilder: StringBuilder) cancellationToken = + ValueTask(task { + let! generatedFiles = parseCsharpStringToGeneratedFiles (csharpCodeStringBuilder.ToString()) cancellationToken + for file in generatedFiles do + let filePath = Path.Join(directoryPath, "/", file.FileName) + do! writeFileToPath filePath file.FileText cancellationToken + }) + + let writeCsharpCodeToFileSystem destination csharpCodeStringBuilder cancellationToken: ValueTask = + ValueTask(task { + match destination with + | SingleFile filePath -> + do! writeFileToPath filePath (csharpCodeStringBuilder.ToString()) cancellationToken + | Directory directoryPath -> + do! parseCsharpCodeAndWriteToDirectoryPath directoryPath csharpCodeStringBuilder cancellationToken + | DirectoryAndTemporaryFile(directoryPath, temporaryFilePath) -> + do! writeFileToPath temporaryFilePath (csharpCodeStringBuilder.ToString()) cancellationToken + do! parseCsharpCodeAndWriteToDirectoryPath directoryPath csharpCodeStringBuilder cancellationToken + }) diff --git a/ShopifySharp.GraphQL.Parser/Parser.fs b/ShopifySharp.GraphQL.Parser/Parser.fs index f103a859..29ec1ff9 100644 --- a/ShopifySharp.GraphQL.Parser/Parser.fs +++ b/ShopifySharp.GraphQL.Parser/Parser.fs @@ -12,11 +12,15 @@ let private parseAsync (casing: Casing) (graphqlData: ReadOnlyMemory) cancellationToken : ValueTask = - let context = ParserContext(casing, assumeNullability, cancellationToken) + let ast = Parser.Parse(graphqlData) + let context = ParserContext(casing, assumeNullability, ast, cancellationToken) let visitor: ASTVisitor = Visitor() - // Read the GraphQL document - let ast = Parser.Parse(graphqlData) + // let sdlThing = StructurePrinter() + // use writer = new StringWriter() + // let result = sdlThing.PrintAsync(ast, writer, context.CancellationToken).AsTask() + // |> Async.AwaitTask + // |> Async.RunSynchronously ValueTask(task { do! visitor.VisitAsync(ast, context) @@ -33,7 +37,7 @@ let ParseAsync (casing: Casing) return context.GetVisitedTypes() }) -let ParseAndWriteAsync (destination: FileSystemDestination) +let ParseAndWriteAsync (typesDestination: FileSystemDestination, servicesDestination: FileSystemDestination) (casing: Casing) (assumeNullability: bool) (graphqlData: ReadOnlyMemory) @@ -41,5 +45,6 @@ let ParseAndWriteAsync (destination: FileSystemDestination) : ValueTask = ValueTask(task { let! context = parseAsync casing assumeNullability graphqlData cancellationToken - do! Writer.writeVisitedTypesToFileSystem destination context + do! Writer.writeVisitedTypesToFileSystem typesDestination context + do! QueryBuilderWriter.writeServicesToFileSystem servicesDestination context }) diff --git a/ShopifySharp.GraphQL.Parser/QueryBuilderWriter.fs b/ShopifySharp.GraphQL.Parser/QueryBuilderWriter.fs new file mode 100644 index 00000000..0fd2195c --- /dev/null +++ b/ShopifySharp.GraphQL.Parser/QueryBuilderWriter.fs @@ -0,0 +1,263 @@ +namespace ShopifySharp.GraphQL.Parser + +open System.IO.Pipelines +open System.Threading.Tasks +open GraphQLParser.AST +open ShopifySharp.GraphQL.Parser.PipeWriter +open Utils + +module rec QueryBuilderWriter = + + let private canAddFields = function + | VisitedTypes.Class _ -> true + | VisitedTypes.Interface _ -> true + | VisitedTypes.Enum _ -> false + | VisitedTypes.InputObject _ -> true + | VisitedTypes.UnionType _ -> false + | VisitedTypes.Operation operation when operation.ReturnType.IsFieldType -> true + | VisitedTypes.Operation _ -> false + + let private canAddArguments (type': VisitedTypes) = + type'.IsOperation + + let writeUnionTypeMutationJoins (pascalParentClassName: string) (unionCaseName: string) (_: IParsedContext) writer: ValueTask = + pipeWriter writer { + let pascalUnionCaseName = toCasing Pascal unionCaseName + let camelUnionCaseName = toCasing Camel unionCaseName + let unionCaseQueryBuilderName = $"{pascalUnionCaseName}QueryBuilder" + + do! Indented + $"public {pascalParentClassName} AddUnion{pascalUnionCaseName}(Func<{unionCaseQueryBuilderName}, {unionCaseQueryBuilderName}> build)" + do! NewLine + do! Indented + "{" + do! NewLine + do! DoubleIndented + $"AddUnion<{pascalUnionCaseName}>(\"{camelUnionCaseName}\", build);" + do! NewLine + do! DoubleIndented + "return this;" + do! NewLine + do! Indented + "}" + do! NewLine + } + + let writeQueryBuilderAddFieldMethods (pascalClassName: string) (type': VisitedTypes) (context: IParsedContext) writer: ValueTask = + let writeField (fieldName: string) fieldDeprecationWarning: ValueTask = + let pascalFieldName = toCasing Pascal fieldName + let camelFieldName = toCasing Camel fieldName + + pipeWriter writer { + yield! writeDeprecationAttribute Indented fieldDeprecationWarning + do! Indented + $"public {pascalClassName} AddField{pascalFieldName}()" + do! NewLine + do! DoubleIndented + "{" + do! NewLine + // TODO: check if field.Type is a union type – if so, use AddUnion? + if context.TypeIsKnownUnionCase fieldName then + do! DoubleIndented + $"Add{pascalClassName}()" + else + do! DoubleIndented + $"AddField(\"{camelFieldName}\");" + do! NewLine + do! TripleIndented + "return this;" + do! NewLine + do! DoubleIndented + "}" + do! NewLine + // TODO: add the Func overload + } + + let writeAddValue (_: FieldType): ValueTask = + pipeWriter writer { + do! Indented + $"public {pascalClassName} AddValue()" + do! NewLine + do! DoubleIndented + "{" + do! NewLine + do! DoubleIndented + "// This method is a no-op – the value will be included automatically by virtue of this QueryBuilder being including" + do! NewLine + do! TripleIndented + "return this;" + do! NewLine + do! DoubleIndented + "}" + do! NewLine + } + + let writeForVisitedType (visitedType: VisitedTypes): ValueTask = + pipeWriter writer { + match visitedType with + | VisitedTypes.UnionType unionType -> + for unionCase in unionType.Cases do + yield! writeUnionTypeMutationJoins pascalClassName unionCase.Name context + | visitedType -> + let fields = + match visitedType with + | VisitedTypes.Class class' -> + class'.Fields + | VisitedTypes.Interface interface' -> + interface'.Fields + | VisitedTypes.InputObject inputObject -> + inputObject.Fields + | _ -> + failwith $"The VisitedType %A{visitedType.Name} is not supported here." + + for field in fields do + do! writeField field.Name field.Deprecation + } + + pipeWriter writer { + match type' with + | VisitedTypes.Operation operation -> + match operation.ReturnType with + | ReturnType.FieldType (FieldType.ValueType (FieldValueType.GraphObjectType graphObjectTypeName)) -> + if context.TypeIsKnownUnionCase graphObjectTypeName || context.IsNamedType (NamedType.UnionType graphObjectTypeName) then + yield! writeUnionTypeMutationJoins pascalClassName graphObjectTypeName context + else + do! writeField graphObjectTypeName None + | ReturnType.FieldType fieldType -> + do! writeAddValue fieldType + | ReturnType.VisitedType visitedTypes -> + do! writeForVisitedType visitedTypes + | visitedType -> + do! writeForVisitedType visitedType + } + + let writeQueryBuilderAddArgumentMethods (pascalClassName: string) (type': VisitedTypes) (context: IParsedContext) writer: ValueTask = + let arguments = + match type' with + | Operation operation -> operation.Arguments + | _ -> failwith $"Type {type'.Name} does not support adding QueryBuilder arguments." + + pipeWriter writer { + let sanitizeArgumentName casing argName = + sanitizeFieldOrOperationName (NamedType.Class pascalClassName) argName + |> toCasing casing + + for argument in arguments do + let valueType = + AstNodeMapper.mapFieldTypeToString context.IsNamedType context.AssumeNullability argument.ValueType FieldTypeCollectionHandling.KeepCollection + let camelArgumentName = + sanitizeArgumentName Camel argument.Name + let pascalArgumentName = + toCasing Pascal argument.Name + + yield! writeDeprecationAttribute Indented argument.Deprecation + do! $"public {pascalClassName} AddArgument{pascalArgumentName}({valueType} {camelArgumentName})" + do! NewLine + do! DoubleIndented + "{" + do! NewLine + do! DoubleIndented + $"AddArgument(\"{argument.Name}\", {camelArgumentName});" + do! NewLine + do! TripleIndented + "return this;" + do! NewLine + do! DoubleIndented + "}" + do! NewLine + } + + let writeQueryBuilderConstructor (pascalClassName: string) (type': VisitedTypes) (context: IParsedContext) writer: ValueTask = + pipeWriter writer { + let camelTypeName = toCasing Camel type'.Name + + let genericType = + match type' with + | VisitedTypes.Operation operation -> + match operation.ReturnType with + | ReturnType.VisitedType visitedTypes -> + visitedTypes.Name + | ReturnType.FieldType fieldType -> + AstNodeMapper.mapFieldTypeToString context.IsNamedType context.AssumeNullability fieldType FieldTypeCollectionHandling.KeepCollection + | x -> x.Name + + // Fully qualify class names that might collide with System types + let qualifiedGenericType = + let pascalGenericType = toCasing Pascal genericType + // The type name should already have the "I" prefix if it's an interface + // (it comes from VisitedTypes.Name which includes the prefix) + match pascalGenericType with + | "Attribute" -> "ShopifySharp.GraphQL.Attribute" + | _ -> pascalGenericType + + // TODO: this may not always be a query, it may also be a mutation (or even a subselection in the case of nested objects) + do! $"public class {pascalClassName}(): GraphQueryBuilder<{qualifiedGenericType}>(\"query {camelTypeName}\")" + do! NewLine + } + + let writeQueryBuilder (type': VisitedTypes) (context: IParsedContext) writer: ValueTask = + if type'.IsEnum || type'.IsInputObject then + failwithf $"The {type'.GetType().Name} type is not supported." + + pipeWriter writer { + let pascalClassName = toCasing Pascal (type'.Name + "QueryBuilder") + + yield! writeDeprecationAttribute Outdented type'.Deprecation + yield! writeQueryBuilderConstructor pascalClassName type' context + do! "{" + do! NewLine + + if canAddFields type' then + yield! writeQueryBuilderAddFieldMethods pascalClassName type' context + + if canAddArguments type' then + yield! writeQueryBuilderAddArgumentMethods pascalClassName type' context + + do! NewLine + do! "}" + do! NewLine + } + + let private writeNamespaceAndUsings writer: ValueTask = + pipeWriter writer { + do! "#nullable enable" + do! NewLine + do! "namespace ShopifySharp.Services.Generated;" + do! NewLine + do! "using System;" + do! NewLine + do! "using System.Threading.Tasks;" + do! NewLine + do! "using System.Text.Json.Serialization;" + do! NewLine + do! "using System.Collections.Generic;" + do! NewLine + do! "using ShopifySharp.Credentials;" + do! NewLine + do! "using ShopifySharp.GraphQL;" + do! NewLine + do! "using ShopifySharp.Infrastructure;" + do! NewLine + do! "using ShopifySharp.Infrastructure.Serialization.Json;" + do! NewLine + } + + let private writeServicesToPipe (context: IParsedContext) writer: ValueTask = + pipeWriter writer { + // Always write the namespace and usings at the very top of the document + yield! writeNamespaceAndUsings + + for node in context.Document.Definitions do + match node with + | :? GraphQLObjectTypeDefinition as objDef when + objDef.Name.StringValue = "QueryRoot" || objDef.Name.StringValue = "Mutation" -> + + // Each field in this object is an operation and should have a QueryBuilder + for field in objDef.Fields do + let operation = AstNodeMapper.mapRootFieldDefinition field context + yield! writeQueryBuilder (VisitedTypes.Operation operation) context + | _ -> + match AstNodeMapper.tryMap context node with + | None -> + printfn $"No mapping for type %A{node.GetType()}" + | Some (VisitedTypes.InputObject _) + | Some (VisitedTypes.Enum _) -> + // InputObjects and Enums do not need a QueryBuilder and are not supported + () + | Some mappedType -> + yield! writeQueryBuilder mappedType context + } + + let writeServicesToFileSystem(destination: FileSystemDestination) (context: ParserContext): ValueTask = + let cancellationToken = context.CancellationToken + + ValueTask(task { + let pipe = Pipe(PipeOptions()) + let readTask = (readPipe pipe.Reader cancellationToken).ConfigureAwait(false) + + do! writeServicesToPipe context pipe.Writer + do! pipe.Writer.CompleteAsync().ConfigureAwait(false); + + let! csharpCode = readTask + do! (FileSystem.writeCsharpCodeToFileSystem destination csharpCode cancellationToken).ConfigureAwait(false) + }) diff --git a/ShopifySharp.GraphQL.Parser/Reserved.fs b/ShopifySharp.GraphQL.Parser/Reserved.fs new file mode 100644 index 00000000..d14b3b60 --- /dev/null +++ b/ShopifySharp.GraphQL.Parser/Reserved.fs @@ -0,0 +1,18 @@ +namespace ShopifySharp.GraphQL.Parser + +module Reserved = + let csharpKeywords = Set.ofList [ + "abstract"; "as"; "base"; "bool"; "break"; "byte"; "case"; "catch"; "char"; "checked"; + "class"; "const"; "continue"; "decimal"; "default"; "delegate"; "do"; "double"; "else"; + "enum"; "event"; "explicit"; "extern"; "false"; "finally"; "fixed"; "float"; "for"; + "foreach"; "goto"; "if"; "implicit"; "in"; "int"; "interface"; "internal"; "is"; "lock"; + "long"; "namespace"; "new"; "null"; "object"; "operator"; "out"; "override"; "params"; + "private"; "protected"; "public"; "readonly"; "ref"; "return"; "sbyte"; "sealed"; + "short"; "sizeof"; "stackalloc"; "static"; "string"; "struct"; "switch"; "this"; + "throw"; "true"; "try"; "typeof"; "uint"; "ulong"; "unchecked"; "unsafe"; "ushort"; + "using"; "virtual"; "void"; "volatile"; "while"; + // Contextual keywords that can also cause issues + "add"; "alias"; "ascending"; "async"; "await"; "by"; "descending"; "dynamic"; "equals"; + "from"; "get"; "global"; "group"; "into"; "join"; "let"; "nameof"; "orderby"; "partial"; + "remove"; "select"; "set"; "value"; "var"; "when"; "where"; "yield" + ] diff --git a/ShopifySharp.GraphQL.Parser/ShopifySharp.GraphQL.Parser.fsproj b/ShopifySharp.GraphQL.Parser/ShopifySharp.GraphQL.Parser.fsproj index 4e8373a8..34dcfe33 100644 --- a/ShopifySharp.GraphQL.Parser/ShopifySharp.GraphQL.Parser.fsproj +++ b/ShopifySharp.GraphQL.Parser/ShopifySharp.GraphQL.Parser.fsproj @@ -8,7 +8,12 @@ + + + + + diff --git a/ShopifySharp.GraphQL.Parser/Utils.fs b/ShopifySharp.GraphQL.Parser/Utils.fs new file mode 100644 index 00000000..915d4c87 --- /dev/null +++ b/ShopifySharp.GraphQL.Parser/Utils.fs @@ -0,0 +1,81 @@ +namespace ShopifySharp.GraphQL.Parser + +open System +open System.IO.Pipelines +open System.Text +open System.Threading.Tasks +open ShopifySharp.GraphQL.Parser.PipeWriter + +module Utils = + let NewLine = Environment.NewLine + + let mapStrToInterfaceName = + sprintf "I%s" + + let toUnionCaseWrapperName (unionTypeName: string) unionCaseName = + unionTypeName + unionCaseName + + let toTab (indentation: Indentation) = + indentation.ToString() + + let toCasing casing (str: string): string = + let first = str[0] + let rest = str[1..] + match casing with + | Pascal -> Char.ToUpper(first).ToString() + rest + | Camel -> Char.ToLower(first).ToString() + rest + + let sanitizeString (str: string): string = + str.ReplaceLineEndings("") + .Replace("\"", "", StringComparison.OrdinalIgnoreCase) + .Replace("'", "", StringComparison.OrdinalIgnoreCase) + + /// + /// Sanitizes the value, replacing reserved C# keywords with $"@{value}" + /// + let sanitizeFieldOrOperationName (parentType: NamedType) (fieldName: string): string = + if fieldName.Equals(parentType.ToString(), StringComparison.OrdinalIgnoreCase) then + // The C# compiler will not allow the @ prefix for members that have the same name as their enclosing type + fieldName + "_" + elif Set.contains fieldName Reserved.csharpKeywords then + "@" + fieldName + else + fieldName + + let writeDeprecationAttribute indentation (deprecationWarning: string option) writer : ValueTask = + let indentation = toTab indentation + pipeWriter writer { + match deprecationWarning with + | Some x when String.IsNullOrWhiteSpace x -> + do! indentation + do! "[Obsolete]" + do! NewLine + | Some x -> + do! indentation + do! $"[Obsolete(\"{sanitizeString x}\")]" + do! NewLine + | None -> + () + } + + let readPipe (reader: PipeReader) cancellationToken: ValueTask = + let sb = StringBuilder() + let rec loop () = task { + let! result = reader.ReadAsync(cancellationToken).ConfigureAwait(false) + + let mutable enumerator = result.Buffer.GetEnumerator() + while enumerator.MoveNext() do + sb.Append(Encoding.UTF8.GetString(enumerator.Current.Span)) + |> ignore + + reader.AdvanceTo(result.Buffer.End) + + if not (result.IsCompleted || result.IsCanceled) then + do! loop() + } + + ValueTask(task { + do! loop() + do! reader.CompleteAsync().ConfigureAwait(false) + return sb + }) diff --git a/ShopifySharp.GraphQL.Parser/Visitor.fs b/ShopifySharp.GraphQL.Parser/Visitor.fs index 4efb3b25..191a174e 100644 --- a/ShopifySharp.GraphQL.Parser/Visitor.fs +++ b/ShopifySharp.GraphQL.Parser/Visitor.fs @@ -1,21 +1,9 @@ namespace ShopifySharp.GraphQL.Parser -open System open System.Diagnostics.CodeAnalysis -open System.Runtime.CompilerServices open System.Threading.Tasks -open FSharp.Span.Utils.SafeLowLevelOperators open GraphQLParser.AST open GraphQLParser.Visitors -open FSharp.Span.Utils - -type private FieldsDefinition = - | InputFields of inputFields: GraphQLInputFieldsDefinition - | ObjectFields of xFields: GraphQLFieldsDefinition - -type private Presence - = Present of fieldType: GraphQLType - | NotPresent type Visitor() = inherit ASTVisitor() @@ -23,217 +11,46 @@ type Visitor() = [] let (~%) job = ignore job - let removeNewLines (value: char readonlyspan): string = - let newlineChar = '\n'; - let spanValue = value; - let hasNewLine = spanValue.Contains(newlineChar) + override this.VisitFieldDefinitionAsync(_, _) = + // This method is called for each query/mutation operation in QueryRoot and Mutation + ValueTask.CompletedTask - if not hasNewLine then - value.ToString() - else - let destination: char span = stackalloc spanValue.Length - value.Replace(destination, newlineChar, ' '); - destination.ToString(); - - let getDeprecationMessage (directives: GraphQLDirectives | null): string option = - if isNull directives - then None - else directives.Items - |> Seq.tryFind (fun i -> i.Name.StringValue = "deprecated") - |> Option.bind (fun deprecation -> - deprecation.Arguments.Items - |> Seq.tryPick (fun arg -> - if arg.Name.StringValue = "reason" && arg.Value.Kind = ASTNodeKind.StringValue - then Some (arg.Value :?> GraphQLStringValue) - else None - ) - |> Option.map (fun reason -> removeNewLines reason.Value.Span) - ) - - let mapDescriptionToXmlSummary (description: GraphQLDescription): string[] = - if isNull description then - Array.empty - else - // Split the description on each new line - let segments = description.Value.Span.ToString().Split([|Environment.NewLine|], StringSplitOptions.RemoveEmptyEntries) - - [| - yield "/// " - for segment in segments do - yield $"/// {segment}" - yield "/// " - |] - - let typeMap: Map = Map.ofList [ - "UnsignedInt64", FieldValueType.ULong - "Money", FieldValueType.Decimal - "Decimal", FieldValueType.Decimal - "Float", FieldValueType.Decimal - "DateTime", FieldValueType.DateTime // GraphQL DateTimes are always UTC - "Date", FieldValueType.DateOnly - "UtcOffset", FieldValueType.TimeSpan - "URL", FieldValueType.String - "HTML", FieldValueType.String - "JSON", FieldValueType.String - "FormattedString", FieldValueType.String - "ARN", FieldValueType.String - "StorefrontID", FieldValueType.String - "Color", FieldValueType.String - "BigInt", FieldValueType.Long - "String", FieldValueType.String - "Boolean", FieldValueType.Boolean - "Integer", FieldValueType.Int - "Int", FieldValueType.Int - "ID", FieldValueType.String - ] - - let rec mapGraphTypeToFieldType (fieldType: GraphQLType): FieldType = - match fieldType with - | :? GraphQLNamedType as namedType -> - Map.tryFind namedType.Name.StringValue typeMap - |> Option.defaultWith (fun _ -> FieldValueType.GraphObjectType namedType.Name.StringValue) - |> FieldType.ValueType - | :? GraphQLListType as listType -> - mapGraphTypeToFieldType listType.Type - |> FieldType.CollectionType - | :? GraphQLNonNullType as nonNullType -> - mapGraphTypeToFieldType nonNullType.Type - |> FieldType.NonNullableType - | _ -> - raise (SwitchExpressionException fieldType) - - let mapToArguments (argument: GraphQLArgumentsDefinition | null): FieldArgument[] = - if isNull argument then - Array.empty - else - argument.Items - |> Array.ofSeq - |> Array.map (fun argument -> - { Name = argument.Name.StringValue - Deprecation = getDeprecationMessage argument.Directives - XmlSummary = mapDescriptionToXmlSummary argument.Description - ValueType = mapGraphTypeToFieldType argument.Type }) - - let mapToFields (fieldsDefinition: FieldsDefinition): Field[] = - let createField (fieldType: FieldType) name directives description arguments = - { Name = name - XmlSummary = mapDescriptionToXmlSummary description - Deprecation = getDeprecationMessage directives - Arguments = arguments - ValueType = fieldType } - - match fieldsDefinition with - | InputFields inputFields -> - inputFields.Items - |> Array.ofSeq - |> Array.map (fun field -> - let fieldType = mapGraphTypeToFieldType field.Type - createField fieldType field.Name.StringValue field.Directives field.Description Array.empty) - | ObjectFields objectFields -> - objectFields.Items - |> Array.ofSeq - |> Array.map (fun field -> - let fieldType = mapGraphTypeToFieldType field.Type - createField fieldType field.Name.StringValue field.Directives field.Description (mapToArguments field.Arguments)) - - let strToInterfaceName = - sprintf "I%s" - - let getBestConnectionTypeInterfaceName (fields: GraphQLFieldsDefinition): ConnectionType = - let check (nodesFieldPresence: Presence, edgesFieldPresence: Presence) (field: GraphQLFieldDefinition) = - if nodesFieldPresence.IsPresent && edgesFieldPresence.IsPresent - then nodesFieldPresence, edgesFieldPresence - else match field.Name.StringValue.ToLowerInvariant() with - | "nodes" -> Present field.Type, edgesFieldPresence - | "edges" -> nodesFieldPresence, Present field.Type - | _ -> nodesFieldPresence, edgesFieldPresence - - let nodeFieldPresence, edgesFieldPresence = - fields.Items - |> Seq.fold check (NotPresent, NotPresent) - - match nodeFieldPresence, edgesFieldPresence with - | Present nodesFieldType, Present edgesFieldType -> - ConnectionWithNodesAndEdges (mapGraphTypeToFieldType nodesFieldType, mapGraphTypeToFieldType edgesFieldType) - | Present nodesFieldType, NotPresent -> - ConnectionWithNodes (mapGraphTypeToFieldType nodesFieldType) - | NotPresent, Present edgesFieldType -> - ConnectionWithEdges (mapGraphTypeToFieldType edgesFieldType) - | NotPresent, NotPresent -> - ConnectionType.Connection - - let rec mapToInheritedTypeNames (implementsInterface: GraphQLImplementsInterfaces): string[] = - if isNull implementsInterface then - Array.empty - else - implementsInterface.Items - |> Array.ofSeq - |> Array.map (_.Name.StringValue >> strToInterfaceName) - - let mapToEnumCases (enumValuesDefinition: GraphQLEnumValuesDefinition): VisitedEnumCase[] = - enumValuesDefinition.Items - |> Array.ofSeq - |> Array.map (fun enumCase -> - { Name = enumCase.Name.StringValue - XmlSummary = mapDescriptionToXmlSummary enumCase.Description - Deprecation = getDeprecationMessage enumCase.Directives - Value = match enumCase.EnumValue.Name.StringValue with - | value when String.IsNullOrWhiteSpace value -> None - | value when value = enumCase.Name.StringValue -> None - | value -> Some value }) - - let mapToUnionTypeMemberNames (unionMemberTypes: GraphQLUnionMemberTypes): string[] = - unionMemberTypes.Items - |> Array.ofSeq - |> Array.map _.Name.StringValue + override this.VisitFieldAsync (field: GraphQLField, context: ParserContext): ValueTask = + // This override has a SelectionSet property, but isn't called when parsing the schema file. + // Could be very useful for parsing custom graphql queries. + base.VisitFieldAsync(field, context) override this.VisitObjectTypeDefinitionAsync(objectTypeDefinition, context) = context.CancellationToken.ThrowIfCancellationRequested() let objectTypeName = objectTypeDefinition.Name.StringValue - let classInheritedType = - if objectTypeName.EndsWith("Edge", StringComparison.Ordinal) then - Some Edge - else if objectTypeName.EndsWith("Connection", StringComparison.OrdinalIgnoreCase) then - getBestConnectionTypeInterfaceName objectTypeDefinition.Fields - |> Connection - |> Some - else - None - - let inheritedTypeNames = mapToInheritedTypeNames objectTypeDefinition.Interfaces - let generated: Class = - { Name = objectTypeDefinition.Name.StringValue - XmlSummary = mapDescriptionToXmlSummary objectTypeDefinition.Description - Deprecation = getDeprecationMessage objectTypeDefinition.Directives - Fields = mapToFields (ObjectFields objectTypeDefinition.Fields) - KnownInheritedType = classInheritedType - InheritedTypeNames = inheritedTypeNames } - - VisitedTypes.Class generated - |> context.SetVisitedType - NamedType.Class objectTypeDefinition.Name.StringValue - |> context.AddNamedType + if objectTypeName = "Mutation" then + base.VisitObjectTypeDefinitionAsync(objectTypeDefinition, context) + else + let class' = AstNodeMapper.mapObjectTypeDefinition objectTypeDefinition - inheritedTypeNames - |> context.AddInterfaceRelationship objectTypeDefinition.Name.StringValue + VisitedTypes.Class class' + |> context.SetVisitedType - ValueTask.CompletedTask + class'.InheritedTypeNames + |> context.AddInterfaceRelationship class'.Name + + NamedType.Class class'.Name + |> context.AddNamedType + + ValueTask.CompletedTask override this.VisitInterfaceTypeDefinitionAsync(interfaceTypeDefinition, context) = context.CancellationToken.ThrowIfCancellationRequested() - let generated: Interface = - { Name = strToInterfaceName interfaceTypeDefinition.Name.StringValue - XmlSummary = mapDescriptionToXmlSummary interfaceTypeDefinition.Description - Deprecation = getDeprecationMessage interfaceTypeDefinition.Directives - Fields = mapToFields (ObjectFields interfaceTypeDefinition.Fields) - InheritedTypeNames = mapToInheritedTypeNames interfaceTypeDefinition.Interfaces } + let interface' = AstNodeMapper.mapInterfaceTypeDefinition interfaceTypeDefinition - VisitedTypes.Interface generated + VisitedTypes.Interface interface' |> context.SetVisitedType + // Register the interface with its original schema name (without "I" prefix) + // so that field type lookups can find it NamedType.Interface interfaceTypeDefinition.Name.StringValue |> context.AddNamedType @@ -242,16 +59,12 @@ type Visitor() = override this.VisitEnumTypeDefinitionAsync(enumTypeDefinition, context) = context.CancellationToken.ThrowIfCancellationRequested() - let generated: VisitedEnum = - { Name = enumTypeDefinition.Name.StringValue - XmlSummary = mapDescriptionToXmlSummary enumTypeDefinition.Description - Deprecation = getDeprecationMessage enumTypeDefinition.Directives - Cases = mapToEnumCases enumTypeDefinition.Values } + let enum = AstNodeMapper.mapEnumTypeDefinition enumTypeDefinition - VisitedTypes.Enum generated + VisitedTypes.Enum enum |> context.SetVisitedType - NamedType.Enum enumTypeDefinition.Name.StringValue + NamedType.Enum enum.Name |> context.AddNamedType ValueTask.CompletedTask @@ -259,16 +72,12 @@ type Visitor() = override this.VisitInputObjectTypeDefinitionAsync(inputObjectTypeDefinition, context) = context.CancellationToken.ThrowIfCancellationRequested() - let generated: InputObject = - { Name = inputObjectTypeDefinition.Name.StringValue - XmlSummary = mapDescriptionToXmlSummary inputObjectTypeDefinition.Description - Deprecation = getDeprecationMessage inputObjectTypeDefinition.Directives - Fields = mapToFields (InputFields inputObjectTypeDefinition.Fields) } + let inputObject = AstNodeMapper.mapInputObjectTypeDefinition inputObjectTypeDefinition - VisitedTypes.InputObject generated + VisitedTypes.InputObject inputObject |> context.SetVisitedType - NamedType.InputObject inputObjectTypeDefinition.Name.StringValue + NamedType.InputObject inputObject.Name |> context.AddNamedType ValueTask.CompletedTask @@ -276,19 +85,15 @@ type Visitor() = override this.VisitUnionTypeDefinitionAsync(unionTypeDefinition, context) = context.CancellationToken.ThrowIfCancellationRequested() - let generated: UnionType = - { Name = unionTypeDefinition.Name.StringValue - XmlSummary = mapDescriptionToXmlSummary unionTypeDefinition.Description - Deprecation = getDeprecationMessage unionTypeDefinition.Directives - Types = mapToUnionTypeMemberNames unionTypeDefinition.Types } + let unionType = AstNodeMapper.mapUnionTypeDefinition context unionTypeDefinition - VisitedTypes.UnionType generated + VisitedTypes.UnionType unionType |> context.SetVisitedType - NamedType.UnionType unionTypeDefinition.Name.StringValue + NamedType.UnionType unionType.Name |> context.AddNamedType - generated.Types - |> context.AddUnionRelationship unionTypeDefinition.Name.StringValue + unionType.Cases + |> context.AddUnionCases ValueTask.CompletedTask diff --git a/ShopifySharp.GraphQL.Parser/Writer.fs b/ShopifySharp.GraphQL.Parser/Writer.fs index 37567154..7d255669 100644 --- a/ShopifySharp.GraphQL.Parser/Writer.fs +++ b/ShopifySharp.GraphQL.Parser/Writer.fs @@ -1,225 +1,33 @@ module ShopifySharp.GraphQL.Parser.Writer open System -open System.IO open System.IO.Pipelines -open System.Linq -open System.Text open System.Threading.Tasks -open Microsoft.CodeAnalysis; -open Microsoft.CodeAnalysis.CSharp; -open Microsoft.CodeAnalysis.CSharp.Syntax -open ShopifySharp.GraphQL.Parser.PipeWriter; +open ShopifySharp.GraphQL.Parser.PipeWriter +open Utils type private Writer = PipeWriter let private (~%) job = ignore job -let private NewLine = Environment.NewLine - -let private toTab = function - | Indented -> "\t" - | Outdented -> String.Empty - -let private parseCsharpStringToGeneratedFiles (csharpCode: string) cancellationToken: ValueTask = - ValueTask(task { - let! csharpTree = CSharpSyntaxTree.ParseText(csharpCode).GetRootAsync(cancellationToken) - let syntaxRoot = csharpTree :?> CompilationUnitSyntax; - let usings = syntaxRoot.Usings; - let externals = syntaxRoot.Externs - - let rootMembers = syntaxRoot.Members |> Array.ofSeq - let rootTypes = rootMembers |> Array.filter (fun m -> m :? BaseTypeDeclarationSyntax) - - let namespaces = syntaxRoot.Members.OfType() |> Array.ofSeq - let fileScopedNamespaces = syntaxRoot.Members.OfType() |> Array.ofSeq - - let allTypes = - if fileScopedNamespaces.Length > 0 then - // Handle file-scoped namespaces - collect ALL types (both from namespace and root) - let nsTypes = - fileScopedNamespaces - |> Array.collect (fun ns -> - let types = ns.Members.OfType() |> Array.ofSeq - types |> Array.map (fun t -> (ns :> BaseNamespaceDeclarationSyntax, t))) - - // Also collect types that ended up at root level due to parsing issues - let rootLevelTypes = rootTypes |> Array.map (fun t -> - // Use the file-scoped namespace for root-level types - (fileScopedNamespaces[0] :> BaseNamespaceDeclarationSyntax, t :?> BaseTypeDeclarationSyntax)) - Array.concat [nsTypes; rootLevelTypes] - else - // Handle regular namespaces - namespaces - |> Array.collect (fun ns -> - let types = ns.Members.OfType() |> Array.ofSeq - types |> Array.map (fun t -> (ns, t))) - - return - allTypes - |> Array.map (fun (ns, type') -> - let unit = SyntaxFactory.CompilationUnit() - .WithExterns(externals) - .WithUsings(usings) - .AddMembers(ns.WithMembers(SyntaxFactory.SingletonList(type'))) - .NormalizeWhitespace(eol = Environment.NewLine) - - { FileName = type'.Identifier.Text + ".cs" - FileText = unit.ToFullString() } - ) - }) - -let private writeFileToPath filePath (fileText: string) cancellationToken: ValueTask = - ValueTask(task { - if File.Exists(filePath) then - File.Delete(filePath) - - let directory = Path.GetDirectoryName filePath - - if directory <> "" && not (Directory.Exists directory) then - %Directory.CreateDirectory(directory) - - do! File.WriteAllTextAsync(filePath, fileText, cancellationToken) - }) - -let private parseCsharpCodeAndWriteToDirectoryPath directoryPath (csharpCode: StringBuilder) cancellationToken = - ValueTask(task { - let! generatedFiles = parseCsharpStringToGeneratedFiles (csharpCode.ToString()) cancellationToken - for file in generatedFiles do - let filePath = Path.Join(directoryPath, "/", file.FileName) - do! writeFileToPath filePath file.FileText cancellationToken - }) - -let private readPipe (reader: PipeReader) cancellationToken: ValueTask = - let sb = StringBuilder() - let rec loop () = task { - let! result = reader.ReadAsync(cancellationToken).ConfigureAwait(false) - - let mutable enumerator = result.Buffer.GetEnumerator() - while enumerator.MoveNext() do - %sb.Append(Encoding.UTF8.GetString(enumerator.Current.Span)) - - reader.AdvanceTo(result.Buffer.End) - - if not (result.IsCompleted || result.IsCanceled) then - do! loop() - } - - ValueTask(task { - do! loop() - do! reader.CompleteAsync().ConfigureAwait(false) - return sb - }) - -let private sanitizeString (str: string): string = - str.ReplaceLineEndings("") - .Replace("\"", "", StringComparison.OrdinalIgnoreCase) - .Replace("'", "", StringComparison.OrdinalIgnoreCase) - -let private csharpKeywords = Set.ofList [ - "abstract"; "as"; "base"; "bool"; "break"; "byte"; "case"; "catch"; "char"; "checked"; - "class"; "const"; "continue"; "decimal"; "default"; "delegate"; "do"; "double"; "else"; - "enum"; "event"; "explicit"; "extern"; "false"; "finally"; "fixed"; "float"; "for"; - "foreach"; "goto"; "if"; "implicit"; "in"; "int"; "interface"; "internal"; "is"; "lock"; - "long"; "namespace"; "new"; "null"; "object"; "operator"; "out"; "override"; "params"; - "private"; "protected"; "public"; "readonly"; "ref"; "return"; "sbyte"; "sealed"; - "short"; "sizeof"; "stackalloc"; "static"; "string"; "struct"; "switch"; "this"; - "throw"; "true"; "try"; "typeof"; "uint"; "ulong"; "unchecked"; "unsafe"; "ushort"; - "using"; "virtual"; "void"; "volatile"; "while"; - // Contextual keywords that can also cause issues - "add"; "alias"; "ascending"; "async"; "await"; "by"; "descending"; "dynamic"; "equals"; - "from"; "get"; "global"; "group"; "into"; "join"; "let"; "nameof"; "orderby"; "partial"; - "remove"; "select"; "set"; "value"; "var"; "when"; "where"; "yield" -] - -/// -/// Sanitizes the value, replacing reserved C# keywords with $"@{value}" -/// -let private sanitizeFieldName (parentType: NamedType) (fieldName: string): string = - if fieldName.Equals(parentType.ToString(), StringComparison.OrdinalIgnoreCase) then - // The C# compiler will not allow the @ prefix for members that have the same name as their enclosing type - fieldName + "_" - elif Set.contains fieldName csharpKeywords then - "@" + fieldName - else - fieldName - -let private toCasing casing (str: string): string = - let first = str[0] - let rest = str[1..] - match casing with - | Pascal -> Char.ToUpper(first).ToString() + rest - | Camel -> Char.ToLower(first).ToString() + rest - -let private mapStrToInterfaceName = - sprintf "I%s" - -let private toUnionCaseWrapperName unionTypeName unionCaseName = - unionTypeName + unionCaseName - -let private mapValueTypeToString (isNamedType: NamedType -> bool) = function - | FieldValueType.ULong -> "ulong" - | FieldValueType.Long -> "long" - | FieldValueType.Int -> "int" - | FieldValueType.Decimal -> "decimal" - | FieldValueType.Float -> "float" - | FieldValueType.Boolean -> "bool" - | FieldValueType.String -> "string" - | FieldValueType.DateTime -> "DateTime" - | FieldValueType.DateOnly -> "DateOnly" - | FieldValueType.TimeSpan -> "TimeSpan" - | FieldValueType.GraphObjectType graphObjectTypeName -> - if isNamedType (NamedType.Interface graphObjectTypeName) - then mapStrToInterfaceName graphObjectTypeName - else graphObjectTypeName - -let rec private unwrapFieldType = function - | ValueType valueType -> valueType - | NullableType valueType -> unwrapFieldType valueType - | NonNullableType valueType -> unwrapFieldType valueType - | CollectionType collectionType -> unwrapFieldType collectionType - -let rec private mapFieldTypeToString (isNamedType: NamedType -> bool) assumeNullability (valueType: FieldType) (collectionHandling: FieldTypeCollectionHandling) = - let maybeWriteNullability isNullable fieldStr = - fieldStr + (if isNullable then "?" else "") - - let rec unwrapType isRecursing = function - | ValueType valueType - | NonNullableType (ValueType valueType) -> - mapValueTypeToString isNamedType valueType - |> maybeWriteNullability (not isRecursing && assumeNullability) - | NullableType (ValueType valueType) -> - mapValueTypeToString isNamedType valueType - |> maybeWriteNullability true - | NonNullableType (CollectionType collectionType) // We unwrap this one twice because CollectionTypes are all (NonNullable (CollectionType Type)) in GraphQL - | CollectionType collectionType -> - let mappedType = unwrapType true collectionType - match collectionHandling with - | KeepCollection -> $"ICollection<{mappedType}>" - | UnwrapCollection -> mappedType - |> maybeWriteNullability (not isRecursing && assumeNullability) - | NonNullableType nonNullableType -> - unwrapType true nonNullableType - | NullableType nullableType -> - unwrapFieldType nullableType - |> mapValueTypeToString isNamedType - |> maybeWriteNullability true - - unwrapType false valueType - let private writeNamespaceAndUsings (writer: Writer) : ValueTask = pipeWriter writer { do! "#nullable enable" do! NewLine - do! NewLine do! "namespace ShopifySharp.GraphQL;" do! NewLine do! "using System;" do! NewLine + do! "using System.Threading.Tasks;" + do! NewLine do! "using System.Text.Json.Serialization;" do! NewLine do! "using System.Collections.Generic;" do! NewLine + do! "using ShopifySharp.Credentials;" + do! NewLine + do! "using ShopifySharp.Infrastructure;" + do! NewLine do! "using ShopifySharp.Infrastructure.Serialization.Json;" do! NewLine } @@ -233,22 +41,6 @@ let private writeSummary indentation (summary: string[]) writer : ValueTask = do! NewLine } -let private writeDeprecationAttribute indentation (deprecationWarning: string option) writer : ValueTask = - let indentation = toTab indentation - pipeWriter writer { - match deprecationWarning with - | Some x when String.IsNullOrWhiteSpace x -> - do! indentation - do! "[Obsolete]" - do! NewLine - | Some x -> - do! indentation - do! $"[Obsolete(\"{sanitizeString x}\")]" - do! NewLine - | None -> - () - } - let private writeJsonPropertyAttribute (propertyName: string) writer : ValueTask = pipeWriter writer { do! (toTab Indented) + $"[JsonPropertyName(\"{propertyName}\")]" @@ -260,7 +52,7 @@ let private writeJsonPropertyAttribute (propertyName: string) writer : ValueTask /// package's converter. /// let private writeDateOnlyJsonConverterAttribute (fieldType: FieldType) writer: ValueTask = - let fieldValueType = unwrapFieldType fieldType + let fieldValueType = AstNodeMapper.unwrapFieldType fieldType pipeWriter writer { if fieldValueType = FieldValueType.DateOnly then do! (toTab Indented) + "#if NETSTANDARD2_0" @@ -292,7 +84,7 @@ let private writeJsonDerivedTypeAttributes2 interfaceName (classNames: string[]) let private getAppropriateClassTNodeTypeFromField (isNamedType: NamedType -> bool) assumeNullability fieldName (fields: Field[]) = fields |> Array.find _.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase) - |> fun field -> mapFieldTypeToString isNamedType assumeNullability field.ValueType UnwrapCollection + |> fun field -> AstNodeMapper.mapFieldTypeToString isNamedType assumeNullability field.ValueType UnwrapCollection let private writeInheritedUnionCaseType (context: IParsedContext) (unionCaseName: string) writer: ValueTask = pipeWriter writer { @@ -401,7 +193,7 @@ let private writeFields (context: IParsedContext) shouldSkipWritingField parentT pipeWriter writer { for field in writeableFields do - let fieldType = mapFieldTypeToString context.IsNamedType context.AssumeNullability field.ValueType KeepCollection + let fieldType = AstNodeMapper.mapFieldTypeToString context.IsNamedType context.AssumeNullability field.ValueType KeepCollection yield! writeSummary Indented field.XmlSummary yield! writeJsonPropertyAttribute field.Name @@ -410,7 +202,7 @@ let private writeFields (context: IParsedContext) shouldSkipWritingField parentT let fieldName = toCasing context.CasingType field.Name - |> sanitizeFieldName parentType + |> sanitizeFieldOrOperationName parentType do! (toTab Indented) + $$"""public {{fieldType}} {{fieldName}} { get; set; }""" @@ -527,11 +319,11 @@ let private writeInputObject (inputObject: InputObject) (context: IParsedContext do! NewLine } -let private writeUnionCaseWrappers unionTypeName (unionTypeCases: string[]) (writer: Writer): ValueTask = +let private writeUnionCaseWrappers (unionType: UnionType) (writer: Writer): ValueTask = pipeWriter writer { - for unionCaseName in unionTypeCases do - let caseWrapperName = toUnionCaseWrapperName unionTypeName unionCaseName - do! $"internal record {caseWrapperName}({unionCaseName} Value): {unionTypeName};" + for unionCase in unionType.Cases do + let caseWrapperName = toUnionCaseWrapperName unionType.Name unionCase.Name + do! $"internal record {caseWrapperName}({unionCase.Name} Value): {unionType.Name};" do! NewLine } @@ -544,11 +336,11 @@ let private writeUnionCaseWrappers unionTypeName (unionTypeCases: string[]) (wri /// /// /// -let private writeUnionTypeConversionMethods unionTypeName (unionCaseNames: string[]) writer: ValueTask = +let private writeUnionTypeConversionMethods (unionType: UnionType) writer: ValueTask = pipeWriter writer { - for unionCaseName in unionCaseNames do - let caseWrapperName = toUnionCaseWrapperName unionTypeName unionCaseName - do! (toTab Indented) + $"public {unionCaseName}? As{unionCaseName}() => this is {caseWrapperName} wrapper ? wrapper.Value : null;" + for unionCase in unionType.Cases do + let caseWrapperName = toUnionCaseWrapperName unionType.Name unionCase.Name + do! (toTab Indented) + $"public {unionCase.Name}? As{unionCase.Name}() => this is {caseWrapperName} wrapper ? wrapper.Value : null;" do! NewLine } @@ -564,28 +356,22 @@ let private writeUnionType (unionType: UnionType) (_: IParsedContext) (writer: W do! "{" do! NewLine - yield! writeUnionTypeConversionMethods unionType.Name unionType.Types + yield! writeUnionTypeConversionMethods unionType do! NewLine do! "}" do! NewLine - yield! writeUnionCaseWrappers unionType.Name unionType.Types + yield! writeUnionCaseWrappers unionType } -let private shouldSkipType visitedType: bool = +let private shouldSkipType (visitedType: VisitedTypes): bool = let typeNamesToSkip = Set.ofList [ "Node"; "INode" "PageInfo" ] - let typeName = - match visitedType with - | Class class' -> class'.Name - | Interface interface' -> interface'.Name - | Enum enum' -> enum'.Name - | InputObject inputObject -> inputObject.Name - | UnionType unionType -> unionType.Name - Set.contains typeName typeNamesToSkip + + Set.contains visitedType.Name typeNamesToSkip let private writeVisitedTypesToPipe (writer: Writer) (context: ParserContext): ValueTask = let parsedContext = context :> IParsedContext @@ -599,16 +385,18 @@ let private writeVisitedTypesToPipe (writer: Writer) (context: ParserContext): V () else match visitedType with - | Class class' -> + | VisitedTypes.Class class' -> yield! writeClass class' parsedContext - | Interface interface' -> + | VisitedTypes.Interface interface' -> yield! writeInterface interface' parsedContext - | Enum enum' -> + | VisitedTypes.Enum enum' -> yield! writeEnum enum' parsedContext - | InputObject inputObject -> + | VisitedTypes.InputObject inputObject -> yield! writeInputObject inputObject parsedContext - | UnionType unionType -> + | VisitedTypes.UnionType unionType -> yield! writeUnionType unionType parsedContext + | VisitedTypes.Operation _ -> + () } let writeVisitedTypesToFileSystem (destination: FileSystemDestination) (context: ParserContext) : ValueTask = @@ -622,13 +410,5 @@ let writeVisitedTypesToFileSystem (destination: FileSystemDestination) (context: do! pipe.Writer.CompleteAsync().ConfigureAwait(false); let! csharpCode = readTask - - match destination with - | SingleFile filePath -> - do! writeFileToPath filePath (csharpCode.ToString()) cancellationToken - | Directory directoryPath -> - do! parseCsharpCodeAndWriteToDirectoryPath directoryPath csharpCode cancellationToken - | DirectoryAndTemporaryFile(directoryPath, temporaryFilePath) -> - do! writeFileToPath temporaryFilePath (csharpCode.ToString()) cancellationToken - do! parseCsharpCodeAndWriteToDirectoryPath directoryPath csharpCode cancellationToken + do! (FileSystem.writeCsharpCodeToFileSystem destination csharpCode cancellationToken).ConfigureAwait(false) }) diff --git a/ShopifySharp/Entities/GraphQL/Edge.cs b/ShopifySharp/Entities/GraphQL/Edge.cs index e9c82f02..97108728 100644 --- a/ShopifySharp/Entities/GraphQL/Edge.cs +++ b/ShopifySharp/Entities/GraphQL/Edge.cs @@ -8,7 +8,7 @@ public interface IEdge TNode? node { get; } } -public record Edge: IEdge +public record Edge: IEdge, IGraphQLObject { public string? cursor { get; set; } public TNode? node { get; set; } diff --git a/ShopifySharp/Entities/GraphQL/IConnection.cs b/ShopifySharp/Entities/GraphQL/IConnection.cs index 6b14a949..7e9a95b6 100644 --- a/ShopifySharp/Entities/GraphQL/IConnection.cs +++ b/ShopifySharp/Entities/GraphQL/IConnection.cs @@ -7,7 +7,7 @@ namespace ShopifySharp.GraphQL; using System; using System.Collections.Generic; -public interface IConnection +public interface IConnection : IGraphQLObject { PageInfo? pageInfo { get; } int? totalCount { get; } diff --git a/ShopifySharp/Entities/GraphQL/Node.cs b/ShopifySharp/Entities/GraphQL/Node.cs index 73c9a111..00d263d9 100644 --- a/ShopifySharp/Entities/GraphQL/Node.cs +++ b/ShopifySharp/Entities/GraphQL/Node.cs @@ -3,7 +3,7 @@ namespace ShopifySharp.GraphQL; -public interface INode +public interface INode : IGraphQLObject { // ReSharper disable once InconsistentNaming string? id { get; } diff --git a/ShopifySharp/Entities/GraphQL/PageInfo.cs b/ShopifySharp/Entities/GraphQL/PageInfo.cs index ee35939c..6364f066 100644 --- a/ShopifySharp/Entities/GraphQL/PageInfo.cs +++ b/ShopifySharp/Entities/GraphQL/PageInfo.cs @@ -13,7 +13,7 @@ public sealed record PageInfo( string? startCursor, string? endCursor, bool hasPreviousPage, - bool hasNextPage) + bool hasNextPage) : IGraphQLObject { /// /// The cursor corresponding to the first node in edges. diff --git a/ShopifySharp/Infrastructure/GraphQueryBuilder.cs b/ShopifySharp/Infrastructure/GraphQueryBuilder.cs new file mode 100644 index 00000000..800dbb4e --- /dev/null +++ b/ShopifySharp/Infrastructure/GraphQueryBuilder.cs @@ -0,0 +1,59 @@ +#nullable enable +using System; +using System.Collections.Generic; +using ShopifySharp.GraphQL; + +namespace ShopifySharp.Infrastructure; + +public abstract class GraphQueryBuilder(string name) + where T: IGraphQLObject +{ + protected IQuery Query { get; private set; } = new Query(name); + + public void Alias(string alias) + { + Query = Query.Alias(alias); + } + + public void AddArgument(string key, object? value) + { + Query = Query.AddArgument(key, value); + } + + public void AddArguments(Dictionary arguments) + { + Query = Query.AddArguments(arguments); + } + + public void AddArguments(TArguments arguments) where TArguments : class + { + Query = Query.AddArguments(arguments); + } + + public void AddField(string name) + { + Query = Query.AddField(name); + } + + public void AddField(string name, Func, IQuery> customize) + where TField: class, IGraphQLObject + { + Query = Query.AddField(name, customize); + } + + public void AddUnion(string name, Func, GraphQueryBuilder> build) + where TUnionCase : class, IGraphQLUnionCase, IGraphQLObject + where TGraphQueryBuilder : GraphQueryBuilder, new() + { + var thing = new TGraphQueryBuilder(); + var union = build.Invoke(thing); + + Query.AddUnion(union.Query); + } + + public void AddUnion(string name, GraphQueryBuilder union) + where TUnionCase : class, IGraphQLUnionCase, IGraphQLObject + { + Query.AddUnion(union.Query); + } +} diff --git a/ShopifySharp/Infrastructure/Query.cs b/ShopifySharp/Infrastructure/Query.cs new file mode 100644 index 00000000..a93751d7 --- /dev/null +++ b/ShopifySharp/Infrastructure/Query.cs @@ -0,0 +1,243 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using ShopifySharp.GraphQL; + +namespace ShopifySharp.Infrastructure; + +public interface IQuery +{ + /// Gets the query name. + string Name { get; } + + /// Gets the alias name. + string? AliasName { get; } + + /// Builds the query. + /// The GraphQL query as string, without outer enclosing block. + /// Must have a 'Name' specified in the Query + /// Must have a one or more 'Select' fields in the Query + string Build(); +} + +public interface IQuery : IQuery +{ + List SelectList { get; } + Dictionary Arguments { get; } + IQuery Alias(string alias); + IQuery AddField(Expression> selector); + IQuery AddField(string field); + IQuery AddField( + Expression> selector, + Func, IQuery> build) + where TSubSource : class?; + IQuery AddField( + Expression>> selector, + Func, IQuery> build) + where TSubSource : class?; + IQuery AddField( + string field, + Func, IQuery> build) + where TSubSource : class?; + public IQuery AddUnion(IQuery union) + where TUnionType : class?; + IQuery AddUnion( + string typeName, + Func, IQuery> build) + where TUnionType : class?, TSource; + IQuery AddUnion( + Func, IQuery> build) + where TUnionType : class?, TSource; + IQuery AddArgument(string key, object? value); + IQuery AddArguments(Dictionary arguments); + IQuery AddArguments(TArguments arguments) where TArguments : class; +} + +public class Query : IQuery +{ + protected readonly QueryOptions Options; + + public string Name { get; } + public string? AliasName { get; private set; } + public List SelectList { get; private set; } = []; + public Dictionary Arguments { get; private set; } = []; + + public Query(string name, QueryOptions? options = null) + { + RequiredArgument.NotNullOrEmpty(name, nameof(name)); + Name = name; + Options = options ?? new QueryOptions(); + } + + public string Build() + { + throw new NotImplementedException(); + } + + public IQuery Alias(string alias) + { + RequiredArgument.NotNullOrEmpty(alias, nameof(alias)); + AliasName = alias; + return this; + } + + public IQuery AddField(Expression> selector) + { + RequiredArgument.NotNull(selector, nameof(selector)); + var property = GetPropertyInfo(selector); + var name = GetPropertyName(property); + + SelectList.Add(name); + + return this; + } + + public IQuery AddField(string field) + { + RequiredArgument.NotNullOrEmpty(field, nameof(field)); + SelectList.Add(field); + return this; + } + + public IQuery AddField( + Expression> selector, + Func, IQuery> build + ) where TSubSource : class? + { + RequiredArgument.NotNull(selector, nameof(selector)); + RequiredArgument.NotNull(build, nameof(build)); + + var property = GetPropertyInfo(selector); + var name = GetPropertyName(property); + + return AddField(name, build); + } + + public IQuery AddField( + Expression>> selector, + Func, IQuery> build + ) where TSubSource : class? + { + RequiredArgument.NotNull(selector, nameof(selector)); + RequiredArgument.NotNull(build, nameof(build)); + + var property = GetPropertyInfo(selector); + var name = GetPropertyName(property); + + return AddField(name, build); + } + + public IQuery AddField(string field, Func, IQuery> build) + where TSubSource : class? + { + RequiredArgument.NotNullOrEmpty(field, nameof(field)); + RequiredArgument.NotNull(build, nameof(build)); + + var query = new Query(field, Options); + var subQuery = build.Invoke(query); + + SelectList.Add(subQuery); + + return this; + } + + public IQuery AddUnion(IQuery union) + where TUnionType : class? + { + RequiredArgument.NotNull(union, nameof(union)); + + var query = new Query($"... on {union.Name}", Options); + + SelectList.Add(query); + + return this; + } + + public IQuery AddUnion(string typeName, Func, IQuery> build) + where TUnionType : class?, TSource + { + RequiredArgument.NotNullOrEmpty(typeName, nameof(typeName)); + RequiredArgument.NotNull(build, nameof(build)); + + var query = new Query($"... on {typeName}", Options); + var union = build.Invoke(query); + + return AddUnion(union); + } + + public IQuery AddUnion(Func, IQuery> build) + where TUnionType : class?, TSource + { + RequiredArgument.NotNull(build, nameof(build)); + return AddUnion(typeof(TUnionType).Name, build); + } + + public IQuery AddArgument(string key, object? value) + { + RequiredArgument.NotNullOrEmpty(key, nameof(key)); + Arguments.Add(key, value); + return this; + } + + public IQuery AddArguments(Dictionary arguments) + { + RequiredArgument.NotNull(arguments, nameof(arguments)); + + foreach (var argument in arguments) + Arguments.Add(argument.Key, argument.Value); + + return this; + } + + public IQuery AddArguments(TArguments arguments) where TArguments : class + { + RequiredArgument.NotNull(arguments, nameof(arguments)); + + IEnumerable properties = arguments + .GetType() + .GetProperties() + .Where(property => property.GetValue(arguments) != null) + .OrderBy(property => property.Name); + foreach (var property in properties) + { + Arguments.Add( + GetPropertyName(property), + property.GetValue(arguments)); + } + + return this; + } + + private static PropertyInfo GetPropertyInfo(Expression> lambda) + { + if (lambda.Body is not MemberExpression member) + throw new ArgumentException($"Expression '{lambda}' body is not member expression."); + + if (member.Member is not PropertyInfo propertyInfo) + throw new ArgumentException($"Expression '{lambda}' not refers to a property."); + + if (propertyInfo.ReflectedType is null) + throw new ArgumentException($"Expression '{lambda}' not refers to a property."); + + var type = typeof(TSource); + if (type != propertyInfo.ReflectedType && !propertyInfo.ReflectedType.IsAssignableFrom(type)) + throw new ArgumentException($"Expression '{lambda}' refers to a property that is not from type {type}."); + + return propertyInfo; + } + + private string GetPropertyName(PropertyInfo property) + { + // var thing = new Query("thing"); + // // TODO: how can we get intellisense on the string fields here? enums are obvious answer, and so are adding an + // // individual method for each known field. but stringly typed definitions would be idea + // thing.AddField("boop"); + // + return Options.Formatter is not null + ? Options.Formatter.Invoke(property) + : property.Name; + } +} diff --git a/ShopifySharp/Infrastructure/QueryOptions.cs b/ShopifySharp/Infrastructure/QueryOptions.cs new file mode 100644 index 00000000..c0c98b46 --- /dev/null +++ b/ShopifySharp/Infrastructure/QueryOptions.cs @@ -0,0 +1,14 @@ +#nullable enable +using System; +using System.Reflection; + +namespace ShopifySharp.Infrastructure; + +public class QueryOptions +{ + /// Gets or sets the property name formatter. + public Func? Formatter { get; set; } + + /// Gets or sets the query string builder factory. + public Func? QueryStringBuilderFactory { get; set; } +} diff --git a/ShopifySharp/Infrastructure/QueryStringBuilder.cs b/ShopifySharp/Infrastructure/QueryStringBuilder.cs new file mode 100644 index 00000000..a444ff68 --- /dev/null +++ b/ShopifySharp/Infrastructure/QueryStringBuilder.cs @@ -0,0 +1,264 @@ +#nullable enable +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace ShopifySharp.Infrastructure; + +/// The GraphQL query builder interface. +public interface IQueryStringBuilder +{ + /// Clears the string builder. + void Clear(); + + /// Builds the query. + /// The query. + /// The GraphQL query as string, without outer enclosing block. + string Build(IQuery query); +} + +/// The GraphQL query builder class. +public class QueryStringBuilder : IQueryStringBuilder +{ + /// The property name formatter. + protected readonly Func? Formatter; + + /// The query string builder. + public StringBuilder QueryString { get; } = new(); + + /// Initializes a new instance of the class. + public QueryStringBuilder() { } + + /// Initializes a new instance of the class. + /// The property name formatter + public QueryStringBuilder(Func formatter) + { + Formatter = formatter; + } + + /// Builds the query. + /// The query. + /// The GraphQL query as string, without outer enclosing block. + public string Build(IQuery query) + { + if (!string.IsNullOrWhiteSpace(query.AliasName)) + { + QueryString.Append($"{query.AliasName}:"); + } + + QueryString.Append(query.Name); + + if (query.Arguments.Count > 0) + { + QueryString.Append("("); + AddParams(query); + QueryString.Append(")"); + } + + if (query.SelectList.Count > 0) + { + QueryString.Append("{"); + AddFields(query); + QueryString.Append("}"); + } + + return QueryString.ToString(); + } + + /// Clears the string builder. + public void Clear() + { + QueryString.Clear(); + } + + /// + /// Formats query param. + /// + /// Returns: + /// + /// + /// null + /// null + /// + /// + /// String + /// "foo" + /// + /// + /// Number + /// 10 + /// + /// + /// Boolean + /// true or false + /// + /// + /// Enum + /// EnumValue + /// + /// + /// DateTime + /// "2024-06-15T13:45:30.0000000Z" + /// + /// + /// Key value pair + /// foo:"bar" or foo:10 ... + /// + /// + /// List + /// ["foo","bar"] or [1,2] ... + /// + /// + /// Dictionary + /// {foo:"bar",b:10} + /// + /// + /// Object + /// {foo:"bar",b:10} + /// + /// + /// + /// + /// The formatted query param. + /// Invalid Object Type in Param List + protected internal virtual string FormatQueryParam(object? value) + { + switch (value) + { + case null: + return "null"; + + case string strValue: + var encoded = strValue.Replace("\"", "\\\""); + return $"\"{encoded}\""; + + case char charValue: + return $"\"{charValue}\""; + + case byte byteValue: + return byteValue.ToString(); + + case sbyte sbyteValue: + return sbyteValue.ToString(); + + case short shortValue: + return shortValue.ToString(); + + case ushort ushortValue: + return ushortValue.ToString(); + + case int intValue: + return intValue.ToString(); + + case uint uintValue: + return uintValue.ToString(); + + case long longValue: + return longValue.ToString(); + + case ulong ulongValue: + return ulongValue.ToString(); + + case float floatValue: + return floatValue.ToString(CultureInfo.CreateSpecificCulture("en-us")); + + case double doubleValue: + return doubleValue.ToString(CultureInfo.CreateSpecificCulture("en-us")); + + case decimal decimalValue: + return decimalValue.ToString(CultureInfo.CreateSpecificCulture("en-us")); + + case bool booleanValue: + return booleanValue ? "true" : "false"; + + case Enum enumValue: + return enumValue.ToString(); + + case DateTime dateTimeValue: + return FormatQueryParam(dateTimeValue.ToString("o")); + + case KeyValuePair kvValue: + return $"{kvValue.Key}:{FormatQueryParam(kvValue.Value)}"; + + case IDictionary dictValue: + return $"{{{string.Join(",", dictValue.Select(e => FormatQueryParam(e)))}}}"; + + case IEnumerable enumerableValue: + List items = []; + foreach (var item in enumerableValue) + { + items.Add(FormatQueryParam(item)); + } + return $"[{string.Join(",", items)}]"; + + case { } objectValue: + var dictionary = ObjectToDictionary(objectValue); + return FormatQueryParam(dictionary); + } + } + + /// Adds query params to the query string. + /// The query. + protected internal void AddParams(IQuery query) + { + RequiredArgument.NotNull(query, nameof(query)); + + foreach (KeyValuePair param in query.Arguments) + { + QueryString.Append($"{param.Key}:{FormatQueryParam(param.Value)},"); + } + + if (query.Arguments.Count > 0) + { + QueryString.Length--; + } + } + + /// Adds fields to the query sting. + /// The query. + /// Invalid Object in Field List + protected internal void AddFields(IQuery query) + { + foreach (object? item in query.SelectList) + { + switch (item) + { + case string field: + QueryString.Append($"{field} "); + break; + + case IQuery subQuery: + QueryString.Append($"{subQuery.Build()} "); + break; + + default: + throw new ArgumentException("Invalid Field Type Specified, must be `string` or `Query`"); + } + } + + if (query.SelectList.Count > 0) + { + QueryString.Length--; + } + } + + /// Convert object into dictionary. + /// The object. + /// The object as dictionary. + private Dictionary ObjectToDictionary(object @object) => + @object + .GetType() + .GetProperties() + .Where(property => property.GetValue(@object) != null) + .Select(property => + new KeyValuePair( + Formatter is not null ? Formatter.Invoke(property) : property.Name, + property.GetValue(@object))) + .OrderBy(property => property.Key) + .ToDictionary(property => property.Key, property => property.Value); +} diff --git a/ShopifySharp/Infrastructure/RequiredArgument.cs b/ShopifySharp/Infrastructure/RequiredArgument.cs new file mode 100644 index 00000000..aed79c45 --- /dev/null +++ b/ShopifySharp/Infrastructure/RequiredArgument.cs @@ -0,0 +1,44 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; + +namespace ShopifySharp.Infrastructure; + +internal static class RequiredArgument +{ + /// Verifies argument is not null. + /// The parameter. + /// The parameter name. + /// The parameter type. + internal static void NotNull( + #if NET6_0_OR_GREATER + [NotNull] + #endif + TArgument? param, + string paramName) + { + if (param is null) + { + throw new ArgumentNullException(paramName); + } + } + + /// Verifies argument is not null or empty. + /// The parameter. + /// The parameter name. + internal static void NotNullOrEmpty( + #if NET6_0_OR_GREATER + [NotNull] + #endif + string? param, + string paramName + ) + { + NotNull(param, paramName); + + if (param.Length == 0) + { + throw new ArgumentException("Value cannot be empty.", paramName); + } + } +} diff --git a/ShopifySharp/Services/Graph/GraphService.cs b/ShopifySharp/Services/Graph/GraphService.cs index c62e94cd..945353f7 100644 --- a/ShopifySharp/Services/Graph/GraphService.cs +++ b/ShopifySharp/Services/Graph/GraphService.cs @@ -33,10 +33,10 @@ public class GraphService : ShopifyService, IGraphService public override string APIVersion => _apiVersion ?? base.APIVersion; - internal GraphService(ShopifyApiCredentials shopifyApiCredentials, IServiceProvider serviceProvider) + internal GraphService(ShopifyApiCredentials shopifyApiCredentials, IServiceProvider serviceProvider, string? apiVersion = null) : base(shopifyApiCredentials, serviceProvider) { - _apiVersion = null; + _apiVersion = apiVersion; (_httpContentSerializer, _jsonSerializer) = InitializeDependencies(serviceProvider); }