diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4bb7923 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,28 @@ +name: Build and Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cd1e184 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish NuGet Package + +on: + release: + types: [published] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal + + - name: Pack + run: dotnet pack GraphQLSourceGen/GraphQLSourceGen.csproj --no-build --configuration Release -o ./nupkgs + + - name: Push to NuGet + run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/GraphQLSourceGen.Samples/GraphQLSourceGen.Samples.csproj b/GraphQLSourceGen.Samples/GraphQLSourceGen.Samples.csproj new file mode 100644 index 0000000..4fd4adb --- /dev/null +++ b/GraphQLSourceGen.Samples/GraphQLSourceGen.Samples.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + GraphQL.Generated + true + + + \ No newline at end of file diff --git a/GraphQLSourceGen.Samples/Program.cs b/GraphQLSourceGen.Samples/Program.cs new file mode 100644 index 0000000..5a1f4fb --- /dev/null +++ b/GraphQLSourceGen.Samples/Program.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; + +namespace GraphQLSourceGen.Samples +{ + // Define the classes that would normally be generated + public class UserBasicFragment + { + public string? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public bool? IsActive { get; set; } + } + + public class PostWithStatsFragment + { + public string? Id { get; set; } + public string? Title { get; set; } + public int? ViewCount { get; set; } + public double? Rating { get; set; } + public bool IsPublished { get; set; } + public DateTime? PublishedAt { get; set; } + public List? Tags { get; set; } + public List Categories { get; set; } = new List(); + } + + class Program + { + static void Main(string[] args) + { + Console.WriteLine("GraphQL Source Generator Samples"); + Console.WriteLine("================================"); + + // Create a UserBasicFragment instance + var user = new UserBasicFragment + { + Id = "user-123", + Name = "John Doe", + Email = "john.doe@example.com", + IsActive = true + }; + + Console.WriteLine("\nUser Basic Fragment:"); + Console.WriteLine($"ID: {user.Id}"); + Console.WriteLine($"Name: {user.Name}"); + Console.WriteLine($"Email: {user.Email}"); + Console.WriteLine($"Active: {user.IsActive}"); + + // Create a PostWithStatsFragment instance + var post = new PostWithStatsFragment + { + Id = "post-123", + Title = "GraphQL and C# Source Generators", + ViewCount = 1250, + Rating = 4.8, + IsPublished = true, + PublishedAt = DateTime.Now.AddDays(-14), + Tags = new List { "GraphQL", "C#", "Source Generators" }, + Categories = new List { "Programming", "Web Development" } + }; + + Console.WriteLine("\nPost With Stats Fragment:"); + Console.WriteLine($"ID: {post.Id}"); + Console.WriteLine($"Title: {post.Title}"); + Console.WriteLine($"Views: {post.ViewCount}"); + Console.WriteLine($"Rating: {post.Rating}"); + Console.WriteLine($"Published: {post.IsPublished}"); + Console.WriteLine($"Published At: {post.PublishedAt}"); + Console.WriteLine($"Tags: {string.Join(", ", post.Tags ?? new List())}"); + Console.WriteLine($"Categories: {string.Join(", ", post.Categories)}"); + + Console.WriteLine("\nPress any key to exit..."); + Console.ReadKey(); + } + } +} \ No newline at end of file diff --git a/GraphQLSourceGen.Samples/README.md b/GraphQLSourceGen.Samples/README.md new file mode 100644 index 0000000..e69de29 diff --git a/GraphQLSourceGen.Samples/schema.graphql b/GraphQLSourceGen.Samples/schema.graphql new file mode 100644 index 0000000..6e4112a --- /dev/null +++ b/GraphQLSourceGen.Samples/schema.graphql @@ -0,0 +1,78 @@ +# This is a sample GraphQL file with fragment definitions + +# A simple fragment with scalar fields +fragment UserBasic on User { + id + name + email + isActive +} + +# A fragment with nested objects and lists +fragment UserDetails on User { + id + name + email + isActive + profile { + bio + avatarUrl + joinDate + } + posts { + id + title + content + createdAt + } + followers { + id + name + } +} + +# A fragment with non-nullable fields +fragment RequiredUserInfo on User { + id! + name! + email +} + +# A fragment with deprecated fields +fragment UserWithDeprecated on User { + id + name + email + username @deprecated(reason: "Use email instead") + oldField @deprecated +} + +# A fragment that uses another fragment +fragment UserWithPosts on User { + ...UserBasic + posts { + id + title + publishedAt + comments { + id + text + author { + id + name + } + } + } +} + +# A fragment with various scalar types +fragment PostWithStats on Post { + id: ID! + title: String! + viewCount: Int + rating: Float + isPublished: Boolean! + publishedAt: DateTime + tags: [String] + categories: [String!]! +} \ No newline at end of file diff --git a/GraphQLSourceGen.Tests/GraphQLFragmentGeneratorTests.cs b/GraphQLSourceGen.Tests/GraphQLFragmentGeneratorTests.cs index 239c378..b9a2461 100644 --- a/GraphQLSourceGen.Tests/GraphQLFragmentGeneratorTests.cs +++ b/GraphQLSourceGen.Tests/GraphQLFragmentGeneratorTests.cs @@ -28,7 +28,7 @@ fragment UserBasic on User { var fragments = GraphQLParser.ParseContent(graphqlContent); // Verify the fragment was parsed correctly - Assert.Equal(1, fragments.Count); + Assert.Single(fragments); var fragment = fragments[0]; Assert.Equal("UserBasic", fragment.Name); diff --git a/GraphQLSourceGen.Tests/GraphQLParserTests.cs b/GraphQLSourceGen.Tests/GraphQLParserTests.cs index 4e26518..8194c7f 100644 --- a/GraphQLSourceGen.Tests/GraphQLParserTests.cs +++ b/GraphQLSourceGen.Tests/GraphQLParserTests.cs @@ -23,7 +23,7 @@ fragment UserBasic on User { var fragments = GraphQLParser.ParseContent(graphql); // Assert - Assert.Equal(1, fragments.Count); + Assert.Single(fragments); var fragment = fragments[0]; Assert.Equal("UserBasic", fragment.Name); @@ -53,7 +53,7 @@ fragment UserDetails on User { var fragments = GraphQLParser.ParseContent(graphql); // Assert - Assert.Equal(1, fragments.Count); + Assert.Single(fragments); var fragment = fragments[0]; Assert.Equal("UserDetails", fragment.Name); @@ -82,7 +82,7 @@ oldField @deprecated var fragments = GraphQLParser.ParseContent(graphql); // Assert - Assert.Equal(1, fragments.Count); + Assert.Single(fragments); var fragment = fragments[0]; Assert.Equal(3, fragment.Fields.Count); @@ -114,7 +114,7 @@ fragment UserWithPosts on User { var fragments = GraphQLParser.ParseContent(graphql); // Assert - Assert.Equal(1, fragments.Count); + Assert.Single(fragments); var fragment = fragments[0]; Assert.Equal(2, fragment.Fields.Count); @@ -149,7 +149,7 @@ fragment PostWithStats on Post { var fragments = GraphQLParser.ParseContent(graphql); // Assert - Assert.Equal(1, fragments.Count); + Assert.Single(fragments); var fragment = fragments[0]; Assert.Equal(8, fragment.Fields.Count); diff --git a/GraphQLSourceGen/Configuration/GraphQLSourceGenOptions.cs b/GraphQLSourceGen/Configuration/GraphQLSourceGenOptions.cs new file mode 100644 index 0000000..da2d2ab --- /dev/null +++ b/GraphQLSourceGen/Configuration/GraphQLSourceGenOptions.cs @@ -0,0 +1,30 @@ +using System; + +namespace GraphQLSourceGen.Configuration +{ + /// + /// Configuration options for GraphQL Source Generator + /// + public class GraphQLSourceGenOptions + { + /// + /// The namespace to use for generated types. If null, the namespace will be "GraphQL.Generated" + /// + public string? Namespace { get; set; } + + /// + /// Whether to generate records (true) or classes (false) + /// + public bool UseRecords { get; set; } = true; + + /// + /// Whether to use init-only properties + /// + public bool UseInitProperties { get; set; } = true; + + /// + /// Whether to include XML documentation comments in generated code + /// + public bool GenerateDocComments { get; set; } = true; + } +} \ No newline at end of file diff --git a/GraphQLSourceGen/Diagnostics/DiagnosticDescriptors.cs b/GraphQLSourceGen/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000..2b32d30 --- /dev/null +++ b/GraphQLSourceGen/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,67 @@ +using Microsoft.CodeAnalysis; + +namespace GraphQLSourceGen.Diagnostics +{ + /// + /// Diagnostic descriptors for GraphQL Source Generator + /// + internal static class DiagnosticDescriptors + { + private const string AnalyzerReleaseTrackingId = "GQLSG"; + /// + /// Invalid GraphQL syntax diagnostic + /// + public static readonly DiagnosticDescriptor InvalidGraphQLSyntax = new( + id: "GQLSG001", + title: "Invalid GraphQL syntax", + messageFormat: "The GraphQL file contains invalid syntax: {0}", + category: "GraphQLSourceGen", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The GraphQL file contains syntax that could not be parsed. Fix the syntax error to generate code.", + helpLinkUri: $"https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md", + customTags: new[] { WellKnownDiagnosticTags.AnalyzerException }); + + /// + /// No GraphQL fragments found diagnostic + /// + public static readonly DiagnosticDescriptor NoFragmentsFound = new( + id: "GQLSG002", + title: "No GraphQL fragments found", + messageFormat: "No GraphQL fragments were found in the file {0}", + category: "GraphQLSourceGen", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The GraphQL file does not contain any fragment definitions. Add fragment definitions to generate code.", + helpLinkUri: $"https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md", + customTags: new[] { WellKnownDiagnosticTags.AnalyzerException }); + + /// + /// Invalid fragment name diagnostic + /// + public static readonly DiagnosticDescriptor InvalidFragmentName = new( + id: "GQLSG003", + title: "Invalid fragment name", + messageFormat: "The fragment name '{0}' is not a valid C# identifier", + category: "GraphQLSourceGen", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The fragment name must be a valid C# identifier. Rename the fragment to a valid C# identifier.", + helpLinkUri: $"https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md", + customTags: new[] { WellKnownDiagnosticTags.AnalyzerException }); + + /// + /// Fragment spread not found diagnostic + /// + public static readonly DiagnosticDescriptor FragmentSpreadNotFound = new( + id: "GQLSG004", + title: "Fragment spread not found", + messageFormat: "The fragment spread '{0}' was not found in any of the GraphQL files", + category: "GraphQLSourceGen", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The fragment spread references a fragment that was not found in any of the GraphQL files. Make sure the fragment is defined in one of the GraphQL files.", + helpLinkUri: $"https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md", + customTags: new[] { WellKnownDiagnosticTags.AnalyzerException }); + } +} \ No newline at end of file diff --git a/GraphQLSourceGen/GraphQLFragmentGenerator.cs b/GraphQLSourceGen/GraphQLFragmentGenerator.cs index 36f51f8..62c6ec3 100644 --- a/GraphQLSourceGen/GraphQLFragmentGenerator.cs +++ b/GraphQLSourceGen/GraphQLFragmentGenerator.cs @@ -1,3 +1,5 @@ +using GraphQLSourceGen.Configuration; +using GraphQLSourceGen.Diagnostics; using GraphQLSourceGen.Models; using GraphQLSourceGen.Parsing; using Microsoft.CodeAnalysis; @@ -16,6 +18,9 @@ public void Initialize(GeneratorInitializationContext context) public void Execute(GeneratorExecutionContext context) { + // Read configuration from MSBuild properties + var options = ReadConfiguration(context); + // Find all .graphql files in the project var graphqlFiles = context.AdditionalFiles .Where(file => file.Path.EndsWith(".graphql", StringComparison.OrdinalIgnoreCase)) @@ -31,35 +36,144 @@ public void Execute(GeneratorExecutionContext context) var allFragments = new List(); foreach (var file in graphqlFiles) { - var fileContent = file.GetText()?.ToString() ?? string.Empty; - var fragments = GraphQLParser.ParseFile(fileContent); - allFragments.AddRange(fragments); + try + { + var fileContent = file.GetText()?.ToString() ?? string.Empty; + var fragments = GraphQLParser.ParseFile(fileContent); + + if (!fragments.Any()) + { + // Report diagnostic for no fragments found + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.NoFragmentsFound, + Location.None, + Path.GetFileName(file.Path)); + context.ReportDiagnostic(diagnostic); + continue; + } + + allFragments.AddRange(fragments); + } + catch (Exception ex) + { + // Report diagnostic for parsing error + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.InvalidGraphQLSyntax, + Location.None, + ex.Message); + context.ReportDiagnostic(diagnostic); + } } + // Validate fragment names and references + ValidateFragments(context, allFragments); + // Generate code for each fragment foreach (var fragment in allFragments) { - string generatedCode = GenerateFragmentCode(fragment, allFragments); + string generatedCode = GenerateFragmentCode(fragment, allFragments, options); context.AddSource($"{fragment.Name}Fragment.g.cs", SourceText.From(generatedCode, Encoding.UTF8)); } } + + private void ValidateFragments(GeneratorExecutionContext context, List fragments) + { + // Check for invalid fragment names + foreach (var fragment in fragments) + { + if (!IsValidCSharpIdentifier(fragment.Name)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.InvalidFragmentName, + Location.None, + fragment.Name); + context.ReportDiagnostic(diagnostic); + } + } + + // Check for fragment spreads that don't exist + var fragmentNames = new HashSet(fragments.Select(f => f.Name)); + foreach (var fragment in fragments) + { + foreach (var field in fragment.Fields) + { + foreach (var spread in field.FragmentSpreads) + { + if (!fragmentNames.Contains(spread)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.FragmentSpreadNotFound, + Location.None, + spread); + context.ReportDiagnostic(diagnostic); + } + } + } + } + } + + private bool IsValidCSharpIdentifier(string name) + { + if (string.IsNullOrEmpty(name)) + return false; + + if (!char.IsLetter(name[0]) && name[0] != '_') + return false; + + for (int i = 1; i < name.Length; i++) + { + if (!char.IsLetterOrDigit(name[i]) && name[i] != '_') + return false; + } + + return true; + } + + private GraphQLSourceGenOptions ReadConfiguration(GeneratorExecutionContext context) + { + var options = new GraphQLSourceGenOptions(); + + if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.GraphQLSourceGenNamespace", out var ns)) + { + options.Namespace = ns; + } + + if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.GraphQLSourceGenUseRecords", out var useRecords)) + { + options.UseRecords = bool.TryParse(useRecords, out var value) && value; + } + + if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.GraphQLSourceGenUseInitProperties", out var useInitProperties)) + { + options.UseInitProperties = bool.TryParse(useInitProperties, out var value) && value; + } + + if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.GraphQLSourceGenGenerateDocComments", out var generateDocComments)) + { + options.GenerateDocComments = bool.TryParse(generateDocComments, out var value) && value; + } + + return options; + } - string GenerateFragmentCode(GraphQLFragment fragment, List allFragments) + string GenerateFragmentCode(GraphQLFragment fragment, List allFragments, GraphQLSourceGenOptions options) { var sb = new StringBuilder(); - // Add using statements + // Add using statements and nullable directive sb.AppendLine("using System;"); sb.AppendLine("using System.Collections.Generic;"); sb.AppendLine(); - + sb.AppendLine("#nullable enable"); + sb.AppendLine(); // Add namespace - sb.AppendLine("namespace GraphQL.Generated"); + string ns = options.Namespace ?? "GraphQL.Generated"; + sb.AppendLine($"namespace {ns}"); sb.AppendLine("{"); - // Generate the record - GenerateClass(sb, fragment, allFragments, " "); + // Generate the class or record + GenerateClass(sb, fragment, allFragments, " ", options); // Close namespace sb.AppendLine("}"); @@ -67,45 +181,79 @@ string GenerateFragmentCode(GraphQLFragment fragment, List allF return sb.ToString(); } - void GenerateClass(StringBuilder sb, GraphQLFragment fragment, List allFragments, string indent) + void GenerateClass(StringBuilder sb, GraphQLFragment fragment, List allFragments, string indent, GraphQLSourceGenOptions options) { - // Class declaration - sb.AppendLine($"{indent}/// "); - sb.AppendLine($"{indent}/// Generated from GraphQL fragment '{fragment.Name}' on type '{fragment.OnType}'"); - sb.AppendLine($"{indent}/// "); - sb.AppendLine($"{indent}public class {fragment.Name}Fragment"); + // Class or record declaration + if (options.GenerateDocComments) + { + sb.AppendLine($"{indent}/// "); + sb.AppendLine($"{indent}/// Generated from GraphQL fragment '{fragment.Name}' on type '{fragment.OnType}'"); + sb.AppendLine($"{indent}/// "); + } + + string typeKeyword = options.UseRecords ? "record" : "class"; + sb.AppendLine($"{indent}public {typeKeyword} {fragment.Name}Fragment"); sb.AppendLine($"{indent}{{"); // Generate properties for each field foreach (var field in fragment.Fields) { - GenerateProperty(sb, field, allFragments, indent + " "); + GenerateProperty(sb, field, allFragments, indent + " ", options); } - // Generate nested records for complex fields + // Generate nested classes for complex fields foreach (var field in fragment.Fields.Where(f => f.SelectionSet.Any())) { sb.AppendLine(); - var nestedFragment = new GraphQLFragment + string nestedTypeName = char.ToUpper(field.Name[0]) + field.Name.Substring(1); + + if (options.GenerateDocComments) + { + sb.AppendLine($"{indent} /// "); + sb.AppendLine($"{indent} /// Represents the {field.Name} field of {fragment.Name}"); + sb.AppendLine($"{indent} /// "); + } + + string nestedTypeKeyword = options.UseRecords ? "record" : "class"; + sb.AppendLine($"{indent} public {nestedTypeKeyword} {nestedTypeName}Model"); + sb.AppendLine($"{indent} {{"); + + // Generate properties for nested fields + foreach (var nestedField in field.SelectionSet) { - Name = $"{fragment.Name}_{char.ToUpper(field.Name[0]) + field.Name.Substring(1)}", - OnType = field.Type.Name, - Fields = field.SelectionSet - }; + GenerateProperty(sb, nestedField, allFragments, indent + " ", options); + } + + // Generate nested classes for nested fields with selection sets + foreach (var nestedField in field.SelectionSet.Where(f => f.SelectionSet.Any())) + { + sb.AppendLine(); + var nestedFragment = new GraphQLFragment + { + Name = $"{nestedTypeName}_{char.ToUpper(nestedField.Name[0]) + nestedField.Name.Substring(1)}", + OnType = nestedField.Type.Name, + Fields = nestedField.SelectionSet + }; - GenerateClass(sb, nestedFragment, allFragments, indent + " "); + GenerateClass(sb, nestedFragment, allFragments, indent + " ", options); + } + + sb.AppendLine($"{indent} }}"); } - // Close record + // Close class or record sb.AppendLine($"{indent}}}"); } - void GenerateProperty(StringBuilder sb, GraphQLField field, List allFragments, string indent) + void GenerateProperty(StringBuilder sb, GraphQLField field, List allFragments, string indent, GraphQLSourceGenOptions options) { // Add XML documentation - sb.AppendLine($"{indent}/// "); - sb.AppendLine($"{indent}/// {field.Name}"); - sb.AppendLine($"{indent}/// "); + if (options.GenerateDocComments) + { + sb.AppendLine($"{indent}/// "); + sb.AppendLine($"{indent}/// {field.Name}"); + sb.AppendLine($"{indent}/// "); + } // Add [Obsolete] attribute if the field is deprecated if (field.IsDeprecated) @@ -120,18 +268,18 @@ void GenerateProperty(StringBuilder sb, GraphQLField field, List{(isNullable ? "?" : "")}"; + propertyType = $"List<{typeName}Model>{(isNullable ? "?" : "")}"; } else { - propertyType = $"{typeName}{(isNullable ? "?" : "")}"; + propertyType = $"{typeName}Model{(isNullable ? "?" : "")}"; } } else if (field.FragmentSpreads.Any()) @@ -152,7 +300,8 @@ void GenerateProperty(StringBuilder sb, GraphQLField field, Listlatest enable enable + + false true true + true + + + GraphQLSourceGen + 1.0.0 + Your Name + A Roslyn-based C# Source Generator that creates C# types from GraphQL fragment definitions. + graphql;source-generator;codegen;roslyn + MIT + https://github.com/yourusername/graphql-sourcegen + https://github.com/yourusername/graphql-sourcegen + git + README.md + + + true + $(NoWarn);CS1591 @@ -15,8 +34,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/GraphQLSourceGen/build/GraphQLSourceGen.props b/GraphQLSourceGen/build/GraphQLSourceGen.props new file mode 100644 index 0000000..a431a5d --- /dev/null +++ b/GraphQLSourceGen/build/GraphQLSourceGen.props @@ -0,0 +1,9 @@ + + + + GraphQL.Generated + true + true + true + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c6bc987 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 GraphQLSourceGen Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 64a3afc..5971e4e 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,45 @@ # GraphQL Fragment Source Generator -A Roslyn-based C# Source Generator that scans GraphQL files for fragment definitions and produces matching C# record types. +A Roslyn-based C# Source Generator that scans GraphQL files for fragment definitions and produces matching C# types. ## Features -- Automatically generates C# record types from GraphQL fragment definitions +- Automatically generates C# types from GraphQL fragment definitions - Preserves GraphQL scalar-to-C# type mappings - Supports nullable reference types to reflect GraphQL nullability -- Handles nested selections with nested record types +- Handles nested selections with nested types - Supports `@deprecated` directive with `[Obsolete]` attributes - Handles fragment spreads through composition +- Configurable output (namespace, records vs classes, etc.) +- Comprehensive error reporting and diagnostics ## Installation -Add a reference to the source generator project in your .NET 6+ SDK-style project: +### NuGet Package (Recommended) + +Install the package from NuGet: + +```bash +dotnet add package GraphQLSourceGen +``` + +Or add it directly to your project file: + +```xml + + + +``` + +### Project Reference (For Development) + +If you're developing or customising the generator, add a reference to the source generator project: ```xml + ReferenceOutputAssembly="true" /> ``` @@ -34,11 +54,31 @@ Add a reference to the source generator project in your .NET 6+ SDK-style projec ``` -3. The source generator will automatically process these files and generate C# record types for each fragment +3. The source generator will automatically process these files and generate C# types for each fragment -## Example +## Configuration -### GraphQL Fragment +You can configure the generator using MSBuild properties in your project file: + +```xml + + + MyCompany.GraphQL.Generated + + + false + + + false + + + false + +``` + +## Examples + +### Basic Fragment ```graphql fragment UserBasic on User { @@ -49,12 +89,14 @@ fragment UserBasic on User { } ``` -### Generated C# Record +Generated C# type: ```csharp using System; using System.Collections.Generic; +#nullable enable + namespace GraphQL.Generated { /// @@ -84,3 +126,77 @@ namespace GraphQL.Generated } } ``` + +### Fragment with Nested Objects + +```graphql +fragment UserWithProfile on User { + id + name + profile { + bio + avatarUrl + joinDate + } +} +``` + +### Fragment with Non-Nullable Fields + +```graphql +fragment RequiredUserInfo on User { + id! + name! + email +} +``` + +### Fragment with Deprecated Fields + +```graphql +fragment UserWithDeprecated on User { + id + name + username @deprecated(reason: "Use email instead") + oldField @deprecated +} +``` + +### Fragment that References Another Fragment + +```graphql +fragment UserWithPosts on User { + ...UserBasic + posts { + id + title + } +} +``` + +## Troubleshooting + +### Common Issues + +#### No Code is Generated + +Make sure: +- Your `.graphql` files are marked as `AdditionalFiles` in your project file +- Your GraphQL files contain valid fragment definitions +- You've added the package reference correctly + +#### Error GQLSG001: Invalid GraphQL syntax + +This indicates a syntax error in your GraphQL file. Check the error message for details on the specific issue. + +#### Error GQLSG002: No GraphQL fragments found + +The GraphQL file doesn't contain any fragment definitions. Make sure you're using the `fragment Name on Type { ... }` syntax. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/graphql-sourcegen.sln b/graphql-sourcegen.sln index c5e2079..722b47e 100644 --- a/graphql-sourcegen.sln +++ b/graphql-sourcegen.sln @@ -6,6 +6,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQLSourceGen.Tests", "G EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQLSourceGen", "GraphQLSourceGen\GraphQLSourceGen.csproj", "{3517AEAF-A318-D15F-A9CC-DDF3639CC089}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQLSourceGen.Samples", "GraphQLSourceGen.Samples\GraphQLSourceGen.Samples.csproj", "{B4C5D6E7-F8A9-0B1C-2D3E-4F5A6B7C8D9E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,6 +22,10 @@ Global {3517AEAF-A318-D15F-A9CC-DDF3639CC089}.Debug|Any CPU.Build.0 = Debug|Any CPU {3517AEAF-A318-D15F-A9CC-DDF3639CC089}.Release|Any CPU.ActiveCfg = Release|Any CPU {3517AEAF-A318-D15F-A9CC-DDF3639CC089}.Release|Any CPU.Build.0 = Release|Any CPU + {B4C5D6E7-F8A9-0B1C-2D3E-4F5A6B7C8D9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4C5D6E7-F8A9-0B1C-2D3E-4F5A6B7C8D9E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4C5D6E7-F8A9-0B1C-2D3E-4F5A6B7C8D9E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4C5D6E7-F8A9-0B1C-2D3E-4F5A6B7C8D9E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE