Skip to content

Latest commit

 

History

History
500 lines (411 loc) · 23.2 KB

File metadata and controls

500 lines (411 loc) · 23.2 KB

C# port

A first-class full-stack target on .NET 8. Loader + conformance + EF Core codegen

  • render engine + the meta CLI all ship. Targets EF Core + ASP.NET Core + Postgres + Npgsql.

Install

Published to NuGet at 1.0.13 — four packages, version-locked to the C# port version:

<!-- YourApp.csproj -->
<ItemGroup>
  <PackageReference Include="MetaObjects"          Version="1.0.13" />
  <PackageReference Include="MetaObjects.Codegen"  Version="1.0.13" />
  <PackageReference Include="MetaObjects.Render"   Version="1.0.13" />
</ItemGroup>

Install the CLI as a .NET tool:

dotnet tool install --global MetaObjects.Cli
dotnet meta          # bare invocation prints the usage banner

The C# CLI is invoked as dotnet meta (command dotnet-meta), or run directly from the repo via dotnet run --project server/csharp/MetaObjects.Cli. It is deliberately not a bare meta executable — that name belongs to the canonical Node meta CLI, which owns schema + TS codegen (ADR-0015).

Configure

Drop metadata under metadata/:

// metadata/meta.blog.json
{ "metadata.root": {
    "package": "acme::blog",
    "children": [
      { "object.entity": {
        "name": "Author",
        "children": [
          { "source.rdb": { "@table": "authors" } },
          { "field.long":   { "name": "id" } },
          { "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
          { "field.string": { "name": "bio", "@maxLength": 2000 } },
          { "identity.primary": { "@fields": "id", "@generation": "increment" } }
        ]
      }}
    ]
}}

Custom providers (optional)

If your app needs a metamodel subtype the core doesn't ship, declare an IMetaDataTypeProvider and compose it into the registry before loading:

using MetaObjects;
using MetaObjects.Loader;

var registry = Provider.ComposeRegistry(new IMetaDataTypeProvider[] {
    CoreTypes.CoreTypesProvider,
    yourProvider,            // adds your custom subtype/attrs
});

var loader = MetaDataLoader.FromDirectory("./metadata", registry);

The provider object has the same four-member contract (Id, Dependencies, Description, RegisterTypes(registry)) as TS / Python. Composition errors surface ERR_PROVIDER_DUPLICATE_ID, ERR_PROVIDER_MISSING_DEPENDENCY, ERR_PROVIDER_DEPENDENCY_CYCLE — codes match the cross-port contract. See ../features/extending-with-providers.md for the full reference and ../recipes/extending-metaobjects-with-providers.md for a worked example.

Generate

Own a generator. dotnet meta eject <name>... copies a reference generator into codegen/generators/ and scaffolds an owned codegen/ console project that dotnet meta gen then runs. See Own your codegen → C#.

# Generate EF Core entities + AppDbContext + CRUD minimal-API routes
dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog \
  --generators entity,db-context,filter-allowlist,routes

# Drift-check templates against payloads (FR-004)
dotnet meta verify ./metadata --templates ./prompts

Codegen is opt-in: with no --generators, gen writes nothing. dotnet meta gen --list is the catalog, and it shows what each generator requires. routes requires entity, db-context and filter-allowlist, because the handlers it emits reference their output; gen warns when one is missing, since the result would not compile.

Schema migrations are owned by the Node meta CLI (ADR-0015) — the C# CLI is gen + verify only.

gen also accepts --template-spec <json> (+ --template-root <dir>, default templates) — the declarative Mustache template-codegen surface; see Declarative template-codegen below.

The codegen emits:

  • Author.g.cs — class per entity (a mutable attributed POCO, not a record).
  • <Report>.g.cs — keyless row class per view-backed report (object.report with a read-only @kind: view source); mapped in AppDbContext with DbSet + HasNoKey().ToView(...). It is served like a keyless projection: <Report>Routes.g.cs (MapGet list, MapPost answering 405, no {id} route) and <Report>FilterAllowlist.g.cs, with every derived field that has filter operators filterable and sortable (on a report an enum dimension sorts too; an entity's enum field does not). No names artifact. An enum dimension reaches the wire as its string symbol only when the host serializes enums as strings (ConfigureHttpJsonOptions with a JsonStringEnumConverter): the routes return the row object and the host owns serialization. See reporting.
  • AppDbContext.g.cs — DbSet<Author>, projection .ToView(), @storage owned types via OwnsOne (single) / OwnsMany(...).ToJson(...) (@isArray array-of-VO), enum-as-string via HasConversion<string>().
  • AuthorRoutes.g.cs — CRUD minimal-API endpoints.
  • AuthorFilterAllowlist.g.cs — the server-side filter/sort allowlist feeding the generated list handler.

<Entity>Names — the physical names, as constants

names is opt-in — select it with --generators names on dotnet meta gen (dotnet meta gen --list names the whole catalog). Selecting a generator is not owning it: to change what names emits, dotnet meta eject names copies its source into codegen/generators/ (see "Own a generator" above). When selected, a project gets <Entity>Names.g.cs. It carries the physical database names for one object as const strings:

// SubscriberNames.g.cs (using/namespace elided)
public abstract class SubscriberNames
{
    public const string Type = "object";
    public const string SubType = "entity";
    public const string Name = "Subscriber";

    public const string SourcePrimaryType = "source";
    public const string SourcePrimarySubType = "rdb";
    public const string SourcePrimaryKind = "table";
    public const string SourcePrimaryTable = "subscribers";

    public const string CreatedAtField = "createdAt";
    public const string CreatedAtColumn = "created_at";
    public const string IdField = "id";
    public const string IdColumn = "id";

    public const string IdentityPrimaryType = "identity";
    public const string IdentityPrimarySubType = "primary";
    public const string IdentityPrimaryName = "primary";

    public static readonly Dictionary<string, string> ColumnsByField = new(System.StringComparer.Ordinal)
    {
        ["createdAt"] = CreatedAtColumn,
        ["id"] = IdColumn,
    };
}

The artifact mirrors the metadata tree. Every node it describes — the object, each source.rdb, each identity, each index — carries its own Type, SubType and Name, and a physical name sits under the member that says what it IS: SourcePrimaryTable, SourceReplicaView, SourcePrimaryProc, using the metamodel's own FR-016/ADR-0018 kind→alias map. SubscriberNames.SourcePrimaryView does not exist, so the read site answers "table or view?" instead of the reader having to. Sources are keyed by @role, which is what gives a write-through entity's replica view a member of its own (SourceReplicaView) — it declares two physical names and the artifact used to carry one.

Name changed meaning in 0.25.0 and still compiles. It held the PHYSICAL name; it now holds the object's own name. [Table(SubscriberNames.Name)] in hand-written code binds a table called Subscriber instead of subscribers — silently. Kind, ReadOnly and a top-level Schema are gone and fail to compile, which is the loud half. Grep for Names.Name before regenerating. ReadOnly is not relocated but REMOVED: it was never metadata, only a derivation over @kind — ask SourcePrimaryKind instead.

An identity or index carries an Index member — its DATABASE name — only where one exists: identity.secondary and index.lookup. An identity.primary gets none, because no port's codegen names a primary key (Postgres migrations hardcode <table>_pkey, SQLite emits an unnamed PK), and carrying that would restate a migrate-only formula in the artifact built to stop exactly that.

const, not static readonly: a [Table("...")]/[Column("...")] attribute argument must be a compile-time constant, and those two attributes are the whole reason this artifact can replace a literal there rather than sit beside one. The generated entity class and AppDbContext already reference these constants — [Table(SubscriberNames.SourcePrimaryTable)], [Column(SubscriberNames.CreatedAtColumn)] — instead of embedding the same physical names a second time, so the constant and the EF mapping cannot drift apart.

Prefer a typed handle where one exists. If the ORM gives you a type-checked object for the same thing, use that. Replacing it with a string constant trades an error the compiler catches for one the database raises at runtime. These constants are for the places with no typed handle: raw SQL, a migration script, a log line, an external system's column mapping.

Here, that handle is the generated EF Core property. If you are writing a LINQ query, use it (db.Subscribers.Where(s => s.CreatedAt > cutoff)) — it is type-checked against the DbContext model, and swapping it for SubscriberNames.CreatedAtColumn trades a compile error for a runtime one. Reach for the constant instead in raw SQL (FromSqlRaw/FromSqlInterpolated), a migration script, a log line, or an external system's column mapping.

It follows extends, so a constant you do not find on a class is on its base. <Entity>Names is a public abstract class — abstract rather than static precisely so it can be inherited — and an object that extends another produces a class that extends the other's:

public abstract class CopayAuthNames : AuthNames
{
    public const string CopayAmountField = "copayAmount";
    public const string CopayAmountColumn = "copay_cents";
    public new const string Type = "object";
    public new const string SubType = "entity";
    public new const string Name = "CopayAuth";
    // SourcePrimaryTable / IdColumn / … are the base's. A C# const is inherited, so
    // CopayAuthNames.SourcePrimaryTable and CopayAuthNames.IdColumn both resolve.
}

new on Type/SubType/Name because every artifact declares its own three and a derived const hides the base's rather than clashing with it.

Two forms, and which one you get is structural. An object with its OWN source declares its own Source<Role>* members; one that INHERITS its source — a TPH subtype sharing its base's single table — declares none and takes them from the base. An abstract base a persisted entity extends gets a class of its own carrying the columns it declares and no source members at all — it has no table and must never acquire one. ColumnsByField stays complete on every class, inherited entries included.

The default column-naming strategy is literal here (unlike TypeScript's snake_case) — see the "Column naming" section of features/field-types.md for why the defaults disagree across ports and what to do about it. Both dotnet meta gen and dotnet meta verify take the same --column-naming flag, so a verify --codegen regen resolves the identical column strings gen did.

Use

// Program.cs
using Acme.Blog;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

app.MapAuthorRoutes();   // generated — GET/POST/PUT/DELETE on /api/authors
app.Run();

EF Core does the rest — the generated entities and AppDbContext are plain EF Core with no MetaObjects types in them. Note the one exception: generated routes emit using MetaObjects.Codegen.Runtime; for the shared filter/sort helpers, so a project that generates routes with the packaged generator references MetaObjects.Codegen at runtime. Entities-and-DbContext-only projects do not.

Owning the routes, helpers included. dotnet meta eject routes copies the routes generator into codegen/generators/ AND the helper source its output calls into codegen/runtime/: FilterParser, FilterParseResult, FilterPredicate, EfCoreFilterDispatch, ValueObjectValidator, ConstraintErrors and Iso8601TimestampConverter, each under the namespace Codegen.Runtime. The owned generator's output imports Codegen.Runtime instead of the package. Compile the folder into your app (<Compile Include="../codegen/runtime/**/*.cs" />) and the generated routes need no MetaObjects.Codegen reference; a fix to the filter parser is then an edit in your repo, not a wait for a release. What stays in the package is core: ExtractObject (the reply parser the prompt tier calls) and M2MResolver (metadata-driven M:N traversal), plus the loader, registry, render and verify. dotnet meta gen --list reports the copy identical or per-file DIFFERS, verify --codegen does not treat it as drift, and Own your codegen → C# has the diff recipe for pulling an upstream fix into it.

Consumer dependencies. The generated AppDbContext and the Program.cs wiring above use EF Core (AddDbContext, DbContext, UseNpgsql), which MetaObjects does not pull in for you — add the two EF Core NuGet packages to the consuming app: Microsoft.EntityFrameworkCore and Npgsql.EntityFrameworkCore.PostgreSQL.

// Optional handwritten service over the generated DbContext
public class AuthorService(AppDbContext db)
{
    public Task<List<Author>> ListAsync() => db.Authors.ToListAsync();

    public async Task<long> CreateAsync(string name, string? bio = null)
    {
        var author = new Author { Name = name, Bio = bio };
        db.Authors.Add(author);
        await db.SaveChangesAsync();
        return author.Id;
    }
}

FR-004 — render

using MetaObjects.Render;

var provider = new FilesystemProvider("./prompts");
// The @payloadRef value object's own POCO, emitted by EntityGenerator (ADR-0056).
var payload = new WelcomePayload
{
    DisplayName = "Ada",
    PostCount = 12,
    Posts = new List<PostSummary> { new() { Title = "Hello" } },
};

string output = Renderer.Render(new RenderRequest {
    Ref = "lobby/welcome",
    Payload = payload,
    Provider = provider,
    Format = "xml",
});

Verify in MetaObjects.Render drift-checks every template.* against its @payloadRef. Wire it into your CI step or invoke dotnet meta verify directly. The renderer reads a POCO through its [JsonPropertyName] wire names, so {{postCount}} resolves against the PostCount property and {{#hasPosts}} works.

FR-006 — response parsing

OutputParserGenerator (in MetaObjects.Codegen) emits one <PromptName>.response.cs file per responding template.prompt — one declaring @responseRef. The static <PromptName>Parser class follows the BCL Parse/TryParse dual-API convention — Parse throws on bad input, TryParse returns a bool plus an out-error string.

ADR-0052: the shape parsed INTO is @responseRef, never @payloadRef (which types the request the prompt renders outbound), and template.output gets no parser at all. The parser returns the @responseRef value object's own POCO (<Vo>.g.cs, from EntityGenerator; ADR-0056) — so wire entity in the same run. A @required member is enforced by a generated JsonTypeInfo modifier rather than the C# required keyword, because the REST tier shares the POCO. The strict tier is JSON-only: an @responseFormat: xml reply gets the tolerant extract and nothing strict.

// generated/NpcResponse.response.cs
public static class NpcResponseParser
{
    private static readonly JsonSerializerOptions Options = new()
    {
        PropertyNameCaseInsensitive = false,
    };

    /// <exception cref="JsonException">malformed JSON or schema mismatch.</exception>
    public static NpcResponse Parse(string text) =>
        JsonSerializer.Deserialize<NpcResponse>(text, Options)
            ?? throw new JsonException("deserialized to null");

    public static bool TryParse(string text,
        [NotNullWhen(true)] out NpcResponse? value,
        [NotNullWhen(false)] out string? error) { ... }
}

The [NotNullWhen] attributes mean nullable-flow analysis lets you use npc without a null-check after a true return, and error without one after false.

Consumer wiring:

string llmResponse = await myLlmClient.CompleteAsync(promptText);

// Throwing path
var npc = NpcResponseParser.Parse(llmResponse);

// TryParse for explicit error handling
if (NpcResponseParser.TryParse(llmResponse, out var npc, out var error))
    return Ok(npc);
else
    return BadRequest(new { error });

MetaObjects.Render's Verify walks both template subtypes, catching payload ↔ template drift at build time. Cross-port design is at ADR-0010; the feature reference is at features/templates-and-payloads.md.

Consumer dependency. System.Text.Json ships in the .NET 8 BCL — no NuGet package to add. The generated parser uses the strict (case-sensitive) default options.

Declarative template-codegen (--template-spec)

Beyond the built-in EF Core / routes suite, dotnet meta gen runs declarative Mustache template generators from a JSON template-spec — the cross-port contract shared with the Python port (see docs/features/codegen-concepts.md and the neutral data dict in docs/features/codegen-data-shapes.md):

dotnet meta gen ./metadata --out ./Generated \
  --template-spec ./template-spec.json --template-root ./templates
// template-spec.json — the cross-port shape
{ "generators": [
    { "name": "entity-doc",
      "scope": "perEntity",            // perEntity | perPackage | perModel
      "outputPattern": "{package}/{Name}.md",
      "template": "entity-doc",          // resolved under --template-root
      "format": "markdown" }            // optional; a registered escaper format
]}

The spec is auto-discovered. Omit --template-spec and the CLI reads <projectRoot>/template-spec.json, where projectRoot is the metadata dir's parent — the same anchor .metaobjects/ uses (GenCommand.ProjectRootFor). The flag overrides it.

Prefer the conventional path over the flag, because dotnet meta verify --codegen accepts no --template-spec: discovery is how the drift gate learns your template generators exist. A spec reachable only by flag leaves verify regenerating a different generator list from gen and convicting your committed template output as "committed but a fresh regen would not emit it".

dotnet meta gen ./metadata --out ./Generated --template-root ./templates
dotnet meta verify --codegen ./metadata --out ./Generated --template-root ./templates
#   ^ both resolve <projectRoot>/template-spec.json — the gate agrees with the generator

Each spec entry derives the neutral template data dict for its scope (MetaObjects.Codegen.TemplateCodegen.TemplateData) and names each file via the outputPattern placeholders ({name}, {Name}, {package}). The named generators are appended to the --generators selection (there is no default suite) and gated byte-identical against the shared fixtures/template-codegen-conformance/ corpus. A target field is rejected (C# has no output-target concept); a bad template ref or wrong --template-root surfaces as a clean error, not a stack trace. For output to be regenerable, the template must emit the @generated header itself (the write path refuses to overwrite files lacking it).

Angular 18 frontend

C# 12 / .NET 8 backends pair cleanly with an Angular 18 client built from the universal @metaobjectsdev/angular runtime + @metaobjectsdev/codegen-ts-angular codegen packages — which are source-only today, not published to npm (build them from the TS workspace; see the recipe). The generated ASP.NET Minimal API routes (from MetaObjects.Codegen RoutesGenerator) speak the same URL grammar and wire format the Angular client expects — no special-casing.

End-to-end recipe — CORS wiring, dev-server port conventions, base-URL configuration, the dotnet meta gen command sequence that emits both halves — lives at docs/recipes/csharp-angular18.md.

Today's RoutesGenerator honours pagination (?limit/?offset), sort (?sort=<field>:asc|desc against a static per-entity allowlist), and the ?withCount=1 envelope ({ rows, total }) that the Angular grid hook always sends. Filter operators (eq / ne / gt / gte / lt / lte / in / like / isNull) per api-contract.md ship too — the generated <Entity>FilterAllowlist (FilterAllowlistGenerator) feeds FilterParser.Parse + EfCoreFilterDispatch.ApplyFilter, both wired directly into the generated list handler. A read-only projection (source.rdb @kind: view) and a view-backed report get the same filter and sort on their list route, against an allowlist of their own fields. Remaining gaps are in server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md.

Capability snapshot

Feature Status
Entities + fields Yes
Relationships + FK Yes (EF Core + Postgres FK clause)
Source kinds (table / view / storedProc) table + view fully shipped; storedProc / tableFunction / materializedView partial
field.currency / field.enum / field.object + @storage Yes (incl. EF Core OwnsOne for flattened; OwnsMany(...).ToJson(...) for @isArray array-of-VO jsonb)
Templates + render (FR-004) Yes (MetaObjects.Render)
Output parser codegen (FR-006) Yes (OutputParserGenerator — Parse/TryParse BCL pattern)
Payload-VO codegen Yes — the payload IS the value object's own POCO from EntityGenerator (ADR-0056); no separate payload generator
Declarative template-codegen Yes — dotnet meta gen --template-spec (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with Python)
Migrations Owned by the Node meta CLI (ADR-0015) — no C# migrate surface
Drift verify dotnet meta verify (template drift, FR-004)
Runtime metadata Loader API + render engine; ObjectManager-style runtime tier on the roadmap

Conformance status

Per-corpus pass counts move every release — see docs/CONFORMANCE.md for the current, authoritative per-port numbers (metamodel, YAML, render, verify, persistence, API contract). C# is green across all six active corpora today.

See also