Skip to content

Commit 0402235

Browse files
committed
merge: C# Minimal API CRUD routes codegen [P2b]
2 parents c68c0db + 5cc4280 commit 0402235

5 files changed

Lines changed: 133 additions & 15 deletions

File tree

server/csharp/MetaObjects.Cli/GenCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ public sealed record Outcome(IReadOnlyList<string> LoadErrors, CodegenRunner.Run
1818
public bool Ok => LoadErrors.Count == 0 && Result is not null;
1919
}
2020

21-
/// <summary>The default generator set (data layer). Extended as more generators land.</summary>
21+
/// <summary>The default generator set. Extended as more generators land.</summary>
2222
public static IReadOnlyList<IGenerator> DefaultGenerators() =>
23-
[new EntityGenerator(), new DbContextGenerator()];
23+
[new EntityGenerator(), new DbContextGenerator(), new RoutesGenerator()];
2424

2525
public static Outcome Run(string metadataDir, string outDir, string ns)
2626
{

server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ public void DbContext_exposes_a_dbset_per_entity()
8888
Assert.Contains("public DbSet<Subscriber> Subscribers { get; set; } = default!;", file.Content);
8989
}
9090

91+
[Fact]
92+
public void Routes_generator_emits_crud_endpoints()
93+
{
94+
var ctx = Ctx(Load());
95+
var file = Assert.Single(new RoutesGenerator().Generate(ctx));
96+
Assert.Equal("SubscriberRoutes.g.cs", file.Path);
97+
var src = file.Content;
98+
99+
Assert.Contains("public static class SubscriberRoutes", src);
100+
Assert.Contains("public static IEndpointRouteBuilder MapSubscriberRoutes(this IEndpointRouteBuilder app, string prefix = \"/api\")", src);
101+
Assert.Contains("app.MapGet(prefix + \"/subscribers\", async (AppDbContext db) =>", src);
102+
Assert.Contains("app.MapGet(prefix + \"/subscribers/{id}\", async (long id, AppDbContext db) =>", src);
103+
Assert.Contains("db.Subscribers.FindAsync(id)", src);
104+
Assert.Contains("app.MapPost(prefix + \"/subscribers\", async (Subscriber input, AppDbContext db) =>", src);
105+
Assert.Contains("app.MapPut(prefix + \"/subscribers/{id}\", async (long id, Subscriber input, AppDbContext db) =>", src);
106+
Assert.Contains("app.MapDelete(prefix + \"/subscribers/{id}\", async (long id, AppDbContext db) =>", src);
107+
}
108+
91109
[Fact]
92110
public void Runner_writes_generated_files_but_refuses_handwritten()
93111
{

server/csharp/MetaObjects.Codegen/CSharpNaming.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ public static class CSharpNaming
4747
public static string Pascal(string name) =>
4848
name.Length == 0 ? name : char.ToUpperInvariant(name[0]) + name[1..];
4949

50+
/// <summary>
51+
/// Cosmetic pluralization for a DbSet property name + the route collection
52+
/// segment (the table name itself comes from [Table]). Shared so the DbContext
53+
/// and routes generators agree.
54+
/// </summary>
55+
public static string Pluralize(string name)
56+
{
57+
if (name.EndsWith("s", StringComparison.Ordinal) || name.EndsWith("x", StringComparison.Ordinal) ||
58+
name.EndsWith("z", StringComparison.Ordinal) || name.EndsWith("ch", StringComparison.Ordinal) ||
59+
name.EndsWith("sh", StringComparison.Ordinal))
60+
return name + "es";
61+
if (name.Length > 1 && name.EndsWith("y", StringComparison.Ordinal) && !"aeiou".Contains(name[^2]))
62+
return name[..^1] + "ies";
63+
return name + "s";
64+
}
65+
5066
/// <summary>
5167
/// Whether a field is non-nullable in the generated entity: explicitly @required,
5268
/// or part of the primary identity.

server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,9 @@ public IEnumerable<EmittedFile> Generate(GenContext ctx)
3131
sb.AppendLine(" public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }");
3232
sb.AppendLine();
3333
foreach (var name in entities)
34-
sb.AppendLine($" public DbSet<{name}> {Pluralize(name)} {{ get; set; }} = default!;");
34+
sb.AppendLine($" public DbSet<{name}> {CSharpNaming.Pluralize(name)} {{ get; set; }} = default!;");
3535
sb.AppendLine("}");
3636

3737
yield return new EmittedFile("AppDbContext.g.cs", sb.ToString());
3838
}
39-
40-
// Cosmetic DbSet property pluralization (the table name comes from [Table]).
41-
private static string Pluralize(string name)
42-
{
43-
if (name.EndsWith("s", StringComparison.Ordinal) || name.EndsWith("x", StringComparison.Ordinal) ||
44-
name.EndsWith("z", StringComparison.Ordinal) || name.EndsWith("ch", StringComparison.Ordinal) ||
45-
name.EndsWith("sh", StringComparison.Ordinal))
46-
return name + "es";
47-
if (name.Length > 1 && name.EndsWith("y", StringComparison.Ordinal) && !"aeiou".Contains(name[^2]))
48-
return name[..^1] + "ies";
49-
return name + "s";
50-
}
5139
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// routes-generator — emits ASP.NET Core Minimal API CRUD endpoints per entity.
2+
//
3+
// One static class per entity with a Map<Entity>Routes(this IEndpointRouteBuilder,
4+
// string prefix) extension. Single-PK entities get full CRUD (list/get/post/put/
5+
// delete) over the generated AppDbContext; composite-/no-PK entities get the
6+
// collection GET only (item addressing needs a single key) with a warning.
7+
8+
using System.Text;
9+
using MetaObjects.Meta;
10+
11+
namespace MetaObjects.Codegen.Generators;
12+
13+
/// <summary>Generates Minimal API CRUD route registration per <c>object.entity</c>.</summary>
14+
public sealed class RoutesGenerator : PerEntityGenerator
15+
{
16+
public override string Name => "routes-generator";
17+
18+
protected override bool Filter(MetaObject entity) => entity.IsEntity();
19+
20+
protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx)
21+
{
22+
var cls = CSharpNaming.Pascal(entity.Name);
23+
var dbSet = CSharpNaming.Pluralize(cls);
24+
var route = CSharpNaming.Pluralize(entity.Name).ToLowerInvariant();
25+
26+
var pkFields = entity.PrimaryIdentity()?.Fields ?? [];
27+
string? pkType = null;
28+
if (pkFields.Count == 1)
29+
{
30+
var pkField = entity.Fields().FirstOrDefault(f => f.Name == pkFields[0]);
31+
if (pkField is not null) pkType = CSharpNaming.ScalarFor(pkField.SubType);
32+
}
33+
bool fullCrud = pkType is not null;
34+
if (!fullCrud)
35+
ctx.Warn($"{Name}: entity \"{entity.Name}\" has no single-column primary key — emitting collection GET only.");
36+
37+
var sb = new StringBuilder();
38+
sb.AppendLine("// <auto-generated/>");
39+
sb.AppendLine("// Generated by MetaObjects routes-generator. Do not edit by hand.");
40+
sb.AppendLine("#nullable enable");
41+
sb.AppendLine("using Microsoft.AspNetCore.Builder;");
42+
sb.AppendLine("using Microsoft.AspNetCore.Http;");
43+
sb.AppendLine("using Microsoft.AspNetCore.Routing;");
44+
sb.AppendLine("using Microsoft.EntityFrameworkCore;");
45+
sb.AppendLine();
46+
sb.AppendLine($"namespace {ctx.Config.Namespace};");
47+
sb.AppendLine();
48+
sb.AppendLine($"public static class {cls}Routes");
49+
sb.AppendLine("{");
50+
sb.AppendLine($" public static IEndpointRouteBuilder Map{cls}Routes(this IEndpointRouteBuilder app, string prefix = \"/api\")");
51+
sb.AppendLine(" {");
52+
53+
// List
54+
sb.AppendLine(" app.MapGet(prefix + \"/" + route + "\", async (AppDbContext db) =>");
55+
sb.AppendLine(" await db." + dbSet + ".ToListAsync());");
56+
57+
if (fullCrud)
58+
{
59+
sb.AppendLine();
60+
sb.AppendLine(" app.MapGet(prefix + \"/" + route + "/{id}\", async (" + pkType + " id, AppDbContext db) =>");
61+
sb.AppendLine(" await db." + dbSet + ".FindAsync(id) is { } found ? Results.Ok(found) : Results.NotFound());");
62+
sb.AppendLine();
63+
sb.AppendLine(" app.MapPost(prefix + \"/" + route + "\", async (" + cls + " input, AppDbContext db) =>");
64+
sb.AppendLine(" {");
65+
sb.AppendLine(" db." + dbSet + ".Add(input);");
66+
sb.AppendLine(" await db.SaveChangesAsync();");
67+
sb.AppendLine(" return Results.Created(prefix + \"/" + route + "\", input);");
68+
sb.AppendLine(" });");
69+
sb.AppendLine();
70+
sb.AppendLine(" app.MapPut(prefix + \"/" + route + "/{id}\", async (" + pkType + " id, " + cls + " input, AppDbContext db) =>");
71+
sb.AppendLine(" {");
72+
sb.AppendLine(" var existing = await db." + dbSet + ".FindAsync(id);");
73+
sb.AppendLine(" if (existing is null) return Results.NotFound();");
74+
sb.AppendLine(" db.Entry(existing).CurrentValues.SetValues(input);");
75+
sb.AppendLine(" await db.SaveChangesAsync();");
76+
sb.AppendLine(" return Results.NoContent();");
77+
sb.AppendLine(" });");
78+
sb.AppendLine();
79+
sb.AppendLine(" app.MapDelete(prefix + \"/" + route + "/{id}\", async (" + pkType + " id, AppDbContext db) =>");
80+
sb.AppendLine(" {");
81+
sb.AppendLine(" var existing = await db." + dbSet + ".FindAsync(id);");
82+
sb.AppendLine(" if (existing is null) return Results.NotFound();");
83+
sb.AppendLine(" db." + dbSet + ".Remove(existing);");
84+
sb.AppendLine(" await db.SaveChangesAsync();");
85+
sb.AppendLine(" return Results.NoContent();");
86+
sb.AppendLine(" });");
87+
}
88+
89+
sb.AppendLine();
90+
sb.AppendLine(" return app;");
91+
sb.AppendLine(" }");
92+
sb.AppendLine("}");
93+
94+
return new EmittedFile($"{cls}Routes.g.cs", sb.ToString());
95+
}
96+
}

0 commit comments

Comments
 (0)