Skip to content

Commit 710f6cf

Browse files
dmealingclaude
andcommitted
Merge origin/main into sp-g-registry-conformance (#38 + opsForSubType build fix) — clean
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 403e4c2 + 84798d6 commit 710f6cf

10 files changed

Lines changed: 453 additions & 6 deletions

File tree

fixtures/generator-registry-conformance/registry.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@
5353
"ports": ["typescript"]
5454
},
5555
"callable": {
56-
"concept": "Per-entity callable/service surface wrapping the query helpers.",
56+
"concept": "Per-entity callable wrapper for a callable source (storedProc / tableFunction); in TS it also covers the service surface wrapping the query helpers.",
5757
"tier": "native",
58-
"ports": ["typescript"]
58+
"ports": ["typescript", "csharp"]
5959
},
6060
"routes-hono": {
6161
"concept": "Per-entity Hono CRUD routes.",
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Fr013CodegenTests — FR-013 per-field @readOnly C# codegen emission.
2+
//
3+
// A @readOnly:true field is read-after-insert-only: EF may set it on read, but the
4+
// column is omitted from INSERT / UPDATE. The C# emission (per the FR-013 spec) is:
5+
// 1. EntityGenerator: the scalar property emits `{ get; private set; }` (settable
6+
// by EF on materialization, not by application code) instead of `{ get; set; }`.
7+
// 2. DbContextGenerator: a fluent
8+
// `modelBuilder.Entity<Owner>().Property(x => x.Field).Metadata
9+
// .SetAfterSaveBehavior(PropertySaveBehavior.Ignore);`
10+
// so the column is skipped on writes.
11+
// 3. A normal (writable) field is unchanged: `{ get; set; }`, no SetAfterSaveBehavior.
12+
//
13+
// Mirrors the TS reference intent (codegen-ts zod-validators.ts excludes
14+
// FIELD_ATTR_READ_ONLY fields from the Insert/Update schemas). This test gates the
15+
// C# EMISSION shape; the entity POCO + AppDbContext compile together via Roslyn.
16+
17+
using Microsoft.CodeAnalysis;
18+
using Microsoft.CodeAnalysis.CSharp;
19+
using MetaObjects.Codegen;
20+
using MetaObjects.Codegen.Generators;
21+
using MetaObjects.Loader;
22+
using MetaObjects.Meta;
23+
using Xunit;
24+
25+
namespace MetaObjects.Codegen.Tests;
26+
27+
public class Fr013CodegenTests
28+
{
29+
// A writable table with one read-only audit column (createdAt, DB-trigger
30+
// populated) and one ordinary writable column (name).
31+
private const string Model = """
32+
{ "metadata.root": { "package": "acme::audit", "children": [
33+
{ "object.entity": { "name": "Doc", "children": [
34+
{ "source.rdb": { "@table": "docs" } },
35+
{ "field.long": { "name": "id" } },
36+
{ "field.string": { "name": "name", "@required": true, "@maxLength": 80 } },
37+
{ "field.timestamp": { "name": "createdAt", "@readOnly": true } },
38+
{ "identity.primary": { "@fields": "id" } }
39+
]}}
40+
]}}
41+
""";
42+
43+
private static GenContext Ctx(MetaRoot root) => new()
44+
{
45+
Entities = root.Objects(),
46+
Root = root,
47+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = ColumnNamingStrategy.Literal },
48+
};
49+
50+
private static MetaRoot Load()
51+
{
52+
var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "fr013.json")]);
53+
Assert.Empty(r.Errors);
54+
return r.Root;
55+
}
56+
57+
private static string FileContent(IEnumerable<EmittedFile> files, string path) =>
58+
files.Single(f => f.Path == path).Content;
59+
60+
[Fact]
61+
public void MetaField_ReadOnly_reflects_attr()
62+
{
63+
var root = Load();
64+
var doc = root.FindObject("Doc")!;
65+
Assert.True(doc.FindField("createdAt")!.ReadOnly);
66+
Assert.False(doc.FindField("name")!.ReadOnly);
67+
}
68+
69+
[Fact]
70+
public void Entity_readOnly_scalar_emits_private_set()
71+
{
72+
var root = Load();
73+
var doc = FileContent(new EntityGenerator().Generate(Ctx(root)), "Doc.g.cs");
74+
75+
// The read-only field gets a private setter.
76+
Assert.Contains("public DateTime? CreatedAt { get; private set; }", doc);
77+
// A normal field keeps its public setter.
78+
Assert.Contains("public string Name { get; set; }", doc);
79+
// And the read-only field is NOT emitted with a public set.
80+
Assert.DoesNotContain("public DateTime? CreatedAt { get; set; }", doc);
81+
}
82+
83+
[Fact]
84+
public void DbContext_readOnly_field_ignored_after_save()
85+
{
86+
var root = Load();
87+
var db = FileContent(new DbContextGenerator().Generate(Ctx(root)), "AppDbContext.g.cs");
88+
89+
Assert.Contains(
90+
"modelBuilder.Entity<Doc>().Property(x => x.CreatedAt).Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);",
91+
db);
92+
// The using needed for PropertySaveBehavior.
93+
Assert.Contains("using Microsoft.EntityFrameworkCore.Metadata;", db);
94+
// The writable field is not ignored.
95+
Assert.DoesNotContain("x => x.Name).Metadata.SetAfterSaveBehavior", db);
96+
}
97+
98+
[Fact]
99+
public void Entity_and_dbcontext_compile_together()
100+
{
101+
var root = Load();
102+
var files = new EntityGenerator().Generate(Ctx(root))
103+
.Concat(new DbContextGenerator().Generate(Ctx(root)))
104+
.ToList();
105+
106+
var trees = files
107+
.Where(f => f.Path.EndsWith(".g.cs"))
108+
.Select(f => CSharpSyntaxTree.ParseText(f.Content))
109+
.ToList();
110+
111+
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
112+
var tpa = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!;
113+
foreach (var p in tpa.Split(Path.PathSeparator))
114+
if (p.Length > 0) paths.Add(p);
115+
paths.Add(typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly.Location);
116+
paths.Add(typeof(Microsoft.EntityFrameworkCore.ModelBuilder).Assembly.Location);
117+
paths.Add(typeof(Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions).Assembly.Location);
118+
var refs = paths
119+
.Where(File.Exists)
120+
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))
121+
.ToList();
122+
123+
var compilation = CSharpCompilation.Create(
124+
"Fr013Compile",
125+
trees,
126+
refs,
127+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
128+
129+
var errors = compilation.GetDiagnostics()
130+
.Where(d => d.Severity == DiagnosticSeverity.Error)
131+
.Select(d => d.ToString())
132+
.ToList();
133+
Assert.True(errors.Count == 0, "compile errors:\n" + string.Join("\n", errors));
134+
}
135+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Fr015CodegenTests — FR-015 @parameterRef callable-wrapper C# codegen emission.
2+
//
3+
// A source.rdb @kind:"storedProc" | "tableFunction" entity gets a generated calling
4+
// method (one <Entity>.callable.cs per callable entity). Per the FR-015 spec, the C#
5+
// emission uses EF Core FromSqlInterpolated:
6+
//
7+
// public static Task<IReadOnlyList<PhaseSummary>> Call(AppDbContext db, PhaseSummaryArgs args)
8+
// => db.Set<PhaseSummary>()
9+
// .FromSqlInterpolated($"SELECT * FROM analytics.fn_phase_summary({args.CaseId}, {args.AsOfDate})")
10+
// .ToListAsync()...;
11+
//
12+
// Args bind in the @parameterRef value-object's field DECLARATION order. A callable
13+
// with no @parameterRef emits a zero-arg overload calling fn_x().
14+
//
15+
// Mirrors the TS reference (codegen-ts templates/callable-file.ts), which emits one
16+
// call<Entity>(db, args) per callable with the same SQL arg-order contract.
17+
18+
using MetaObjects.Codegen;
19+
using MetaObjects.Codegen.Generators;
20+
using MetaObjects.Loader;
21+
using MetaObjects.Meta;
22+
using Xunit;
23+
24+
namespace MetaObjects.Codegen.Tests;
25+
26+
public class Fr015CodegenTests
27+
{
28+
// One value-object (the proc args, two fields in declaration order), a storedProc
29+
// entity referencing it via @parameterRef, a tableFunction entity, and a zero-arg
30+
// storedProc entity (no @parameterRef).
31+
private const string Model = """
32+
{ "metadata.root": { "package": "acme::analytics", "children": [
33+
{ "object.value": { "name": "PhaseSummaryArgs", "children": [
34+
{ "field.int": { "name": "caseId", "@required": true } },
35+
{ "field.timestamp": { "name": "asOfDate" } }
36+
]}},
37+
{ "object.entity": { "name": "PhaseSummary", "children": [
38+
{ "source.rdb": { "@kind": "storedProc", "@proc": "fn_phase_summary", "@schema": "analytics", "@parameterRef": "PhaseSummaryArgs" } },
39+
{ "field.long": { "name": "phaseId" } },
40+
{ "field.string": { "name": "phaseName" } }
41+
]}},
42+
{ "object.entity": { "name": "ActivePhases", "children": [
43+
{ "source.rdb": { "@kind": "tableFunction", "@function": "fn_active_phases", "@parameterRef": "PhaseSummaryArgs" } },
44+
{ "field.long": { "name": "phaseId" } }
45+
]}},
46+
{ "object.entity": { "name": "AllPhases", "children": [
47+
{ "source.rdb": { "@kind": "storedProc", "@proc": "fn_all_phases" } },
48+
{ "field.long": { "name": "phaseId" } }
49+
]}},
50+
{ "object.entity": { "name": "Plain", "children": [
51+
{ "source.rdb": { "@table": "plains" } },
52+
{ "field.long": { "name": "id" } },
53+
{ "identity.primary": { "@fields": "id" } }
54+
]}}
55+
]}}
56+
""";
57+
58+
private static GenContext Ctx(MetaRoot root) => new()
59+
{
60+
Entities = root.Objects(),
61+
Root = root,
62+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = ColumnNamingStrategy.Literal },
63+
};
64+
65+
private static MetaRoot Load()
66+
{
67+
var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "fr015.json")]);
68+
Assert.Empty(r.Errors);
69+
return r.Root;
70+
}
71+
72+
private static IReadOnlyList<EmittedFile> Files()
73+
=> new CallableGenerator().Generate(Ctx(Load())).ToList();
74+
75+
private static string Content(IEnumerable<EmittedFile> files, string path) =>
76+
files.Single(f => f.Path == path).Content;
77+
78+
[Fact]
79+
public void Source_ParameterRef_and_IsCallable_reflect_attrs()
80+
{
81+
var root = Load();
82+
var ps = root.FindObject("PhaseSummary")!.Sources()[0];
83+
Assert.True(ps.IsCallable());
84+
Assert.Equal("PhaseSummaryArgs", ps.ParameterRef);
85+
86+
var plain = root.FindObject("Plain")!.Sources()[0];
87+
Assert.False(plain.IsCallable());
88+
Assert.Null(plain.ParameterRef);
89+
}
90+
91+
[Fact]
92+
public void Only_callable_entities_emit_a_callable_file()
93+
{
94+
var files = Files();
95+
var paths = files.Select(f => f.Path).OrderBy(p => p, StringComparer.Ordinal).ToList();
96+
Assert.Equal(
97+
["ActivePhases.callable.g.cs", "AllPhases.callable.g.cs", "PhaseSummary.callable.g.cs"],
98+
paths);
99+
}
100+
101+
[Fact]
102+
public void StoredProc_with_parameterRef_emits_FromSqlInterpolated_in_declaration_order()
103+
{
104+
var c = Content(Files(), "PhaseSummary.callable.g.cs");
105+
106+
// Method signature: db + the args value-object.
107+
Assert.Contains("AppDbContext db, PhaseSummaryArgs args", c);
108+
// Returns the projection rows.
109+
Assert.Contains("Task<IReadOnlyList<PhaseSummary>>", c);
110+
// FromSqlInterpolated with schema-qualified proc name + args in declaration
111+
// order (caseId, asOfDate → CaseId, AsOfDate).
112+
Assert.Contains(
113+
"FromSqlInterpolated($\"SELECT * FROM analytics.fn_phase_summary({args.CaseId}, {args.AsOfDate})\")",
114+
c);
115+
Assert.Contains("db.Set<PhaseSummary>()", c);
116+
Assert.Contains("ToListAsync", c);
117+
}
118+
119+
[Fact]
120+
public void TableFunction_with_parameterRef_emits_unqualified_proc_name()
121+
{
122+
var c = Content(Files(), "ActivePhases.callable.g.cs");
123+
// No @schema → bare function name.
124+
Assert.Contains(
125+
"FromSqlInterpolated($\"SELECT * FROM fn_active_phases({args.CaseId}, {args.AsOfDate})\")",
126+
c);
127+
Assert.Contains("AppDbContext db, PhaseSummaryArgs args", c);
128+
}
129+
130+
[Fact]
131+
public void ZeroArg_callable_emits_no_args_method()
132+
{
133+
var c = Content(Files(), "AllPhases.callable.g.cs");
134+
// No args parameter.
135+
Assert.Contains("AppDbContext db)", c);
136+
Assert.DoesNotContain("args", c);
137+
// Empty parameter list in the SQL.
138+
Assert.Contains("FromSqlInterpolated($\"SELECT * FROM fn_all_phases()\")", c);
139+
Assert.Contains("Task<IReadOnlyList<AllPhases>>", c);
140+
}
141+
142+
[Fact]
143+
public void Registry_exposes_callable_for_csharp()
144+
{
145+
Assert.True(GeneratorRegistry.Entries.ContainsKey("callable"));
146+
var built = GeneratorRegistry.Resolve(["callable"]);
147+
Assert.Single(built);
148+
Assert.IsType<CallableGenerator>(built[0]);
149+
}
150+
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ public sealed class GeneratorRegistryTests
1818
[
1919
"entity", "db-context", "routes", "output-parser", "extractor",
2020
"output-prompt", "render-helper", "filter-allowlist", "template",
21+
// FR-015 — per-entity callable wrapper (storedProc / tableFunction).
22+
"callable",
2123
];
2224

