Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 72 additions & 17 deletions src/Trax.Cli/Generator/CodeRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,17 @@ public string RenderTrainInterface(ApiOperation operation, string projectName)
{
var isUnit = IsUnitOutput(operation.OutputType);
var ns = $"{projectName}.Trains.{operation.Group}.{operation.Name}";
var outputName = isUnit ? "Unit" : QualifyIfCollides(operation.OutputType.Name, operation);
return Render(
"TrainInterface",
new
{
Namespace = ns,
TrainName = operation.Name,
InputTypeName = operation.InputType.Fields.Count > 0
? operation.InputType.Name
: "Unit",
OutputTypeName = isUnit ? "Unit" : operation.OutputType.Name,
OutputTypeName = outputName,
OutputIsUnit = isUnit,
InputIsUnit = operation.InputType.Fields.Count == 0,
InputTypeName = operation.InputType.Name,
InputIsUnit = false,
ModelsUsing = _modelsNamespace,
}
);
Expand All @@ -47,21 +46,24 @@ public string RenderTrainImplementation(ApiOperation operation, string projectNa
var isUnit = IsUnitOutput(operation.OutputType);
var ns = $"{projectName}.Trains.{operation.Group}.{operation.Name}";
var attribute = operation.Kind == OperationKind.Query ? "TraxQuery" : "TraxMutation";
var outputName = isUnit ? "Unit" : QualifyIfCollides(operation.OutputType.Name, operation);
return Render(
"TrainImplementation",
new
{
Namespace = ns,
TrainName = operation.Name,
InputTypeName = operation.InputType.Fields.Count > 0
? operation.InputType.Name
: "Unit",
OutputTypeName = isUnit ? "Unit" : operation.OutputType.Name,
InputTypeName = operation.InputType.Name,
OutputTypeName = outputName,
OutputIsUnit = isUnit,
InputIsUnit = operation.InputType.Fields.Count == 0,
InputIsUnit = false,
Attribute = attribute,
Description = operation.Description ?? $"{operation.Name} operation",
Description = SanitizeDescription(
operation.Description ?? $"{operation.Name} operation"
),
ModelsUsing = _modelsNamespace,
GraphQLNamespace = operation.Group,
TrainsNamespace = $"{projectName}.Trains",
}
);
}
Expand All @@ -77,6 +79,7 @@ public string RenderInput(ApiOperation operation, string projectName)
TypeName = operation.InputType.Name,
Fields = operation.InputType.Fields.Select(MapField).ToList(),
HasFields = operation.InputType.Fields.Count > 0,
ModelsUsing = _modelsNamespace,
}
);
}
Expand All @@ -101,18 +104,17 @@ public string RenderJunction(ApiOperation operation, string projectName)
{
var isUnit = IsUnitOutput(operation.OutputType);
var ns = $"{projectName}.Trains.{operation.Group}.{operation.Name}";
var outputName = isUnit ? "Unit" : QualifyIfCollides(operation.OutputType.Name, operation);
return Render(
"Junction",
new
{
Namespace = ns,
TrainName = operation.Name,
InputTypeName = operation.InputType.Fields.Count > 0
? operation.InputType.Name
: "Unit",
OutputTypeName = isUnit ? "Unit" : operation.OutputType.Name,
InputTypeName = operation.InputType.Name,
OutputTypeName = outputName,
OutputIsUnit = isUnit,
InputIsUnit = operation.InputType.Fields.Count == 0,
InputIsUnit = false,
HttpMethod = operation.HttpMethod,
HttpPath = operation.HttpPath,
ModelsUsing = _modelsNamespace,
Expand Down Expand Up @@ -155,6 +157,33 @@ public string RenderTrainsCsproj()
return Render("TrainsCsproj", new { });
}

public string RenderGraphQLNamespaces(IEnumerable<string> groups, string projectName)
{
var scriptObject = new ScriptObject();
scriptObject["Namespace"] = $"{projectName}.Trains";

var groupList = new ScriptArray();
foreach (var group in groups)
{
var obj = new ScriptObject
{
["name"] = group,
["value"] = NamingConventions.ToCamelCase(group),
};
groupList.Add(obj);
}

scriptObject["Groups"] = groupList;

if (!_templates.TryGetValue("GraphQLNamespaces", out var template))
throw new InvalidOperationException("Template 'GraphQLNamespaces' not found.");

var context = new TemplateContext();
context.PushGlobal(scriptObject);

return template.Render(context);
}

public string RenderManifestNames(List<ApiOperation> operations, string projectName)
{
var scriptObject = new ScriptObject();
Expand Down Expand Up @@ -192,13 +221,39 @@ private static ScriptObject MapField(ApiField field)
["TypeName"] = field.TypeName,
["IsRequired"] = field.IsRequired,
["IsNullable"] = field.IsNullable,
["Description"] = field.Description,
["Description"] =
field.Description != null ? SanitizeDescription(field.Description) : null,
["RequiredKeyword"] = field.IsRequired ? "required " : "",
["NullableMarker"] = field.IsNullable && !field.TypeName.EndsWith('?') ? "?" : "",
};
return obj;
}

private static string SanitizeDescription(string description) =>
description.ReplaceLineEndings(" ").Replace("\"", "\\\"");

/// <summary>
/// Qualifies a type name with the full models namespace if it collides with
/// any segment of the operation's namespace (group or operation name).
/// This prevents CS0118 where C# resolves the name to the namespace instead of the type.
/// </summary>
private string QualifyIfCollides(string typeName, ApiOperation operation)
{
if (_modelsNamespace == null)
return typeName;

// Check if the type name matches the group or operation name (namespace segments)
if (
string.Equals(typeName, operation.Group, StringComparison.Ordinal)
|| string.Equals(typeName, operation.Name, StringComparison.Ordinal)
)
{
return $"global::{_modelsNamespace}.{typeName}";
}

return typeName;
}

private static bool IsUnitOutput(ApiType outputType) =>
outputType.Name == "Unit"
|| (outputType.IsBuiltIn && outputType.Fields.Count == 0 && outputType.Name == "Unit");
Expand Down
39 changes: 38 additions & 1 deletion src/Trax.Cli/Generator/NamingConventions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ public static partial class NamingConventions
"post",
};

