Skip to content

Commit c68c0db

Browse files
committed
merge: C# codegen pillar foundation — EF Core entity + DbContext + meta gen [P2a]
2 parents 5990322 + e768abf commit c68c0db

9 files changed

Lines changed: 523 additions & 1 deletion

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using MetaObjects.Cli;
2+
using Xunit;
3+
4+
namespace MetaObjects.Cli.Tests;
5+
6+
/// <summary>End-to-end `meta gen`: a metadata dir -> generated EF Core files on disk.</summary>
7+
public sealed class GenCommandTests : IDisposable
8+
{
9+
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-gen-" + Guid.NewGuid().ToString("N"));
10+
private string MetaDir => Path.Combine(_tmp, "metaobjects");
11+
private string OutDir => Path.Combine(_tmp, "generated");
12+
13+
private const string Metadata = """
14+
{ "metadata.root": { "package": "acme", "children": [
15+
{ "object.entity": { "name": "Subscriber", "children": [
16+
{ "source.dbTable": { "@name": "subscribers" } },
17+
{ "field.long": { "name": "id" } },
18+
{ "field.string": { "name": "email", "@required": true } },
19+
{ "identity.primary": { "@fields": "id" } }
20+
]}}
21+
]}}
22+
""";
23+
24+
public GenCommandTests()
25+
{
26+
Directory.CreateDirectory(MetaDir);
27+
File.WriteAllText(Path.Combine(MetaDir, "meta.acme.json"), Metadata);
28+
}
29+
30+
public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }
31+
32+
[Fact]
33+
public void Gen_writes_entity_and_dbcontext()
34+
{
35+
var outcome = GenCommand.Run(MetaDir, OutDir, "Acme.Generated");
36+
Assert.True(outcome.Ok, string.Join("; ", outcome.LoadErrors));
37+
Assert.True(File.Exists(Path.Combine(OutDir, "Subscriber.g.cs")));
38+
Assert.True(File.Exists(Path.Combine(OutDir, "AppDbContext.g.cs")));
39+
Assert.Contains("public class Subscriber", File.ReadAllText(Path.Combine(OutDir, "Subscriber.g.cs")));
40+
Assert.Contains(outcome.Result!.Files, f => f.Status == "written");
41+
}
42+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// `meta gen` — generate idiomatic C# (EF Core) code from metadata.
2+
//
3+
// Loads metadata from a directory and runs the codegen generator set, writing
4+
// files under the @generated-header guard. Generated today: EF Core entity
5+
// classes + a DbContext. Routes / projections / migrations layer on next.
6+
7+
using MetaObjects.Codegen;
8+
using MetaObjects.Codegen.Generators;
9+
using MetaObjects.Loader;
10+
11+
namespace MetaObjects.Cli;
12+
13+
/// <summary>The gen command's pure logic (no console I/O), so it is testable.</summary>
14+
public static class GenCommand
15+
{
16+
public sealed record Outcome(IReadOnlyList<string> LoadErrors, CodegenRunner.RunResult? Result)
17+
{
18+
public bool Ok => LoadErrors.Count == 0 && Result is not null;
19+
}
20+
21+
/// <summary>The default generator set (data layer). Extended as more generators land.</summary>
22+
public static IReadOnlyList<IGenerator> DefaultGenerators() =>
23+
[new EntityGenerator(), new DbContextGenerator()];
24+
25+
public static Outcome Run(string metadataDir, string outDir, string ns)
26+
{
27+
var load = new FileMetaDataLoader().LoadDirectory(metadataDir);
28+
var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
29+
if (loadErrors.Count > 0)
30+
return new Outcome(loadErrors, null);
31+
32+
var config = new GenConfig { OutDir = outDir, Namespace = ns };
33+
var result = CodegenRunner.Run(config, load.Root, DefaultGenerators());
34+
return new Outcome(loadErrors, result);
35+
}
36+
}

server/csharp/MetaObjects.Cli/Program.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,48 @@
88
Console.Error.WriteLine(
99
"usage: meta <command> [options]\n" +
1010
" commands:\n" +
11-
" verify <metadataDir> --templates <root> drift-check templates against their payloads");
11+
" gen <metadataDir> --out <dir> --namespace <ns> generate EF Core code from metadata\n" +
12+
" verify <metadataDir> --templates <root> drift-check templates against their payloads");
1213
return 2;
1314
}
1415