2325
[Fact]
24-
public void Registry_contains_all_nine_generators_with_stable_names()
26+
public void Registry_contains_all_expected_generators_with_stable_names()
2527
{
26-
Assert.Equal(9, GeneratorRegistry.Entries.Count);
28+
Assert.Equal(ExpectedNames.Length, GeneratorRegistry.Entries.Count);
2729
foreach (var name in ExpectedNames)
2830
Assert.True(GeneratorRegistry.Entries.ContainsKey(name), $"missing stable name: {name}");
2931
}
@@ -55,7 +57,7 @@ public void Every_factory_constructs_without_throwing_for_list()
5557
public void List_returns_all_entries_native_first_alphabetical_within_tier()
5658
{
5759
var list = GeneratorRegistry.List();
58-
Assert.Equal(9, list.Count);
60+
Assert.Equal(GeneratorRegistry.Entries.Count, list.Count);
5961
var native = list.Where(e => e.Tier == GeneratorTier.Native).Select(e => e.Name).ToList();
6062
Assert.Equal(native.OrderBy(n => n, StringComparer.Ordinal).ToList(), native);
6163
}

server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) =>
155155
Factory = TemplatePrimitive,
156156
Options = "name, walk, template, format? (config-only)",
157157
},
158+
// FR-015 — per-entity typed EF Core calling method for a callable source
159+
// (storedProc / tableFunction). Same stable name as the TS callable
160+
// generator (cross-port contract).
161+
["callable"] = new()
162+
{
163+
Name = "callable",
164+
Description = "Per-entity callable wrapper (storedProc / tableFunction FromSqlInterpolated method).",
165+
Tier = GeneratorTier.Native,
166+
Factory = _ => new CallableGenerator(),
167+
},
158168
};
159169

160170
/// <summary>All entries, native first then neutral, alphabetical within tier.</summary>

0 commit comments

Comments
 (0)