private static readonly string[] HttpVerbPrefixes = ["Get", "Post", "Put", "Patch", "Delete"];

public static string ToPascalCase(string name)
{
if (string.IsNullOrEmpty(name))
Expand Down Expand Up @@ -189,6 +191,41 @@ private static string Pluralize(string noun)
return noun + "s";
}

[GeneratedRegex(@"[_\-\s]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")]
/// <summary>
/// Extracts the short type name from a potentially fully-qualified .NET type name.
/// For example, "AdvocacyDay.CVLegacy.Domain.Bills.DTOs.GetBillDto" becomes "GetBillDto".
/// Names without dots are returned unchanged.
/// </summary>
public static string SimplifySchemaName(string name)
{
if (string.IsNullOrEmpty(name))
return name;

var lastDot = name.LastIndexOf('.');
return lastDot >= 0 ? name[(lastDot + 1)..] : name;
}

/// <summary>
/// Strips HTTP verb prefixes (Get, Post, Put, Patch, Delete) from a PascalCase name.
/// Returns the original name if stripping would leave it empty.
/// </summary>
public static string StripHttpVerbPrefix(string name)
{
foreach (var prefix in HttpVerbPrefixes)
{
if (
name.StartsWith(prefix, StringComparison.Ordinal)
&& name.Length > prefix.Length
&& char.IsUpper(name[prefix.Length])
)
{
return name[prefix.Length..];
}
}

return name;
}

[GeneratedRegex(@"[_\-\s.,]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")]
private static partial Regex SplitPattern();
}
30 changes: 22 additions & 8 deletions src/Trax.Cli/Generator/TraxProjectGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,27 @@ internal void GenerateTrainsLibrary(ApiSchema schema, string trainsDir, string p
_renderer.RenderManifestNames(schema.Operations, projectName)
);

// Write GraphQLNamespaces.cs
var groups = schema
.Operations.Select(o => o.Group)
.Where(g => g != null)
.Cast<string>()
.Distinct()
.OrderBy(g => g)
.ToList();

if (groups.Count > 0)
{
WriteFile(
Path.Combine(trainsDir, "GraphQLNamespaces.cs"),
_renderer.RenderGraphQLNamespaces(groups, projectName)
);
}

// Write shared types in Models/
foreach (var apiType in schema.Types)
{
if (apiType.IsBuiltIn || apiType.Fields.Count == 0)
if (apiType.IsBuiltIn || apiType.Name == "Unit" || apiType.Fields.Count == 0)
continue;

var modelsDir = Path.Combine(trainsDir, "Models");
Expand Down Expand Up @@ -104,13 +121,10 @@ internal void GenerateTrainsLibrary(ApiSchema schema, string trainsDir, string p
_renderer.RenderTrainImplementation(operation, projectName)
);

if (operation.InputType.Fields.Count > 0)
{
WriteFile(
Path.Combine(trainDir, $"{operation.InputType.Name}.cs"),
_renderer.RenderInput(operation, projectName)
);
}
WriteFile(
Path.Combine(trainDir, $"{operation.InputType.Name}.cs"),
_renderer.RenderInput(operation, projectName)
);

if (!operation.OutputType.IsBuiltIn && operation.OutputType.Fields.Count > 0)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Trax.Cli/Models/ApiField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Trax.Cli.Models;
public class ApiField
{
public required string Name { get; init; }
public required string TypeName { get; init; }
public required string TypeName { get; set; }
public bool IsRequired { get; init; }
public bool IsNullable { get; init; }
public string? Description { get; init; }
Expand Down
18 changes: 18 additions & 0 deletions src/Trax.Cli/Schema/GraphQL/GraphQLSchemaParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,28 @@ Dictionary<string, GraphQLEnumTypeDefinition> enumDefinitions
if (rootType.Fields == null)
return;

// Collect all known type names to detect namespace/type collisions (CS0118).
// When an operation name matches a type name, the generated namespace segment
// shadows the type reference — e.g. Flowthru.Trains.Group.AllChats.AllChats
var knownTypeNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var name in typeDefinitions.Keys)
knownTypeNames.Add(NamingConventions.ToPascalCase(name));
foreach (var name in inputDefinitions.Keys)
knownTypeNames.Add(NamingConventions.ToPascalCase(name));
foreach (var name in enumDefinitions.Keys)
knownTypeNames.Add(NamingConventions.ToPascalCase(name));

foreach (var field in rootType.Fields)
{
var operationName = NamingConventions.ToPascalCase(field.Name.StringValue);

// Disambiguate if the operation name collides with a known type name
if (knownTypeNames.Contains(operationName))
{
var suffix = kind == OperationKind.Query ? "Query" : "Mutation";
operationName = $"{operationName}{suffix}";
}

// Build input type from arguments
var inputType = BuildInputTypeFromArguments(
operationName,
Expand Down
Loading
Loading