1516
return args[0] switch
1617
{
18+
"gen" => RunGen(args[1..]),
1719
"verify" => RunVerify(args[1..]),
1820
_ => Unknown(args[0]),
1921
};
2022

23+
static int RunGen(string[] rest)
24+
{
25+
string? metadataDir = null;
26+
string? outDir = null;
27+
string ns = "Generated";
28+
for (int i = 0; i < rest.Length; i++)
29+
{
30+
if (rest[i] == "--out" && i + 1 < rest.Length) outDir = rest[++i];
31+
else if (rest[i] == "--namespace" && i + 1 < rest.Length) ns = rest[++i];
32+
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
33+
}
34+
if (metadataDir is null || outDir is null)
35+
{
36+
Console.Error.WriteLine("usage: meta gen <metadataDir> --out <dir> [--namespace <ns>]");
37+
return 2;
38+
}
39+
40+
var outcome = GenCommand.Run(metadataDir, outDir, ns);
41+
if (!outcome.Ok)
42+
{
43+
foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($" load error: {e}");
44+
Console.Error.WriteLine("meta gen: FAILED (metadata did not load cleanly)");
45+
return 1;
46+
}
47+
foreach (var f in outcome.Result!.Files) Console.WriteLine($" {f.Status}: {f.Path}");
48+
foreach (var w in outcome.Result!.Warnings) Console.Error.WriteLine($" warning: {w}");
49+
Console.WriteLine($"meta gen: {outcome.Result!.Files.Count(f => f.Status == "written")} file(s) written");
50+
return 0;
51+
}
52+
2153
static int Unknown(string cmd)
2254
{
2355
Console.Error.WriteLine($"meta: unknown command \"{cmd}\"");
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using MetaObjects.Codegen;
4+
using MetaObjects.Codegen.Generators;
5+
using MetaObjects.Loader;
6+
using MetaObjects.Meta;
7+
using Xunit;
8+
9+
namespace MetaObjects.Codegen.Tests;
10+
11+
public class EntityGeneratorTests
12+
{
13+
private const string Model = """
14+
{ "metadata.root": { "package": "acme", "children": [
15+
{ "object.entity": { "name": "Subscriber", "children": [
16+
{ "source.dbTable": { "@name": "subscribers" } },
17+
{ "field.long": { "name": "id" } },
18+
{ "field.string": { "name": "email", "@required": true, "@maxLength": 255 } },
19+
{ "field.boolean": { "name": "subscribed" } },
20+
{ "field.timestamp": { "name": "createdAt" } },
21+
{ "identity.primary": { "@fields": "id" } }
22+
]}}
23+
]}}
24+
""";
25+
26+
private static MetaRoot Load()
27+
{
28+
var r = new MetaDataLoader().Load([new InMemorySource(Model, id: "gen.json")]);
29+
Assert.Empty(r.Errors);
30+
return r.Root;
31+
}
32+
33+
private static GenContext Ctx(MetaRoot root) => new()
34+
{
35+
Entities = root.Objects(), Root = root,
36+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
37+
};
38+
39+
[Fact]
40+
public void Entity_class_has_table_key_columns_and_nullability()
41+
{
42+
var ctx = Ctx(Load());
43+
var file = Assert.Single(new EntityGenerator().Generate(ctx));
44+
Assert.Equal("Subscriber.g.cs", file.Path);
45+
var src = file.Content;
46+
47+
Assert.Contains("namespace Acme.Generated;", src);
48+
Assert.Contains("[Table(\"subscribers\")]", src);
49+
Assert.Contains("public class Subscriber", src);
50+
// PK long, required -> non-nullable, [Key] + [Column]
51+
Assert.Contains("[Key]", src);
52+
Assert.Contains("[Column(\"id\")]", src);
53+
Assert.Contains("public long Id { get; set; }", src);
54+
// required string -> [Required] + [MaxLength] + non-nullable w/ default!
55+
Assert.Contains("[Column(\"email\")]", src);
56+
Assert.Contains("[MaxLength(255)]", src);
57+
Assert.Contains("public string Email { get; set; } = default!;", src);
58+
// optional value types -> nullable
59+
Assert.Contains("public bool? Subscribed { get; set; }", src);
60+
Assert.Contains("public DateTime? CreatedAt { get; set; }", src);
61+
}
62+
63+
[Fact]
64+
public void Generated_entity_compiles()
65+
{
66+
var ctx = Ctx(Load());
67+
var src = new EntityGenerator().Generate(ctx).Single().Content;
68+
69+
var tree = CSharpSyntaxTree.ParseText(src, new CSharpParseOptions(LanguageVersion.CSharp12));
70+
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
71+
.Split(Path.PathSeparator).Where(p => p.Length > 0)
72+
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
73+
var comp = CSharpCompilation.Create("entitycompile_" + Guid.NewGuid().ToString("N"),
74+
[tree], refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
75+
76+
var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)
77+
.Select(d => $"{d.Id}: {d.GetMessage()}").ToList();
78+
Assert.True(errors.Count == 0, "generated entity should compile, got: " + string.Join("; ", errors));
79+
}
80+
81+
[Fact]
82+
public void DbContext_exposes_a_dbset_per_entity()
83+
{
84+
var ctx = Ctx(Load());
85+
var file = Assert.Single(new DbContextGenerator().Generate(ctx));
86+
Assert.Equal("AppDbContext.g.cs", file.Path);
87+
Assert.Contains("public class AppDbContext : DbContext", file.Content);
88+
Assert.Contains("public DbSet<Subscriber> Subscribers { get; set; } = default!;", file.Content);
89+
}
90+
91+
[Fact]
92+
public void Runner_writes_generated_files_but_refuses_handwritten()
93+
{
94+
var dir = Path.Combine(Path.GetTempPath(), "mo-gen-" + Guid.NewGuid().ToString("N"));
95+
try
96+
{
97+
var config = new GenConfig { OutDir = dir, Namespace = "Acme.Generated" };
98+
var r1 = CodegenRunner.Run(config, Load(), [new EntityGenerator()]);
99+
Assert.Contains(r1.Files, f => f.Path == "Subscriber.g.cs" && f.Status == "written");
100+
Assert.True(File.Exists(Path.Combine(dir, "Subscriber.g.cs")));
101+
102+
// Re-run overwrites the @generated file.
103+
var r2 = CodegenRunner.Run(config, Load(), [new EntityGenerator()]);
104+
Assert.Contains(r2.Files, f => f.Path == "Subscriber.g.cs" && f.Status == "written");
105+
106+
// A hand-written file (no marker) is refused.
107+
File.WriteAllText(Path.Combine(dir, "Subscriber.g.cs"), "// my hand-written file\n");
108+
var r3 = CodegenRunner.Run(config, Load(), [new EntityGenerator()]);
109+
Assert.Contains(r3.Files, f => f.Path == "Subscriber.g.cs" && f.Status == "skipped-handwritten");
110+
Assert.Equal("// my hand-written file\n", File.ReadAllText(Path.Combine(dir, "Subscriber.g.cs")));
111+
}
112+
finally { try { Directory.Delete(dir, true); } catch { } }
113+
}
114+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Field/entity -> idiomatic C# (EF Core) type + name mapping for codegen.
2+
//
3+
// Property names are PascalCase (C# convention); the metadata field name is
4+
// preserved as the DB column via [Column(...)] so the wire/DB contract is
5+
// unchanged. Scalar subtypes map to the natural .NET types; date/time/timestamp
6+
// use the modern DateOnly/TimeOnly/DateTime trio.
7+
8+
using MetaObjects.Meta;
9+
using static MetaObjects.Core.Field.FieldConstants;
10+
11+
namespace MetaObjects.Codegen;
12+
13+
/// <summary>Field-subtype -> C# type + naming helpers for the EF Core generators.</summary>
14+
public static class CSharpNaming
15+
{
16+
private static readonly IReadOnlyDictionary<string, string> ScalarType =
17+
new Dictionary<string, string>(StringComparer.Ordinal)
18+
{
19+
[FIELD_SUBTYPE_STRING] = "string",
20+
[FIELD_SUBTYPE_CLASS] = "string",
21+
[FIELD_SUBTYPE_INT] = "int",
22+
[FIELD_SUBTYPE_SHORT] = "short",
23+
[FIELD_SUBTYPE_BYTE] = "byte",
24+
[FIELD_SUBTYPE_LONG] = "long",
25+
[FIELD_SUBTYPE_CURRENCY] = "long", // integer minor units (wire contract)
26+
[FIELD_SUBTYPE_DOUBLE] = "double",
27+
[FIELD_SUBTYPE_FLOAT] = "float",
28+
[FIELD_SUBTYPE_DECIMAL] = "decimal",
29+
[FIELD_SUBTYPE_BOOLEAN] = "bool",
30+
[FIELD_SUBTYPE_DATE] = "DateOnly",
31+
[FIELD_SUBTYPE_TIME] = "TimeOnly",
32+
[FIELD_SUBTYPE_TIMESTAMP] = "DateTime",
33+
};
34+
35+
/// <summary>Value types that take a <c>?</c> suffix when nullable (vs. reference types).</summary>
36+
private static readonly HashSet<string> ValueTypes = new(StringComparer.Ordinal)
37+
{ "int", "short", "byte", "long", "double", "float", "decimal", "bool", "DateOnly", "TimeOnly", "DateTime" };
38+
39+
/// <summary>The base C# scalar type for a field subtype (no nullability), or null for object fields.</summary>
40+
public static string? ScalarFor(string fieldSubType) =>
41+
ScalarType.GetValueOrDefault(fieldSubType);
42+
43+
/// <summary>True when the C# type is a value type (gets <c>?</c> for nullable; needs no <c>= default!</c>).</summary>
44+
public static bool IsValueType(string csharpType) => ValueTypes.Contains(csharpType);
45+
46+
/// <summary>PascalCase the (camelCase) metadata name for a C# member/type identifier.</summary>
47+
public static string Pascal(string name) =>
48+
name.Length == 0 ? name : char.ToUpperInvariant(name[0]) + name[1..];
49+
50+
/// <summary>
51+
/// Whether a field is non-nullable in the generated entity: explicitly @required,
52+
/// or part of the primary identity.
53+
/// </summary>
54+
public static bool IsRequired(MetaObject entity, MetaField field)
55+
{
56+
if (field.OwnAttr(FIELD_ATTR_REQUIRED) is true) return true;
57+
var pk = entity.PrimaryIdentity();
58+
return pk is not null && pk.Fields.Contains(field.Name);
59+
}
60+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// CodegenRunner — runs generators against a loaded model and writes files under
2+
// an @generated-header guard: a file is overwritten only if it carries the
3+
// generated marker, so hand-written files are never clobbered. Mirrors the
4+
// overwrite policy of codegen-ts's runGen (three-way merge can layer on later).
5+
6+
using MetaObjects.Meta;
7+
8+
namespace MetaObjects.Codegen;
9+
10+
/// <summary>Runs a generator set and writes the emitted files.</summary>
11+
public static class CodegenRunner
12+
{
13+
/// <summary>Files carrying this marker are owned by codegen and safe to overwrite.</summary>
14+
public const string GeneratedMarker = "<auto-generated/>";
15+
16+
public sealed record WriteResult(string Path, string Status);
17+
public sealed record RunResult(IReadOnlyList<WriteResult> Files, IReadOnlyList<string> Warnings);
18+
19+
public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList<IGenerator> generators)
20+
{
21+
var warnings = new List<string>();
22+
var ctx = new GenContext
23+
{
24+
Entities = root.Objects(),
25+
Root = root,
26+
Config = config,
27+
Warn = warnings.Add,
28+
};
29+
30+
Directory.CreateDirectory(config.OutDir);
31+
var results = new List<WriteResult>();
32+
33+
foreach (var generator in generators)
34+
{
35+
foreach (var file in generator.Generate(ctx))
36+
{
37+
var full = Path.GetFullPath(Path.Combine(config.OutDir, file.Path));
38+
if (File.Exists(full) && !File.ReadAllText(full).Contains(GeneratedMarker, StringComparison.Ordinal))
39+
{
40+
warnings.Add($"refusing to overwrite hand-written file: {file.Path}");
41+
results.Add(new WriteResult(file.Path, "skipped-handwritten"));
42+
continue;
43+
}
44+
45+
var dir = Path.GetDirectoryName(full);
46+
if (dir is not null) Directory.CreateDirectory(dir);
47+
File.WriteAllText(full, file.Content);
48+
results.Add(new WriteResult(file.Path, "written"));
49+
}
50+
}
51+
52+
return new RunResult(results, warnings);
53+
}
54+
}

0 commit comments

Comments
 (0)