diff --git a/.claude/rules/codegen-architecture.md b/.claude/rules/codegen-architecture.md index 53141388b4..90150fd479 100644 --- a/.claude/rules/codegen-architecture.md +++ b/.claude/rules/codegen-architecture.md @@ -168,7 +168,7 @@ origins live on `object.projection` only. Gated end-to-end by `fixtures/api-contract-conformance/projection/` (view-only) and `.../write-through/` (table + replica view). -**Column and table naming** is `naming` in `.metaobjects/config.json` (ADR-0058), read by every port: `columns` `snake_case` (default) | `literal` | `kebab-case`, `tables` `snake_case` (default) | `literal`. Generators read it as `ctx.columnNamingStrategy` (set from the loaded root); `metaobjects.config.ts` no longer takes it. +**Column and table naming** is `naming` in `.metaobjects/config.json` (ADR-0058), read by every port: `columns` `snake_case` (default) | `literal` | `kebab-case` | `pascal` (upper-cases the first letter only) | a name the application registered (ADR-0058 Amendment 1), `tables` `snake_case` (default) | `literal`. Generators read it as `ctx.columnNamingStrategy` (set from the loaded root); `metaobjects.config.ts` no longer takes it. ### Currency (Project F) diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index 630ae79ac2..81c8556ad6 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -27,7 +27,7 @@ Preserve the following contracts exactly across all language ports: - Currency attrs: `@currency` (ISO 4217), `@locale` (BCP 47) - Schema attrs: `@schema` on `source.rdb` (DB schema name; Postgres default `public`, SQLite rejects non-default values) - Storage attrs: `@storage` on `field.object` (with `@objectRef`) — values `flattened` / `jsonb` / `subdocument`. Unifies "owned types" (flattened storage) and "structured JSONB" (jsonb storage). Defaults to single-jsonb-column when absent (back-compat). -- Enum: `field.enum` is a first-class field subtype (peer of `currency`), string-backed. Required `@values` string-array attr (member symbols). Members must be a non-empty set, each matching `^[A-Za-z_][A-Za-z0-9_]*$`, no duplicates — every port's loader enforces this (own-only) emitting `ERR_BAD_ATTR_VALUE` (missing `@values` → `ERR_MISSING_REQUIRED_ATTR`). Reuse via abstract `field.enum` + `extends`. Codegen: TS union + `z.enum`, C# `enum` + EF `HasConversion()`, DB `varchar` + `CHECK`. **Int-backed storage shipped** (`@intValueMap`, a `{memberSymbol: int}` map, switches the column to `integer` + int `CHECK` while the wire format, the generated type and every runtime value stay the member SYMBOL; a MAP rather than a positional array so reordering `@values` cannot silently re-map every member; `@isArray` + `@intValueMap` is a LOAD ERROR, and an unmapped stored integer THROWS in all five ports). Display labels and native PG enum remain deferred (see `docs/superpowers/specs/2026-05-23-enum-datatype-design.md`). +- Enum: `field.enum` is a first-class field subtype (peer of `currency`), string-backed. Required `@values` string-array attr (member symbols). Members must be a non-empty set, each matching `^[A-Za-z_][A-Za-z0-9_]*$`, no duplicates — every port's loader enforces this (own-only) emitting `ERR_BAD_ATTR_VALUE` (missing `@values` → `ERR_MISSING_REQUIRED_ATTR`). Reuse via abstract `field.enum` + `extends`. Codegen: TS union + `z.enum`, C# `enum` + EF `HasConversion()`, DB `varchar` + `CHECK`. **Int-backed storage shipped** (`@intValueMap`, a `{memberSymbol: int}` map, switches the column to `integer` + int `CHECK` while the wire format, the generated type and every runtime value stay the member SYMBOL; a MAP rather than a positional array so reordering `@values` cannot silently re-map every member; an int-backed enum array loads in every port and stores each element as its declared integer — TypeScript and C# persist it on every engine, Python on Postgres only, Java's OMDB refuses it at the codec, and Kotlin's Exposed generator emits no enum array column yet; an unmapped stored integer THROWS in all five ports). Display labels and native PG enum remain deferred (see `docs/superpowers/specs/2026-05-23-enum-datatype-design.md`). - Documentation common attrs (any node): `description`, `title`, `notes`, `deprecated`, `replacedBy`, `seeAlso`, `aliases`. Registered via the cross-language `commonAttrs` registry hook (`registerCommonAttrs` / `RegisterCommonAttrs` / `register_common_attrs` / Java `MetaDataRegistry.registerCommonAttribute` — wired in all four ports). `notes` is the internal-only rationale slot — never emitted to user-facing doc-gen (JSDoc / XML-doc / Postgres `COMMENT ON` / Mermaid prose). **The `description` / `notes` split is by CONTENT KIND, not audience** — an audience split invites writing the same content twice at two levels of politeness. `description` states what the element IS and COVERS (scope and boundary, derivable from the model); `notes` states what you had to look OUTSIDE the model to learn (evidence, citations, what breaks if it changes). Mechanical test: a sentence belongs in `notes` exactly when it would have to change because the IMPLEMENTATION changed while the model did not. `title` is a noun phrase, `summary` a one-line sentence. TS doc-gen ships all three tiers; C# ships XML-doc + COMMENT ON. See `docs/superpowers/specs/2026-05-24-documentation-provider-design.md`. **Wire format:** diff --git a/AGENTS.md b/AGENTS.md index 5f149be4de..5d0c29e98b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (449 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (451 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f605864b..473b230f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,51 @@ here.** **`metamodelVersion` moves to `1.3`.** This release adds vocabulary (`field.duration`, `validator.exactlyOne`, `@constraintName`, `@condition`, `@maxLength` on `field.enum`, -`@sequenceStart`, `@sequenceName`, `@flattenedColumnPrefix`, the `upperFirst` column rule) and -nothing that loaded before stops -loading. A port that reports `1.2` does not know that vocabulary; upgrade every port together. +`@sequenceStart`, `@sequenceName`, `@flattenedColumnPrefix`, the `pascal` column rule, and +`@dbSetName`, a C#-only attr) and nothing that loaded before stops loading. A registry attr entry +gains `ports`, carried into the registry manifest: the ports that act on a language-specific attr +(`@dbSetName` names `["csharp"]`). A port that reports `1.2` does not know that vocabulary; upgrade +every port together. ### Added +- **Port-scoped vocabulary, and `@dbSetName`.** A registry attr may name the ports + that act on it (`"ports": ["csharp"]` on its `spec/metamodel` entry, carried into the registry + manifest). Every port registers and type-checks it, so a model that carries it loads under every + port's strict loader; a port not named does nothing with it. An attr the registry does not carry + still fails, and a wrong value type fails in every port (Java now refuses a list given to a scalar + string attr, as the other ports did). `meta types` marks such an attr `[csharp-only]` and its JSON + carries `ports`. The first one is `@dbSetName`: the EF Core DbSet property name the generated C# + context gives an `object.entity`, `object.projection` or `object.report` (`"@dbSetName": + "Diagnosis"`), in place of the derived plural; the routes, the reverse finders and the api + reference read the property name through it. + +- **An int-backed enum can be an array.** `@intValueMap` together with `isArray: true` (the + map on the field, or inherited from a shared enum) now loads in every port instead of + failing with `ERR_ENUM_INT_VALUE_MAP_ARRAY`, which is removed. Each element is stored as its + declared integer and read back as its member: `integer[]` on Postgres, and a JSON array of + numbers on SQLite, D1 and MySQL. A stored integer that maps to no member fails the read, as + it does for a scalar. The refusal had a cost beyond arrays: the map is read through + `extends`, so one map on a shared enum blocked every scalar column that used it whenever any + array field shared the enum too. Per port: + - TypeScript: ObjectManager and the generated Drizzle columns map elements on every engine + (Postgres through the element codec under `.array()`, SQLite and MySQL through a whole-array + JSON codec). An unmapped member throws on write, because the array column has no `CHECK` + to reject it. `meta migrate` already produced `integer[]`. + - C#: the generated array conversion now compiles (it wraps the pair in a `ValueConverter`), + the `UnmappedEnumValue` helper is emitted when a flattened value object's array member is + the only int-backed enum, projection and report-row enum arrays get the element converter + (they read stored ints as ordinals before), and a non-required int-backed enum array is + nullable. + - Python: ObjectManager maps elements on write, read and filter operands on Postgres. Its + SQLite and MySQL dialects store no primitive array, so the new scenario is deferred there. + - Java: OMDB stores no enum array on any engine, so its codec refuses an int-backed one on + write and read instead of writing symbols. Kotlin's Exposed table generator emits a scalar + column for an enum array, so generated code for an int-backed enum array does not compile + yet (see `KNOWN_GAPS.md`). Both defer the new scenario. + Gated by the positive conformance fixtures `enum-int-backed-array` and + `enum-int-backed-array-inherited`, and the persistence scenario `roundtrip-int-enum-array`. + - **A shared model can carry its requirements, and a consumer can inherit them (FR-023).** The publisher's `shared-model` generator takes `requirements: true` (TypeScript `sharedModelFile`, the Java/Kotlin generator arg `requirements`, C# `SharedModelGenerator.Requirements`; default @@ -162,7 +201,9 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every both ways: System.Text.Json's `TimeSpan` form in C# (`"01:30:00"`), `Duration.toString()` in Java and Kotlin, Pydantic's `timedelta` form in Python (which folds 24 hours into a day, `"P1DT2H"`), and the canonical string in TypeScript. `iso` is the canonical form in every port. - A filter operand is ISO-8601 in both modes. The api-contract and persistence corpora pin `iso`, + A filter operand is ISO-8601 in both modes. **Upgrade note:** a C# project that wants + ISO-8601 durations on the wire (`"PT1H30M"`) sets `"wire": { "durations": "iso" }`; without + it, C# writes `TimeSpan`'s own form (`"01:30:00"`). The api-contract and persistence corpora pin `iso`, and a new `api-contract-conformance/duration/` sub-corpus gates it through every port's generated routes on Postgres and SQLite (and MySQL and D1 where a port has them). - An owned (ejected) generator copy from before this release does not know `field.duration`: @@ -183,7 +224,10 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every rule, and a conditional rule only where its condition is true. They are optional; a model without them generates what it did. A `@condition` naming a field the entity does not have, one that is not a boolean expression, one comparing a field with a literal of another kind (or with `null`), or an - `exactlyOne` naming fewer than two fields, is refused at load (`ERR_BAD_ATTR_VALUE`) in every port. A declared CHECK name another constraint on the table + `exactlyOne` naming fewer than two fields, is refused at load (`ERR_BAD_ATTR_VALUE`) in every port. + **Upgrade note:** `validator.atLeastOne` takes no `@max`; a rule meaning "exactly one of these + fields" is `validator.exactlyOne`, so a model carrying `@max: 1` on an `atLeastOne` fails to load + until the validator is renamed and the `@max` removed. A declared CHECK name another constraint on the table already has, or one over 63 bytes on Postgres, is refused by `meta migrate` and `meta verify` (`ERR_CHECK_NAME`). See `docs/features/migrations-and-drift.md`, "CHECKs from validators". - **Contract emitters: JSON Schema and OpenAPI generators (FR-022).** `jsonSchemaFile()` @@ -238,7 +282,10 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every a table rename. On Postgres `meta migrate` creates `GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME "…" START WITH n)`, reads the live sequence back from the catalog, and alters one whose declared start or name differs in place: a new - start is set and the counter moved up to it (never down), a new name is a rename. Only what + start is recorded (`ALTER SEQUENCE … START WITH n`) and the counter is never moved, because live + rows hold the ids it handed out; a new name is a rename. Every port gives `@sequenceStart` this + one meaning, the first value of a fresh sequence; the persistence migration scenario + `sequence-start-change-keeps-counter` pins it on live Postgres. Only what the model declares is compared, on the live, shadow and snapshot paths alike, so a model that declares neither sees no new change and writes the same SQL and snapshots. Every port registers both attributes and refuses either without `@generation: increment` @@ -257,13 +304,34 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every the project default, which `meta migrate` and `meta verify` report by name. `@maxLength` beside `@intValueMap` is refused at load (`ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP`). A new shared corpus, `fixtures/enum-length-conformance/`, pins the resolution in those ports. -- **An `upperFirst` column rule.** `"naming": { "columns": "upperFirst" }` in +- **A `pascal` column rule.** `"naming": { "columns": "pascal" }` in `.metaobjects/config.json` upper-cases a field name's first letter and changes nothing else - (`createdAt` → `CreatedAt`, `requestURL` → `RequestURL`, `address_line1` → `Address_line1`), in - every port. A schema whose columns are the field names with a capital first letter (EF Core's - default, for one) then needs `@column` only where the database spells a name differently. A - column rule only; `literal` tables keep an object name verbatim. - See `docs/features/migrations/physical-naming.md`. + (`createdAt` → `CreatedAt`, `requestURL` → `RequestURL`, `npi` → `Npi`, `address_line1` → + `Address_line1`), in every port. A schema whose columns are the field names with a capital + first letter (EF Core's default, for one) then needs `@column` only where the database spells + a name differently (`@column: "NPI"`). A column rule only; `literal` tables keep an object name + verbatim. See `docs/features/migrations/physical-naming.md`. +- **Your own naming strategies, in every port (ADR-0058 Amendment 1).** The column and table + rules are now named strategies in a registry each port's application extends, and + `.metaobjects/config.json` selects a registered strategy by name exactly as it selects a + built-in: `"naming": { "columns": "pascal-acronyms" }`. A strategy is a function from a name + to a name and may wrap a built-in in one line. Register it with `namingStrategies` in + `metaobjects.config.ts` (every `meta` command registers it before reading the config) or + `registerColumnNamingStrategy` / `registerTableNamingRule` (TypeScript), + `NamingStrategies.RegisterColumns` / `RegisterTables` (C#), + `NamingStrategies.registerColumns` / `registerTables` (Java and Kotlin), or + `register_column_naming_strategy` / `register_table_naming_rule` (Python). Codegen, the + runtime, `verify` (its naming probe tries registered strategies after the built-ins), + `meta migrate` and the view and docs generators all resolve through the registry; `@column` + and `@table` still win. A name no registered strategy answers is refused when the config is + read, with one message in every port naming the setting, the name, the port and what is + registered. A built-in cannot be replaced. The shared naming corpus registers the same two + application strategies in every port and resolves the same names. Every port resolves a + strategy by name each time it applies, so a strategy registered or replaced after the config was + read is the one used. A Python provider module (`providers:` in `metaobjects.config.yaml`) that + registers a strategy when it is imported is in time for the stock `metaobjects` console script, + which imports providers before it reads the config. The C# type change and Java's new exception + are under Changed. - **Schema conventions for the database objects no code addresses.** `migrate.conventions` in `.metaobjects/config.json` picks how `meta migrate` names primary-key and foreign-key constraints, whether each foreign key gets an index, and whether a unique key an FK references @@ -309,6 +377,51 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every `docs/features/extending-with-providers.md` now has a worked example per language, and the `metaobjects-authoring` skill teaches it, with a reference file per language. +- **C#: one property-name resolver for every generator that names a field's property.** + `GenConfig.PropertyNaming` (`Func`, default PascalCase) is read by the + entity classes, the composite `[PrimaryKey(nameof(...))]`, every `AppDbContext` model line and + the reverse finders. `DbContextGenerator` gains the `protected virtual PropertyName` hook + `EntityGenerator` already had; both default to the resolver. Before, an adopter that overrode + `EntityGenerator.PropertyName` got a context that configured the PascalCased name, which did + not compile. +- **C#: `EntityGenerator.EmitClassAttributes`,** a seam for class-level attributes of your own, + called for every class kind after the framework's attributes and before the declaration, so a + subclass no longer copies `EmitClassHeader` to add one. +- **C#: `EntityGenerator.ScopesValueObjectClosure`** (default `false`). With it set, `Include` + also applies to the value objects reached through the model root (an emitted entity's object + fields, callable arguments, template payloads), so a value object `Include` rejects gets no + POCO even when an emitted type references it, and the adopter supplies the type. The default + still emits every reached value object, so the output compiles on its own. +- **C#: `EntityGenerator.EmitsReverseFinders`** (default `true`) turns the ADR-0038 + `Queries.g.cs` finders off. + +- **C#: the DbContext and entity generators are overridable one line at a time.** An adopter that + needs one line of generated output different overrides one `protected virtual` hook, calls base and + adjusts what it returns, instead of rewriting the finished file or re-emitting a whole body. On + `DbContextGenerator`: `ContextBaseClass`, `ContextPreambleLines`, `ContextTrailerLines`, + `AlwaysEmitOnModelCreating`, `DbSetLine`, `DbColumnTypeConfig`, `ReferenceConfig`, + `DefaultValueConfig`, `EnumConversionCall` (now an instance method taking the `GenContext`), + `FieldTypeConfig`, `OwnedTypeConfig`, `TphSubtypeFieldConfig` and `EntityConfigTrailer` (new). On + `EntityGenerator`: `PropertyClrType` and `PropertyInitializer` (every scalar, enum, object and map + member), and `EmitValueObjectPoco`, which may now return `null` to drop a value object's file and is + asked about every one. The preamble comes first in `OnModelCreating`, the trailer last, and a split + model keeps its `ConfigureModelNN` calls between them. The default output is byte-identical. +- **C#: more of the generated output is one hook away.** `EntityGenerator.ValueObjectMemberAttributes` + (the `[JsonPropertyName]` line before each value-object member), `ColumnAttributeArguments` (the + argument list of a mapped property's one `[Column(...)]`; append `TypeName = "jsonb"`, or return + none to leave the attribute out) and `TphDerivedTypeAttributes` (the `[JsonDerivedType]` lines on a + TPH base). The DbContext's head, which `EmitUsings` hard-coded, is built from `ContextUsings` + (the `using` lines), `ContextNamespace`, `ContextClassName` (default `AppDbContext`; a set + `GenConfig.ContextTypeName` still names only the type callables and finders take, and an override + leaves the routes generator naming `AppDbContext`), `EmitContextConstructor`, `ContextIsPartial` + and `ContextMemberLines` (extra members, after the constructor). `NamesGenerator` is no longer `sealed`, and its per-object emit, + `Render`, is `protected virtual`; a `null` from it emits no file. The default output is + byte-identical. +- **A test-only reset of the naming registry, in every port.** `resetNamingStrategiesForTesting()` + (TypeScript), `NamingStrategies.ResetForTesting()` (C#), `NamingStrategies.resetForTesting()` (Java + and Kotlin) and `reset_naming_strategies_for_testing()` (Python) forget the strategies the + application registered and keep the built-ins. + ### Documentation - **Separate-repo model sharing (FR-023 Phase 2) close-out.** The feature page's Phase 2 section is now @@ -398,8 +511,266 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every already named a missing variable, regenerate and fix the name; the generated helper text changes (TypeScript `prompts.ts` and Python helpers gain a `verify` argument). +- **C#, upgrade note: the next EF migration scaffolds your indexes and comments.** The generated + context now declares every `@unique`, `identity.secondary` and `index.lookup` as an EF index + (named as `meta migrate` names them) and every `@description` as a table or column comment, which + the model never carried before. So the first `dotnet ef migrations add` after upgrading scaffolds a + `CreateIndex` for each index and an `AlterColumn`/`AlterTable` comment for each description, as + if they were new. On a database `meta migrate` (or your own SQL) already built, the indexes exist + and the migration fails on "relation already exists". If they exist under the same names, empty + the migration's `Up` and `Down` (keep the snapshot change) so EF records them as present; if they + exist under other names, rename them in the database or in the metadata first. Comments re-apply + harmlessly. A database built from the model (`EnsureCreated`, or a fresh migration chain) is + unaffected. An `@expr` index is never an EF index; `meta migrate` creates it. +- **C#, upgrade note: identity starts never RESTART, and need one registration call.** A Postgres + project's context now carries `identity.primary @sequenceStart` in its model, and every start-only + change — a start added, changed or removed — is written `SET START WITH n` (a removed start goes + back to the identity default), never `RESTART`, so the current value never moves under ids live + rows hold. The first migration after upgrading shows, for each table that declares a start, an + `AlterColumn` (or, when scaffolded through the rewrite, a `Sql("ALTER TABLE … SET START WITH n")`) + that records the start and changes nothing else; it is safe to apply. Register the rewrite where + the context is configured, after `UseNpgsql(...)`: `options.UseNpgsql(cs).UseIdentityStartMigrations()` + (pooled or not, in an `IDesignTimeDbContextFactory`, or in a partial context's own + `OnConfiguring`). A context that declares a start throws as its model is built on Npgsql without + it, naming the call. +- **C#, upgrade note: a self-hosted codegen runner sets `GenConfig.Dialect`.** `@sequenceStart` and + the identity-start rewrite are emitted only when the dialect is `postgres`. `CodegenCli` reads it + from `.metaobjects/config.json` `migrate.dialect`; a runner that builds its own `GenConfig` must + set `Dialect = "postgres"`, or it gets a warning and no start in the model. +- **C#, upgrade note: an unset nullable enum with `@default` starts at `@default`, not NULL** (the + same rule as a nullable scalar with `@default`, which already started at it). An entity + created without setting the field now stores the default member; an explicit `null` is still + stored as NULL. To keep NULL as the starting value, do not declare `@default` on the field. The + same holds for a value object's enum member: a stored JSON document that lacks the member reads + as `@default`, not null. +- **C#: one generated context maps each relation to one object.** Two concrete + objects of one context that resolve to the same table, view, procedure or function in the same + `@schema` are refused with `ERR_DUPLICATE_SQL_NAME`, naming both; before, they silently shared it + once `@dbSetName` split their DbSet names. The same name in two schemas is two relations, and an + object that extends another and inherits its source is one EF hierarchy. A `@dbSetName` that is not + a C# identifier, or names `Database`, `Model`, `ChangeTracker` or `ContextId`, is refused by name. +- **TypeScript: the collision refusal checks only the names the run emits.** The pluralized module + names are checked when entity modules, queries or a UI tier are wired, and the REST segments when + a routes generator is, so a docs-only run never refuses names no generator writes. +- **Breaking, C#: `ColumnNamingStrategy` and `TableNamingRule` are classes, not enums** + (ADR-0058 Amendment 1). They are compared by name. `ColumnNamingStrategy.SnakeCase` and the other + built-ins keep their names (`Pascal` is new). Code that switches on one, uses one as a constant + default parameter (`= ColumnNamingStrategy.Literal`) or as an `[InlineData]` argument has to + change: call `strategy.Apply(name)`, take a nullable parameter, or pass the name string. + `Apply` is an extension method, so `default(ColumnNamingStrategy)` (now `null`) applies as the + enum's default did, `Literal`, rather than throwing `NullReferenceException`; the same holds for + `TableNamingRule`. `EntityGenerator.ScalarProperty`'s `strategy` parameter is now + `ColumnNamingStrategy?` (default `Literal`), so an override changes its signature to match. + `NeutralConfig.Read` and `MetadataLocation.Resolve` gain a `validateNaming` overload that keeps a + name this process has not registered (`ColumnNamingStrategy.ByName`) for resolution to refuse. +- **Java: `ColumnNaming.apply` throws `MetaDataException` for an unknown strategy name** where it + threw `IllegalArgumentException`. +- **TypeScript: an entity's docs page names the column the naming strategy resolves.** The Fields + table's Column cell, a field's Column detail and the Storage payload printed only an explicit + `@column`, so a project that names its columns by rule (`pascal`, `snake_case`) instead of a + `@column` on every field lost them. They now print the resolved column wherever it differs from + the field name; a value object's members still show only an explicit `@column`, since each owner + maps them. A default (`snake_case`) page gains the column of each multi-word field. + ### Fixed +- **An enum's `@default` is what an untouched record stores**. C#: a scalar enum + started at `default(TEnum)`, not at `@default`, while the EF sentinel was `@default`, so EF + wrote it: a string-stored or int-mapped enum stored its first member, a nullable one NULL, and + a `@provided` int-backed enum with no 0 member threw `cannot write 0`. It now starts at its + `@default` member, as a scalar does. Kotlin: the generated data class gave a defaulted field no + ctor default, so a create body omitting a required one answered 400 and a nullable one was + written as an explicit NULL; a literal `@default` (enum member, Boolean, String, Int, Long, + Double) is now the ctor default. Java: the DTO is a record, which cannot default a + component, so a generated create handler for an entity with a literal `@default` binds the raw + body, fills the absent defaulted fields (an explicit value, null included, is kept) and then + binds the DTO, on the vanilla and the TPH per-subtype create alike; an entity with none keeps the + direct binding. TypeScript and Python were already right (a create that omits + the field leaves it out of the INSERT, and the Python model starts at `@default`); live tests + now prove it. The new api-contract `enum-default/` sub-corpus gates it in the generated lane of + all five ports: a create that omits the enum stores its default, and a supplied value is kept. + A nullable C# enum with `@default` now starts at it too: see the upgrade note under + Changed. +- **A generated name collides only in the scope that carries it**. The run-wide + refusal of two objects whose short names pluralize alike is gone. C#: DbSet names are unique + within one generated context (the error says "rename one, or set @dbSetName"), route segments + within one routes entry, reverse finders within their class. TypeScript: the pluralized names + within the entity modules' scope (one directory under the flat layout, one per package under + the package layout), REST segments across the app. Python and JVM keep one run-wide scope, + which is theirs (one flat module package; one Spring app). An abstract or sourceless object + emits nothing, so it never collides: a layered model's abstract `enterprise::Payer` beside a + concrete `core::Payer` generates. The naming-conformance corpus gains `shapeOnlyCases` and + `crossPackageCollisionCases`. +- **Doc pages are keyed by package**. TypeScript's `docsFile()` and `apiDocsFile()` now + place every page under its package path whatever the docs target's `outputLayout` (which still + places code), and Java's and Kotlin's api-docs `layout` defaults to `package` (`flat` is still + honoured). Two same-named nodes in two packages are two pages, and an index entry that shares + its short name carries its package. In every port, two page paths that differ only in case + (`ProductNdc.md`, `ProductNDC.md`: one file on macOS and Windows) are refused by name instead of + one page silently replacing the other. **Upgrade note:** a TypeScript project on the flat + default sees its doc pages move under package directories on the next `meta gen`. A Java or + Kotlin build that relied on the old `flat` default for its api-docs sees the same move; set + `layout` to `flat` on that generator to keep the old paths. +- **`@schema` reaches every mapping.** C#: a view mapping is `.ToView(name, schema)`, as a table's + `[Table]` already carried its schema. Python, TypeScript and Java: the runtime addressed a bare + table name, so an entity in `@schema: sales` was read and written in whatever `search_path` + resolved, and two entities mapping the same table name in two schemas read one table. Every read + and write now qualifies the relation: the Python ObjectManager, the TypeScript ObjectManager on + both its Kysely and Drizzle drivers, and Java OMDB (SQLite, which has no schemas, keeps bare + names). A new persistence scenario, `schema-qualified-same-name`, maps `ledger_entries` and + `archive.ledger_entries` to two entities and runs on Postgres in every port. +- **C#: `identity.primary @sequenceStart` reaches the EF model, and no start-only change ever + restarts**. On Npgsql a fresh database's identity begins at `@sequenceStart`. A + Postgres context with an increment key gets a generated rewrite, registered with + `UseIdentityStartMigrations()` (see Changed), that writes a start added, changed or removed as + `SET START WITH n`, both when the migration is scaffolded and when its SQL is generated: Npgsql's + own generator writes `RESTART WITH n` (or `RESTART WITH 1` for a removed start), which hands out + ids live rows already hold. It references Npgsql at compile time, so it and the start are emitted + only for a project whose `migrate.dialect` is `postgres`; any other project gets a warning and + needs no Npgsql package. TypeScript's `meta migrate` writes the same `START WITH`-only change; + Java and Kotlin do not yet carry the start + ([#544](https://github.com/metaobjectsdev/metaobjects/issues/544)); see `docs/CONFORMANCE.md`, + "Split coverage". +- **C#: the EF model carries the index vocabulary and `@description`.** A field's `@unique`, an + `identity.secondary` and an `index.lookup` are EF indexes under the names `meta migrate` gives + them, with `@where` (`HasFilter`), `@orders` (`IsDescending`) and `@using` (the Npgsql index + method); `@description` is the table and column comment, on a TPH subtype's own fields too. A model over + the migrated database now declares its indexes, so a database EF builds has them (EF never + dropped them: an index its snapshot never knew is invisible to it); see Changed for the first + migration after upgrading. An `@expr` index an abstract base declares reaches each concrete + child's table. Kotlin's Exposed + table now carries an index's `@using` (`indexType`) and `@where` (`filterCondition`); what + Exposed and Drizzle cannot express is listed in `docs/CONFORMANCE.md`, "Split coverage". + +- **C#: a `Names` whose base lives in another namespace compiles.** The names artifact + extends its base's (`Names : Names`), and the base's file is emitted in the base's + own namespace, but the subtype's file carried no `using` for it: one CS0246 per artifact for a + model whose abstract bases bind to another namespace. It now writes that `using`. +- **C#: a reference declared on an abstract TPH intermediate is configured.** The DbContext's TPH + pass read only the concrete subtypes' own references, so one declared on an abstract level + between the base and its leaves reached no `ReferenceConfig` call: EF built no relationship over + the column, and its `@onDelete` and `@constraintName` were lost. Each intermediate is now + configured once: on its own builder when it is a class (`EmitAbstractShapes`), else on each + topmost concrete subtype its fields are folded into (a concrete subtype under one of those + inherits its foreign key). +- **C#: `dotnet meta verify --codegen` accepts a naming strategy only the project's codegen runner + registers.** Stock verify refused the config's `naming` value before it handed the `--codegen` + gate to `codegen/Program.cs`, which is where an application registers its strategy, so verify + failed where `gen` succeeded. It now hands off first and refuses an unknown name in its own process + only when no runner ran, or when `--templates` or `--db` (which run in that process) are asked for + too; either way it is exit 2 with the standard message, never an unhandled exception. +- **TypeScript: `viewNameFromProjection` reads the separator from the strategy.** It switched on + the name `"kebab-case"`; the separator after `v` is now the one the strategy writes between two + words, else `_`. Every built-in keeps its view name, and an application strategy gets the same + rule. +- **TypeScript: a `metaobjects.config.ts` that fails to load while the naming preload needs it is + reported.** The preload swallowed the error, and the command went on to refuse the strategy's + name as unregistered. It now stops and names the failed load, and the strategy it would have + registered when that is still missing. +- **C#: a large model's `OnModelCreating` no longer overflows the stack.** The DbContext generator put + every configuration line in one method; on a model with hundreds of converters and owned types its + stack frame was too large for a thread-pool thread in a Debug build, so a test host crashed with a + stack overflow while building the model. Past `ModelConfigChunkSize` entries (default 200) the + configuration is split into `ConfigureModelNN` methods called in order; a smaller model is emitted + as before. + +- **C#: concurrent reads of a loaded model no longer corrupt its cache.** `MetaData.Cached` + memoized derived reads (`Validators()`, `ResolvedIsArray()`, `OwnAttrs()`, …) in a plain + `Dictionary`, so two threads touching a frozen node for the first time could corrupt it, and + every later read in the process then failed with "Operations that change non-concurrent + collections must have exclusive access". A host that loads metadata once and serves requests + from it in parallel, or a parallel test run, hit this. The cache is now a + `ConcurrentDictionary`. + + +- **C#: a field's `@default` reaches the EF model.** The context configured no default at all, + so a database EF creates (`EnsureCreated`, an EF migration) had none, and an insert that relied + on one failed with a NOT NULL violation. A literal the property's CLR type holds (bool, string, + number, enum member) is now `HasDefaultValue(v).HasSentinel(v)`. The sentinel matters: EF leaves + a property with a database default out of the INSERT when its value equals the sentinel, which is + otherwise the CLR default, so a bool defaulting to `true` would have stored `true` when the app + set `false` (and a nullable one, or a string, would have lost an explicit NULL). With the + default as the sentinel, EF omits only a value the database would write anyway; a live test + sets every such property to its CLR default and reads it back. A SQL expression (`now()`, + anything function-like, `current_timestamp`; a bare `now` is `now()`), or a timestamp, date or + uuid literal, is `HasDefaultValueSql(...)` on a `@required` property only: on a nullable one EF + would replace an explicit NULL with the default, so it is left out there. Primary keys, arrays, + value objects, maps and durations get no default. A model whose database lacks a declared + default (one `meta migrate` did not build) now sees EF omit a column whose value equals it. + +- **C#: a `@required` value-object member of a value object starts as an instance.** It was + `public T X { get; set; } = default!;`, so a document that omitted it deserialized to null + behind a non-nullable type. It is now `= new T();` (an abstract target keeps `default!`, and + an entity's owned navigation is unchanged), so the member is present and its own members are + validated. + +- **C#: a value object nested inside a flattened one maps to one jsonb value.** The flattened + owner configured it as a nested owned `ToJson`, which EF Core 8 refuses inside a table-split + owned type ("All owned types containing a JSON column must be mapped to a JSON column + themselves"), so the whole context failed model validation. It is now one jsonb property + converted whole through the generated `MapJsonb.ObjectConverter`/`ObjectComparer`, pinned to the + same `` the migration creates; members keep their `[JsonPropertyName]` keys and + enums their symbols. Every other flattened member, scalar enums included, keeps its + `HasColumnName`. + +- **C#: a TPH subtype's own fields get their EF configuration.** The context configured the + fields of the TPH root only, so a field a subtype declares itself got none: a `field.object` + was left for EF to discover as an entity (model validation failed with "requires a primary + key"), and a timestamp, enum, decimal precision, array, map, duration or `@readOnly` column + lost the mapping the same field gets on the root. Each concrete subtype now configures the + fields it adds, on its own builder. The integration corpus's `CopayAuth.copayAmount` gains its + `HasPrecision(10, 2)`. + +- **C#: under `EmitAbstractShapes`, a concrete entity extends its abstract shape and keeps the + facets of the fields it inherits.** The entity class left out the fields its abstract parent + declares, expecting the shape class to carry them, but never named the shape as its base, so + those fields were on no class and the context did not compile. It now declares `: ` + (an abstract shape extending another does too). The shape carries no EF attributes, so the + context configures what the inherited fields' attributes would have: the single-column key + (`HasKey`), each column name (`HasColumnName`) and a string's `@maxLength` (`HasMaxLength`). + Models without the knob are unchanged. + +- **C#: a TPH discriminator typed by a shared or `@provided` enum compiles.** `HasValue(...)` + named the enum as a type nested in the base entity (`Vehicle.VehicleKind.Car`), which a shared + or provided enum (FR-019) is not; it now names it where it lives, as the enum conversions do. + +- **C#: a reference's `@constraintName` and non-primary-key target reach the EF model.** The + relationship line now ends in `.HasConstraintName("...")` when the `identity.reference` declares + `@constraintName` (the junction sides of an M:N too), so a database EF creates names its foreign + keys as the schema does. When `@references` names target fields that are not the target's + primary key (`"Warehouse.regionCode,siteNo"`), it carries `.HasPrincipalKey(...)`; EF paired + the FK with the primary key instead, which failed model validation for a composite FK and + joined the wrong column for a single one. The reverse finder for such a reference now types its + value by the named target field, where it used the target's primary key type and did not + compile. + +- **C#: `identity.primary @generation: assigned` configures the key `ValueGeneratedNever()`.** + EF's convention made a single integer or Guid key generated on add, so a caller-supplied key + equal to the CLR default (`0`) was left out of the INSERT and the row refused, and + `EnsureCreated` built an identity column. `increment`, `uuid` and an absent `@generation` keep + EF's convention. + +- **C#: an int-backed enum refuses to write a value its `@intValueMap` does not describe.** The + write converter was `v == A ? 1 : v == B ? 2 : 3`, so the last member was the else branch: a + cast like `(Status)99`, or a member a `@provided` C# enum declares that `@values` does not, was + stored as the last member's integer, and a single-member enum wrote its constant whatever the + value. Every member now has its own branch, ending in a generated `UnmappedEnumMember` helper + that throws, as the read side already did with `UnmappedEnumValue`. Scalars, array elements + and flattened value-object members alike. + +- **C#: the reverse finders take `GenConfig.ContextTypeName`.** They hardcoded `AppDbContext` + as their first parameter's type, so a project that renamed its context got finders that did + not compile. + +- **C#: a "-sis" entity name pluralizes to "-ses" in DbSet and finder names.** `Diagnosis` gave + `DbSet Diagnosises` and `FindDiagnosisesByPatient`; it now gives `Diagnoses` and + `FindDiagnosesByPatient` (`Analysis` → `Analyses`, `Basis` → `Bases`). The `-tion`, `-y` and + `-s` endings were already right and are now pinned by tests. The REST collection segment is a + cross-port wire contract and keeps the shared rule (`/api/diagnosises`) until every port + changes together. An adopter that aliased the old DbSet name can drop the alias. **Upgrade + note:** code that names `Diagnosises` or `FindDiagnosisesBy…` stops compiling; use the new name, + or set `"@dbSetName": "Diagnosises"` on the entity to keep the old one. + - **TypeScript: a derived flattened `field.object` no longer produces flat member keys (#524 follow-up).** A flattened field that carries an `origin.*` owns no column (the Drizzle table and `meta migrate` skip it), but the flat keys (`shipToStreet`) still appeared in the read, create and diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index 260d02e3f6..73d371ac6c 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -72,7 +72,7 @@ from. - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 1c8ef3632d..9b4817e5ef 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 449 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 451 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 45 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -35,8 +35,8 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/jinja-verify-conformance/`](../fixtures/jinja-verify-conformance/) (ADR-0061 — the payload check over a Jinja body) | 31 | ✓ | refuses Jinja | refuses, via Java | refuses Jinja | ✓ | | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 58 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | -| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 121 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 12 projection + 37 report + 11 decimal + 2 duration) | ✓ (Fastify reference + generated lane); `report/`, `decimal/` and `duration/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/`, `decimal/` and `duration/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | +| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 62 (55 query + 7 migration) | all 62; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 55 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 55 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 55 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 55 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 123 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 2 enum-default + 12 projection + 37 report + 11 decimal + 2 duration) | ✓ (Fastify reference + generated lane); `report/`, `decimal/` and `duration/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/`, `decimal/` and `duration/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | | [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) | | [`fixtures/duration-wire/`](../fixtures/duration-wire/) | 1 vector file: the `wire` vectors (parse, precision, refused) every port passes, and the `codecs` groups (Postgres text read and write, stored microseconds) each port passes only for the codecs it has | ✓ (`metadata` / `runtime-ts` and the generated helper) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases + 43 cross-field cases (`cross-field-cases.json`, generated record model only) | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | @@ -111,6 +111,44 @@ corpora, or between its *vocabulary* and its *behaviour* — never gets a row of own, and its boundary ends up discoverable only from the feature doc. The split ones are recorded here instead. +**`identity.primary @sequenceStart`** (one semantic in every port): on a fresh +database the identity or sequence begins at `@sequenceStart`; when the start changes on an +existing database, the change sets `START WITH` only. It never `RESTART`s and never moves the +current value, because live rows already hold the ids it has handed out. The port TODOs below +are tracked in [#544](https://github.com/metaobjectsdev/metaobjects/issues/544). + +- *Honoured and pinned:* C#. The generated context carries the start on Npgsql, and the generated + rewrite (`UseIdentityStartMigrations()`) writes every start-only change — added, changed or + removed — as `ALTER TABLE … ALTER COLUMN … SET START WITH n`, whether the migration is scaffolded + through it or its SQL is generated through it (Npgsql alone writes `RESTART WITH n`, or + `RESTART WITH 1` for a removed start). `EfModelGapsTests` pins all three transitions on both + paths, and on live Postgres that the next id after the change follows the last row. +- *Honoured and pinned:* TypeScript (`meta migrate`). A fresh table is created `START WITH n`; + a changed start writes `ALTER SEQUENCE … START WITH n` only. The persistence migration + scenario `sequence-start-change-keeps-counter` pins on live Postgres that the next id after + the change follows the last row. +- *TODO, Java (OMDB) and Kotlin (Exposed):* the vocabulary loads; neither emits or honours the + start in its DDL or mapping. Add it with the same semantic and the same pin. +- *TODO, Python:* the vocabulary loads; the runtime has no DDL path. If it gains one, the same + semantic and pin apply. + +**Native index vocabulary and `@description` in the ORM models.** `meta migrate` creates +every index (`identity.secondary`, `index.lookup`, a field's `@unique`, with `@where`, `@using`, +`@orders`, `@expr` and the resolved name) and writes `@description` as `COMMENT ON`. What each +port's ORM model carries beside it: + +- *C# (EF Core):* every index, with `@where`, `@orders`, `@using` and its name, and the table and + column comments. An `@expr` index is not an EF index; `meta migrate` creates it. Pinned by + `EfModelGapsTests`. +- *TypeScript (Drizzle):* every index, with `@where`, `@orders`, `@using`, `@expr` and its name + (`templates/drizzle-schema.ts`). Not expressible: `@description`, which Drizzle has no API for; + an `@expr` index on SQLite (Drizzle's `.on()` there takes columns only). +- *Kotlin (Exposed 0.55):* each index's name, `@using` (`indexType`) and `@where` + (`filterCondition`). Pinned by `KotlinExposedIndexVocabularyTest`. Not expressible: `@orders` + (an Exposed index has no per-column direction), `@expr` (it indexes columns or typed + functions, not a raw expression) and `@description` (no comment API). +- *Java, Python:* no ORM model or DDL emitter; nothing to carry. + **The eject contract** ([ADR-0034](../spec/decisions/ADR-0034-codegen-scaffold-and-own.md) Amendment 8). A gate, not a corpus: it has no fixtures of its own, and reads `generator-registry-conformance/registry.json`. @@ -288,7 +326,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (449) +### `fixtures/conformance/` — metamodel loader + canonical serializer (451) | Fixture prefix | Feature doc | |---|---| @@ -352,10 +390,10 @@ All 37 fixtures → [features/migrations-and-drift.md](features/migrations-and-d may carry `"isArray": true`; with `fields` it is a list of objects, without it a list of scalars. The six `verify-positional-accessor-*` cases pin where `-last` is available. -### `fixtures/persistence-conformance/` (59 — 53 query + 6 migration) +### `fixtures/persistence-conformance/` (62 — 55 query + 7 migration) -- `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section) -- `queries/*` (53) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) +- `migrations/*` (7) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section) +- `queries/*` (55) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) **Engines beyond Postgres (FR-044 reports, decimals).** The twenty-four `queries/report-*.yaml` scenarios also run on SQLite and MySQL 8.4 in every port, and on D1's local runtime in @@ -384,11 +422,11 @@ The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`) plus the implicit-AND combinator and 2 error shapes (`invalid_filter_field` / `invalid_filter_op`) under the URL grammar `?filter[][]=` (FR-009). On top of the 31 core -scenarios the corpus carries nine sub-corpora — `tph/` (10, single-table +scenarios the corpus carries ten sub-corpora — `tph/` (10, single-table inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination each corpus alone could not reach, and 1 pinning the collection-URL spelling), `jsonb/` (5, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string), -`write-through/` (2, table-write + view-read entities), `secret-fields/` (2, #484: a `@formExclude` or credential `view.password` field is ignored on create, PATCH and PUT, and a credential is never returned), `projection/` (12, a +`write-through/` (2, table-write + view-read entities), `secret-fields/` (2, #484: a `@formExclude` or credential `view.password` field is ignored on create, PATCH and PUT, and a credential is never returned), `enum-default/` (2, a create that omits a `field.enum` with `@default` stores the default, and a supplied value is kept), `projection/` (12, a read-only view answers reads and refuses writes with 405; a projection with no declared identity has no item route; a projection keyed on a field not named `id` is addressed by it, whether its identity names `@fields` or derives them; decimal and float fields filter), `decimal/` (11, ADR-0019 Amendment 1: a decimal, array element and undeclared-precision column included, is one @@ -400,7 +438,7 @@ its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` re a row with no facts, and a measure with `@default` filters and sorts as its default). All 5 ports — TS, Java, Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and the port's own GENERATED API artifact booted over HTTP. `write-through/`, -`secret-fields/`, `projection/`, `decimal/`, `duration/` and `report/` run the generated lane only, on all five ports: what +`secret-fields/`, `enum-default/`, `projection/`, `decimal/`, `duration/` and `report/` run the generated lane only, on all five ports: what they test is whether a port's generator emits the routes. The `report/` sub-corpus is the one that reads real views, so it runs full stack on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/schema.mysql.sql`, both TypeScript-produced) in every @@ -649,8 +687,8 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 449 + yaml 16 + verify 45 -+ render 18 + persistence 59 + api-contract 121 + source-resolution 25 + scope 10 + +The fixtures in the nine corpora mapped above (metamodel 451 + yaml 16 + verify 45 ++ render 18 + persistence 62 + api-contract 123 + source-resolution 25 + scope 10 + dependency 47) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the `cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 66b35596e7..f8f66a09fe 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -571,6 +571,16 @@ returns the column, its `report-export` output has no column for it, and it is n `fixtures/api-contract-conformance/secret-fields/` pins this in the generated lane of every port. +## Defaults on create + +A create body may leave out a field that declares a literal `@default` (an enum member, a boolean, a +number, a string). The stored row then holds the default, never `null` and never the type's zero value. +Java and Kotlin fill it in the generated handler; TypeScript, C# and Python leave the column out of the +INSERT, so the column default that `meta migrate` writes applies. A value the body does supply is +kept. + +`fixtures/api-contract-conformance/enum-default/` pins this for an enum in the generated lane of every port. + ## Read-only projections **All five ports serve an `object.projection` whose only source is a read-only diff --git a/docs/features/field-types.md b/docs/features/field-types.md index f267c7b2c9..4cd13888bd 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -136,7 +136,8 @@ database calls it. `@column` sets the second. It never changes the first. With no `@column`, the physical name comes from the project's **column rule**: `snake_case` (the default), `literal` (the field name verbatim), `kebab-case` or -`upperFirst` (the first letter upper-cased, the rest verbatim). That +`pascal` (the first letter upper-cased, the rest verbatim), or a rule your application +registers under a name of its own (see [physical-naming.md](migrations/physical-naming.md)). That is CONFIG, not metadata, on purpose: the same model has to be able to drive a snake_case Postgres schema and a literal-column one. It is ONE setting for the whole project, `naming` in `.metaobjects/config.json` (ADR-0058): @@ -241,10 +242,14 @@ deferred (see [enum-datatype-design.md](../superpowers/specs/2026-05-23-enum-dat Two rules follow from int-backing being a **persistence-layer codec**: -- **It is scalar-only.** `@intValueMap` together with `isArray: true` is a load error, - `ERR_ENUM_INT_VALUE_MAP_ARRAY`, in every port — no port implements the codec element-wise - over an array column, so the combination would silently persist member *symbols* into an - integer array. An array-of-enum stays string-backed. +- **An array is mapped element by element.** An enum field with `isArray: true` that + carries `@intValueMap` (its own, or inherited from a shared enum) stores each element as + its declared integer: an `integer[]` column on Postgres, and a JSON array of numbers + (`[5,0]`) where an engine keeps arrays as JSON text (SQLite, D1, MySQL). Each element reads + back as its member. A null array stays null and an empty one stays empty. The array column + has no `CHECK`, the same as a string-backed enum array. Where a port's runtime stores no + primitive array on an engine, it refuses an int-backed one there rather than writing + symbols: see that port's page under [docs/ports/](../ports/). - **A stored integer that maps to no member throws on read**, in every port. The row holds data the model says is impossible (a hand-written `INSERT`, or a member removed without a migration); surfacing the raw integer would hand you a "member" that is not one — and is @@ -323,7 +328,7 @@ For `flattened`, the generated table gets `address_street`, `address_city`, Each member's column is a **prefix** followed by the member's own column (its `@column`, else its name under the project's column rule). The prefix defaults to the embedding field's column -and `_` (`address_`; with `@column: "bill"`, `bill_`; under the `upperFirst` rule, `Address_`, +and `_` (`address_`; with `@column: "bill"`, `bill_`; under the `pascal` rule, `Address_`, EF Core's own default). **`@flattenedColumnPrefix`** on the embedding field replaces it, verbatim: diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md index 13948bed49..d98cb972c6 100644 --- a/docs/features/migrations-and-drift.md +++ b/docs/features/migrations-and-drift.md @@ -305,10 +305,11 @@ existing column with the same options), and `meta verify --db` reads the live se from the catalog. A live sequence whose start or name differs from the declaration is altered in place, never dropped: -- a different start sets it (`ALTER SEQUENCE … START WITH n`) and moves the counter up to it - when it has not got there (`setval(…, n, false)`), so the next insert takes the declared - start. It never moves the counter down, which could hand out an id already in use; the down - migration restores the old start value and leaves the counter where it is. +- a different start records it (`ALTER SEQUENCE … START WITH n`) and nothing else. It never + restarts the sequence and never moves its current value, because live rows already hold the + ids it handed out: `@sequenceStart` is the first value of a fresh sequence, the same in + every port. The down migration restores the old start value, again without touching the + counter. - a different name renames it (`ALTER SEQUENCE … RENAME TO …`); the down renames it back. Only what the model declares is compared, on every path (a live database, a shadow database, diff --git a/docs/features/migrations/docs-are-generators.md b/docs/features/migrations/docs-are-generators.md index de92e9b139..797af53fa3 100644 --- a/docs/features/migrations/docs-are-generators.md +++ b/docs/features/migrations/docs-are-generators.md @@ -147,7 +147,7 @@ them only if you ran those flags. | 1.0 (`meta docs` flag or `docs` key) | 1.1.0 | |---|---| | `docs.outDir`, `--out` (default `./docs/generated`) | `targets.docs.outDir` | -| `docs.layout`, `--layout` | `targets.docs.outputLayout` (falls back to the top-level `outputLayout`, as 1.0 did) | +| `docs.layout`, `--layout` | `targets.docs.outputLayout` (falls back to the top-level `outputLayout`, as 1.0 did). From the release after 1.2.0 it no longer places the pages: `docsFile()` and `apiDocsFile()` always write a page under its package path, so it cannot collide with a same-named node of another package | | `docs.surfaces`, `--model` / `--api` / `--requirements` / `--agent` | which factories you list: `docsFile()`, `apiDocsFile()`, `requirementsFile()`, `agentDocsFile()`. `--agent` wrote all three agent pages unconditionally; `agentDocsFile()` writes `agent/ui.md` only when the run also lists a client UI generator (a form, a hook, a grid or its columns) or the config sets `includeUiTier: true`; with `includeUiTier` unset it warns when it skips the page, and `includeUiTier: false` (no UI anywhere) skips it quietly — see "Things that are different in 1.1.0" | | `docs.apiSurfaces` | `docsFile({ apiSurfaces })`, same shape. Leave it out when the only api pages are this project's own: the model pages then link to whatever `apiDocsFile()` writes to the same target | | the `subDir` of the `ts` entry in `docs.apiSurfaces` (default `api`) | `apiDocsFile({ subDir })`, same default | @@ -624,7 +624,7 @@ to the same paths as the 8.0 pom above: | `` (default `${project.build.directory}/docs`) | `outputDir`, required | | `` (default `java`) | which entries you list: `JavaApiDocsGenerator` for `java`, `KotlinApiDocsGenerator` for `kotlin` | | `` (default `api/`) | `apiSubDir`, same default per generator | -| `` (default `flat`) | `layout`, same default | +| `` (default `flat`) | `layout`, same default through 8.2.0; from the release after it the default is `package`, so a page never collides with a same-named node of another package (`flat` is still accepted) | | `` | `modelBaseUrl` | | the page header's project label, which was the module's `artifactId` | `projectName`, default `project`; set `${project.artifactId}` to keep 8.0's pages byte for byte | | `-Dmetaobjects.docs.*` properties | none; use Maven properties inside `` if you need to override from the command line | diff --git a/docs/features/migrations/physical-naming.md b/docs/features/migrations/physical-naming.md index e5576c84f0..bb4ca9ed4d 100644 --- a/docs/features/migrations/physical-naming.md +++ b/docs/features/migrations/physical-naming.md @@ -25,17 +25,58 @@ every runtime `ObjectManager` and `verify` read it, so they always produce the s | Key | Values | Applies to | Default | |---|---|---|---| -| `columns` | `snake_case`, `literal`, `kebab-case`, `upperFirst` | a field with no `@column` | `snake_case` | -| `tables` | `snake_case`, `literal` | a source with no `@table`/`@view`/`@proc`/`@function`: its `name`, else the object's name made plural | `snake_case` | +| `columns` | `snake_case`, `literal`, `kebab-case`, `pascal`, or a name your application registered | a field with no `@column` | `snake_case` | +| `tables` | `snake_case`, `literal`, or a name your application registered | a source with no `@table`/`@view`/`@proc`/`@function`: its `name`, else the object's name made plural | `snake_case` | -An unknown key or value fails when the file is read. Index and constraint names are unchanged. +An unknown key fails when the file is read, and so does a name no registered strategy answers. +Index and constraint names are unchanged. -`upperFirst` upper-cases the field name's first letter and changes nothing -else: `createdAt` becomes `CreatedAt`, `requestURL` becomes `RequestURL` and `address_line1` -becomes `Address_line1`. It is not a word split, so it is not PascalCase. A schema whose columns -are the field names with a capital first letter (EF Core's default, for one) then needs `@column` -only where the database spells a name differently. It is a column rule only; `literal` tables -already keep an object name verbatim. +`pascal` upper-cases the field name's first letter and changes nothing +else: `createdAt` becomes `CreatedAt`, `requestURL` becomes `RequestURL`, `npi` becomes `Npi` and +`address_line1` becomes `Address_line1`. It does not split words, so an acronym keeps the case +it was written in. A schema whose columns are the field names with a capital first letter (EF +Core's default, for one) then needs `@column` only where the database spells a name differently, +such as `@column: "NPI"`. It is a column rule only; `literal` tables already keep an object name +verbatim. + +### Your own naming strategies + +Each rule above is a registered strategy: a function from a bare metadata name to a physical +name, selected by name. An application registers its own in the same registry, under a name of +its own, and `.metaobjects/config.json` names it exactly as it names a built-in: + +```json +"naming": { "columns": "pascal-acronyms" } +``` + +A strategy can wrap a built-in. This one upper-cases the acronyms the database spells in capitals +and is `pascal` otherwise: + +| Port | Register it | +|---|---| +| TypeScript | `namingStrategies` in `metaobjects.config.ts`: `{ columns: { "pascal-acronyms": (n) => (ACRONYMS.has(n) ? n.toUpperCase() : columnNamingStrategy("pascal")(n)) } }`. Every `meta` command registers it before it reads `.metaobjects/config.json`. A program that loads the model itself calls `registerColumnNamingStrategy` from `@metaobjectsdev/metadata` first | +| C# | `NamingStrategies.RegisterColumns("pascal-acronyms", n => Acronyms.Contains(n) ? n.ToUpperInvariant() : ColumnNamingStrategy.Pascal.Apply(n));` before the load or the codegen run, in the host that runs it (`codegen/Program.cs`). The stock `dotnet meta` tool knows only the built-ins; its `verify --codegen` hands off to that runner before it checks the name, but its `--templates` and `--db` gates run in its own process and refuse the name (exit 2) | +| Java and Kotlin | `NamingStrategies.registerColumns("pascal-acronyms", n -> ACRONYMS.contains(n) ? n.toUpperCase(Locale.ROOT) : NamingStrategies.column("pascal").apply(n));` (`com.metaobjects.database`) before the load, in the application or the host that runs codegen | +| Python | `register_column_naming_strategy("pascal-acronyms", lambda n: n.upper() if n in ACRONYMS else column_naming_strategy("pascal")(n))` (`metaobjects.naming`) before the load, in the application or the host that runs codegen. For the stock `metaobjects` command, register it at import time in a provider module listed under `providers:` in `metaobjects.config.yaml`: the command imports providers before it reads `.metaobjects/config.json` | + +Table rules register the same way (`namingStrategies.tables`, `RegisterTables`, `registerTables`, +`register_table_naming_rule`) and name a source with no physical name; the entity-name fallback +is still made plural after the rule. A built-in name cannot be replaced. Register the same +strategy in every port that reads the database, because each port resolves the names itself. +Every port looks a strategy up by name each time it applies one, so registering again under the +same name replaces it for everything that runs afterwards. A test that registers its own calls +the port's reset when it is done (`resetNamingStrategiesForTesting()`, +`NamingStrategies.ResetForTesting()`, `NamingStrategies.resetForTesting()`, +`reset_naming_strategies_for_testing()`), which leaves only the built-ins. + +A name nothing registered fails when the file is read, naming the setting, the name, the port, +and what is registered: + +``` +"naming.columns" names "pascal-acronym", which no column-naming strategy registered in the C# port answers (built in: snake_case, literal, kebab-case, pascal; registered by the application: pascal-acronyms). Register it before .metaobjects/config.json is read, or correct the name. +``` + +An explicit `@column` or `@table` still wins over every strategy. **Upgrade every port of an estate together.** A 1.0.x TypeScript tool refuses the new `naming` key, but a 1.0.x C#, Java or Python tool ignores an unknown top-level key silently and keeps the old diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index a4a72b357c..547ccea0e7 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -387,11 +387,98 @@ The codegen emits: See [reporting](../features/reporting.md#how-a-report-is-served). - `AppDbContext.g.cs` — `DbSet`, projection `.ToView()`, `@storage` owned types via `OwnsOne` (single) / `OwnsMany(...).ToJson(...)` (`@isArray` array-of-VO), - enum-as-string via `HasConversion()`. + enum-as-string via `HasConversion()`. An int-backed enum (`@intValueMap`) gets a + converter pair that stores the declared integer and throws on a stored integer the map does + not describe, and on a write of a value it does not describe (a cast integer, say), rather + than storing another member's integer. On an `isArray` enum that pair is the element converter of an EF Core primitive + collection, so a Postgres column is `integer[]` and a SQLite or MySQL column is a JSON array + of numbers (`[5,0]`). A non-required int-backed enum array is an `IList?`, so a NULL + column reads back as null. - `AuthorRoutes.g.cs` — CRUD minimal-API endpoints. - `AuthorFilterAllowlist.g.cs` — the server-side filter/sort allowlist feeding the generated list handler. +### Entity and DbContext generator options + +**Renaming a property.** A field's C# property is its PascalCased name. To spell some +differently (an acronym, say), set one resolver on the config, `GenConfig.PropertyNaming` +(`Func`, returning the name). The entity classes, the composite +`[PrimaryKey(nameof(...))]`, every `AppDbContext` model line and the reverse finders all read it, +so the declaration and every reference agree. The hooks `EntityGenerator.PropertyName` and +`DbContextGenerator.PropertyName` default to it; a subclass that overrides one must override the +other the same way. The routes generator and the filter runtime still use the PascalCased name. + +**Class attributes.** `EntityGenerator.EmitClassAttributes(sb, entity, className, kind, ctx)` is +called for every class it emits (mapped entity, TPH subtype, abstract shape, value object), after +the framework's own attributes and right before the declaration line. Override it to add an +attribute without re-implementing `EmitClassHeader`. + +**One line at a time.** Each of these is a `protected virtual` hook whose default is the line it +replaces; override it, call base, and adjust. On `EntityGenerator`: `ValueObjectMemberAttributes` +(the `[JsonPropertyName]` before each value-object member), `ColumnAttributeArguments` (the +arguments of a mapped property's `[Column(...)]`; none leaves the attribute out) and +`TphDerivedTypeAttributes` (the `[JsonDerivedType]` lines on a TPH base). On `DbContextGenerator`, +the head of the file: `ContextUsings` (the `using` lines), `ContextNamespace`, `ContextClassName` +(default `AppDbContext`; `GenConfig.ContextTypeName` does not change it), `ContextBaseClass`, +`ContextIsPartial`, `EmitContextConstructor` and `ContextMemberLines` (members after the +constructor, such as a `partial void` declaration a `ContextTrailerLines` call implements). The +file is `AppDbContext.g.cs` whatever the class is called, and the routes generator still names +`AppDbContext`: rename the class and you keep the routes consistent yourself. `NamesGenerator.Render` +emits one `Names.g.cs`; a `null` from it emits none. + +**Reverse finders.** `Queries.g.cs` takes the context as its first parameter, typed +`GenConfig.ContextTypeName` (default `AppDbContext`). `new EntityGenerator { EmitsReverseFinders = +false }` emits none, for a project that writes its own. + +**Scoping value objects.** `Include` scopes the entities and value objects an entry emits, but a +value object an emitted entity points at is still emitted, so the output compiles. Set +`ScopesValueObjectClosure = true` beside `Include` to drop those too, when you provide the type +yourself. + +**What the context configures beyond the column types.** A single-column +`identity.primary @generation: assigned` key is `ValueGeneratedNever()`, so EF always sends the +caller's value, `0` included. An `identity.reference` relationship carries +`.HasConstraintName(...)` from `@constraintName`, and `.HasPrincipalKey(...)` when `@references` +names target fields other than the primary key. Under `EmitAbstractShapes` a concrete entity +extends its abstract shape class, which carries no EF attributes, so the key, column names and +`@maxLength` of the fields it inherits from there are configured in the context instead. +A `@storage: flattened` value object pins each member to its flattened column; a value object +nested inside it is one jsonb column holding the whole document (EF Core 8 cannot own a JSON +column inside a table-split owned type). +A field's `@default` is `HasDefaultValue(v).HasSentinel(v)` when it is a C# literal of the +property's type (bool, string, number, enum member): EF then leaves the column out of an INSERT +only when the value is the default anyway, so an explicit `false`, `0` or NULL is stored as set. A +SQL expression (`now()`, anything function-like; a bare `now` is `now()`) or a timestamp, date or +uuid literal is `HasDefaultValueSql(...)`, and only on a `@required` property, because EF cannot +tell a deliberate NULL from "not supplied". A scalar or enum property starts at its literal +`@default` (its C# initializer), so a new entity the app never touches holds the sentinel and the +column default applies. A timestamp or uuid property has no C# initializer from its `@default`, +so a new entity carries the CLR default and EF sends it. + +`identity.primary @sequenceStart` on a single-field `increment` key is the identity's START on +Npgsql (a fresh database begins there). No start-only change ever restarts the sequence: Npgsql +alone writes a start added as `RESTART WITH n`, a changed one as `RESTART WITH m`, and a removed one +as `RESTART WITH 1`, each handing out ids live rows may hold. A Postgres context with an increment +key gets `IdentityStartMigrationsSqlGenerator.g.cs`, which carries the rewrite: every start-only +change is `ALTER TABLE … ALTER COLUMN … SET START WITH n` (a removed start is set back to the +identity default), and the current value never moves. Register it after `UseNpgsql(...)`, wherever +the context is configured — the app's registration (pooled or not), an +`IDesignTimeDbContextFactory`, or the context's own `OnConfiguring`: + +```csharp +services.AddDbContextPool(o => o.UseNpgsql(cs).UseIdentityStartMigrations()); +``` + +It applies at both ends: the differ scaffolds the change as that SQL, so a migration added through +it is safe wherever it runs, and the SQL generator rewrites a migration scaffolded without it. A +context that declares a start refuses to build its model on Npgsql when the rewrite is not +registered (an `InvalidOperationException` that names the call), so a missing registration fails at +`dotnet ef migrations add`, never as a silent RESTART. The file references +`Npgsql.EntityFrameworkCore.PostgreSQL` at compile time, so it (and the start in the model) is +emitted only for a project whose `.metaobjects/config.json` names +`"migrate": { "dialect": "postgres" }` (a self-hosted runner sets `GenConfig.Dialect`). Any other +project gets neither, with a warning, and never needs the Npgsql package. + ### Decimals and null members on the wire What the generated API writes is the project setting `wire` in `.metaobjects/config.json` @@ -560,7 +647,9 @@ Column names come from the project's `naming` setting in `.metaobjects/config.js database whose columns are named exactly like the fields declares `"naming": { "columns": "literal" }`; through 1.0 that was this port's default. One whose columns are the field names with the first letter upper-cased (`requestURL` → `RequestURL`) declares -`"upperFirst"`. `dotnet meta gen` +`"pascal"`. A host that needs its own rule (an acronym list, a prefix) registers it with +`NamingStrategies.RegisterColumns(name, n => ...)` before it reads the config, and names it there; +the stock `dotnet meta` tool knows only the built-ins. `dotnet meta gen` and `dotnet meta verify --codegen` both read the setting, so a plain run and the drift gate resolve the identical column strings; `--column-naming` is refused ([physical-naming.md](../features/migrations/physical-naming.md)). `verify --codegen` says so diff --git a/docs/ports/java.md b/docs/ports/java.md index 3cd9e1343d..ba2c998a6b 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -983,6 +983,16 @@ the runtime classpath, and no stored-procedure callable generator exists (FR-015 TypeScript, C# and Kotlin only). OMDB is data access only; schema migrations are owned by the Node `meta` CLI. +OMDB persists no `field.enum` array, string-backed or int-backed. On Postgres a native array column +is mapped only for `field.string`, `field.uuid` and `field.decimal` (`PostgresDriver.nativeArrayElementType`), +and the other drivers have no primitive-array path. An int-backed enum array (`@intValueMap` with +`isArray: true`, own or inherited) loads, but the enum codec refuses it on write and on read with an +`SQLException` naming the field. Without that refusal the scalar codec would write the member +symbols, and on SQLite it would read a stored `[5,0]` as `DRAFT`. The `roundtrip-int-enum-array` +persistence scenario is deferred in the Java runners for this reason. The generated Spring enum for +an int-backed enum carries `dbValue()` and `fromDbValue(int)`, and the payload types an array field as +`List`, so a repository you write maps each element. + ## Capability snapshot | Feature | Status | diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 6d1a56996b..3dc4f382d7 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -476,7 +476,8 @@ reactor's 2.0.21; its round-trip test additionally needs docker. Run via selection compiles every entity's generated `Controller.kt` — TPH, M:N, view projections, and every `emitPerFieldDispatchArm` operator arm across every scalar subtype — against real Spring 6.2 (`spring-webmvc`) + jakarta.servlet + Jackson on the - module's test classpath (real dependencies, not stubs), with no exclusions. The + module's test classpath (real dependencies, not stubs), with one exclusion: + `IntEnumArrayController.kt` (see [Enum arrays](#enum-arrays) below). The per-field dispatch gate is derived from the SAME single source of truth the generated `FilterAllowlist` reads (`com.metaobjects.query.FilterOps`), so the controller can never emit an operator the allowlist itself would refuse to admit — this is also @@ -488,7 +489,7 @@ reactor's 2.0.21; its round-trip test additionally needs docker. Run via agrees with the controller's `String` assumption, rather than the native `Column` `uuid(...)`/`javaUUID(...)` emits for a genuine `field.uuid`. The `exposedApi=0` sibling of this same controller-tier compile pass — over the identical full fitness corpus, - with the SAME zero exclusions — lives in `integration-tests-kotlin` (it already has + with the SAME one exclusion — lives in `integration-tests-kotlin` (it already has Exposed 0.x + Spring on its classpath), closing the gap `CodegenCompileConformanceTest` deliberately leaves open for 0.x (that module has no Spring dependency at all). 3. **Round-trip.** The hand-rolled `Meta*ColumnType` support classes @@ -909,6 +910,18 @@ configuration (Spring `WebMvcConfigurer` instead of `AddCors`, application port 8080 instead of 5000) — every other line carries over verbatim because the contract is universal. +### Enum arrays + +A `field.enum` with `isArray: true` loads, string-backed or int-backed (`@intValueMap`, own or +inherited from a shared abstract enum). The data class types it `List`, but the Exposed table +generator has no array column for an enum of either kind: `enumColumnSpec` in +`KotlinExposedTableGenerator` emits the scalar `enumerationByName(...)` or +`customEnumeration(..., "INTEGER", ...)`, so the generated controller for such an entity does not +compile. It never stores member symbols in an integer column, because the code that would do so +does not type-check. Persist an enum array through your own column until the generator emits one. +The `roundtrip-int-enum-array` persistence scenario is deferred in this port's runners for this +reason. + ## Drift detection (Tier-2 integration) | Drift source | Where caught | When | diff --git a/docs/ports/python.md b/docs/ports/python.md index 82409c66c4..28006a7212 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -688,6 +688,18 @@ A `::`-relative package (`"package": "::parts"`) is expanded when the file is pa that file's root package, so `package` on such a node is already the full `beta::other::parts`. +### Int-backed enums in the runtime + +A `field.enum` with `@intValueMap` (own or inherited from a shared abstract enum) is stored as its +declared integers while `ObjectManager` callers pass and receive member symbols. The codec in +`metaobjects.runtime.object_manager` encodes on every write and on filter operands, and decodes on +every read. On an `isArray: true` field it does this for each element. On Postgres the array is an +`integer[]` holding the ints in order: `["PUBLISHED", "DRAFT"]` is stored as `{5,0}`. An empty +array stays empty, a null array stays null, and a null element stays a null element. An int that +has no member raises `ValueError` on read, whether it is a scalar or an array element. SQLite and +MySQL cannot store an array of any primitive type in this runtime, so an enum array of either kind +is Postgres-only. + ### Run-time validation `run_validators` checks a data mapping against an entity's metadata, with no generated diff --git a/docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md b/docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md index fdce8ff9d4..cfe652238b 100644 --- a/docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md +++ b/docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md @@ -167,6 +167,18 @@ This is why `@intValueMap` is a map, not a second array parallel to `@values`. `error-enum-intvaluemap-array` (own map) and `error-enum-intvaluemap-array-inherited` (the canonical shared-enum shape). +- **D7 amendment (2026-11-04) — the array form is supported; the load error is removed.** + D7's premise, that nothing needs the array form, turned out to be wrong. An adopter's scalar + integer-coded columns share an enum with array fields whose stored elements are the same + integers. Because both halves are read resolving, the map on the shared enum blocked every + one of those scalar columns too. The project rule is that a limit belongs only to the port + or database that has it, so identical refusal everywhere is not the goal. Every port now maps + elements wherever it stores an enum array (TypeScript on all engines, C# on all engines, + Python on Postgres). A port that stores no primitive array on an engine refuses the + int-backed form there at its codec, never writing symbols, and defers the scenario by name + in its own runner. The two error fixtures became the positive `enum-int-backed-array` + fixtures, and `roundtrip-int-enum-array` gates the runtime codecs. + - **D8 — Migration safety: no auto-recast.** Adding `@intValueMap` to a *new* field (no existing column) is a normal create. Adding or removing `@intValueMap` on a field that **already has a table/column** is a backing-mode change — migrate-ts detects it in the diff --git a/examples/advanced-modeling/codegen/templates/enum-int-codec.ts b/examples/advanced-modeling/codegen/templates/enum-int-codec.ts index 7719ae63cf..c02b3dc950 100644 --- a/examples/advanced-modeling/codegen/templates/enum-int-codec.ts +++ b/examples/advanced-modeling/codegen/templates/enum-int-codec.ts @@ -49,6 +49,11 @@ export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: stri const fromEntries = t.members .map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`) .join(", "); + const maps = code` +const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; +const ${t.fromIntConstName}: Record = { ${fromEntries} };`; + if (t.array === "native") return renderNativeArrayElementCodec(t, customTypeSym, union, maps); + if (t.array === "json") return renderJsonArrayCodec(t, customTypeSym, union, maps); return code` const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; const ${t.fromIntConstName}: Record = { ${fromEntries} }; @@ -65,3 +70,74 @@ const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number } }); `; } + +/** + * The ELEMENT codec of a Postgres `integer[]` int-backed enum array, used under + * Drizzle's `.array()`: PgArray maps each element through it on write and read. It + * differs from the scalar codec in two places. PgArray hands a null ELEMENT to + * `fromDriver`, so null reads back as null (as in a string-backed `text[]`). And an + * unmapped member throws on write, where the scalar's `undefined` is left to the + * column's CHECK: an array column has none, and an `undefined` element would be + * bound as NULL. + */ +function renderNativeArrayElementCodec(t: EnumIntCustomType, customTypeSym: unknown, union: string, maps: Code): Code { + return code`${maps} +const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => { + const stored = ${t.toIntConstName}[value]; + if (stored === undefined) { + throw new Error(\`unmapped ${t.fnConstName} member: \${String(value)}\`); + } + return stored; + }, + fromDriver: (value) => { + if (value === null) return value as never; + const member = ${t.fromIntConstName}[value]; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`); + } + return member; + }, +}); +`; +} + +/** + * A whole-array codec for an int-backed enum array kept in ONE JSON column (SQLite + * `text`, MySQL `json`): a JSON array of the mapped INTS, `[5,0]`, never of the member + * symbols. A null element stays null; an unmapped member throws on write and an + * element that is not a mapped int throws on read (a symbol written before this codec + * existed included). The driver hands back JSON text (SQLite) or the parsed array + * (mysql2), so both are accepted. + */ +function renderJsonArrayCodec(t: EnumIntCustomType, customTypeSym: unknown, union: string, maps: Code): Code { + return code`${maps} +const ${t.fnConstName} = ${customTypeSym}<{ data: (${union})[]; driverData: string }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => + JSON.stringify(value.map((member) => { + if (member === null) return null; + const stored = ${t.toIntConstName}[member]; + if (stored === undefined) { + throw new Error(\`unmapped ${t.fnConstName} member: \${String(member)}\`); + } + return stored; + })), + fromDriver: (value) => { + const stored: unknown = typeof value === "string" ? JSON.parse(value) : value; + if (!Array.isArray(stored)) { + throw new Error(\`${t.fnConstName} expected a JSON array, got: \${String(value)}\`); + } + return stored.map((el: unknown) => { + if (el === null) return el as never; + const member = typeof el === "number" ? ${t.fromIntConstName}[el] : undefined; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${JSON.stringify(el)}\`); + } + return member; + }); + }, +}); +`; +} diff --git a/examples/showcase/codegen/templates/enum-int-codec.ts b/examples/showcase/codegen/templates/enum-int-codec.ts index 7719ae63cf..c02b3dc950 100644 --- a/examples/showcase/codegen/templates/enum-int-codec.ts +++ b/examples/showcase/codegen/templates/enum-int-codec.ts @@ -49,6 +49,11 @@ export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: stri const fromEntries = t.members .map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`) .join(", "); + const maps = code` +const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; +const ${t.fromIntConstName}: Record = { ${fromEntries} };`; + if (t.array === "native") return renderNativeArrayElementCodec(t, customTypeSym, union, maps); + if (t.array === "json") return renderJsonArrayCodec(t, customTypeSym, union, maps); return code` const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; const ${t.fromIntConstName}: Record = { ${fromEntries} }; @@ -65,3 +70,74 @@ const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number } }); `; } + +/** + * The ELEMENT codec of a Postgres `integer[]` int-backed enum array, used under + * Drizzle's `.array()`: PgArray maps each element through it on write and read. It + * differs from the scalar codec in two places. PgArray hands a null ELEMENT to + * `fromDriver`, so null reads back as null (as in a string-backed `text[]`). And an + * unmapped member throws on write, where the scalar's `undefined` is left to the + * column's CHECK: an array column has none, and an `undefined` element would be + * bound as NULL. + */ +function renderNativeArrayElementCodec(t: EnumIntCustomType, customTypeSym: unknown, union: string, maps: Code): Code { + return code`${maps} +const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => { + const stored = ${t.toIntConstName}[value]; + if (stored === undefined) { + throw new Error(\`unmapped ${t.fnConstName} member: \${String(value)}\`); + } + return stored; + }, + fromDriver: (value) => { + if (value === null) return value as never; + const member = ${t.fromIntConstName}[value]; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`); + } + return member; + }, +}); +`; +} + +/** + * A whole-array codec for an int-backed enum array kept in ONE JSON column (SQLite + * `text`, MySQL `json`): a JSON array of the mapped INTS, `[5,0]`, never of the member + * symbols. A null element stays null; an unmapped member throws on write and an + * element that is not a mapped int throws on read (a symbol written before this codec + * existed included). The driver hands back JSON text (SQLite) or the parsed array + * (mysql2), so both are accepted. + */ +function renderJsonArrayCodec(t: EnumIntCustomType, customTypeSym: unknown, union: string, maps: Code): Code { + return code`${maps} +const ${t.fnConstName} = ${customTypeSym}<{ data: (${union})[]; driverData: string }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => + JSON.stringify(value.map((member) => { + if (member === null) return null; + const stored = ${t.toIntConstName}[member]; + if (stored === undefined) { + throw new Error(\`unmapped ${t.fnConstName} member: \${String(member)}\`); + } + return stored; + })), + fromDriver: (value) => { + const stored: unknown = typeof value === "string" ? JSON.parse(value) : value; + if (!Array.isArray(stored)) { + throw new Error(\`${t.fnConstName} expected a JSON array, got: \${String(value)}\`); + } + return stored.map((el: unknown) => { + if (el === null) return el as never; + const member = typeof el === "number" ? ${t.fromIntConstName}[el] : undefined; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${JSON.stringify(el)}\`); + } + return member; + }); + }, +}); +`; +} diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index e7d1564682..8124e84e1b 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.3" }, "counts": { - "fixtures": 449, + "fixtures": 451, "corpora": 38, "baseTypes": 17 }, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index d34c780c8f..0e7f2570c9 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index d34c780c8f..0e7f2570c9 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index 60118bd559..484509de51 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index 60118bd559..484509de51 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index 49fe8148af..7bd3174d19 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -47,7 +47,7 @@ from. - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index 49fe8148af..7bd3174d19 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -47,7 +47,7 @@ from. - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index a296481115..ba41481275 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index a296481115..ba41481275 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md index 29d77fc749..72b81c75ed 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md index 29d77fc749..72b81c75ed 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md @@ -50,7 +50,7 @@ Not there? List the missing generator — and a `docs` target, if `targets` has - **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate -- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. +- **Search the vocabulary before concluding it cannot be expressed — `meta types `.** An unregistered TYPE or SUBTYPE fails the load in every command. An undeclared `@attr` is different: it fails `meta verify`, which loads strict, and NOT `meta gen`, which does not — so an invented attribute passes codegen silently and is caught by the drift gate rather than by the build. Either way inventing vocabulary is a failure you will hit, not a shortcut. Search names with `meta types `, names and descriptions with `--all` ("find it by what it does"), and one construct's valid `@attrs` with `meta types . --detail`; `--format json` returns the same answer as one machine-readable document, each attr with its `allowedValues`. Rows marked `[ts-only]` are TypeScript-only vocabulary the cross-port contract does not carry. `[-only]` (such as `[csharp-only]`) marks vocabulary only that port ever acts on. It is the Node `meta` CLI and, like `meta migrate`, works whatever your backend — it reads the registry, so it needs no project. Ask for NEW vocabulary only once that search comes up empty. - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. - Attribute names are unique within a node; for multi-value use one array attr (`@values: [...]`). - An inline `@maxLength: 50` equals an `attr` child of the same name — never write both. diff --git a/fixtures/api-contract-conformance/README.md b/fixtures/api-contract-conformance/README.md index 2e49a231e3..b012a9ed08 100644 --- a/fixtures/api-contract-conformance/README.md +++ b/fixtures/api-contract-conformance/README.md @@ -52,8 +52,8 @@ fixtures/api-contract-conformance/ └── filter-invalid-value.yaml # uncoercible value → 400; the fourth envelope, previously ungated ``` -Beside the 31 core scenarios above sit nine **sub-corpora**, each a directory with its -own `meta.json`, `seed.json`, `scenarios/` and `README.md` (90 scenarios in all, 121 with the +Beside the 31 core scenarios above sit ten **sub-corpora**, each a directory with its +own `meta.json`, `seed.json`, `scenarios/` and `README.md` (92 scenarios in all, 123 with the core): | Directory | Scenarios | Gates | Lanes | @@ -63,6 +63,7 @@ core): | `jsonb/` | 5 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both | | `write-through/` | 2 | an entity that writes a table and reads a view | generated only | | `secret-fields/` | 2 | #484: a `@formExclude` or credential (`view.password`) field is ignored on create, PATCH and PUT, and a credential is never returned | generated only | +| `enum-default/` | 2 | a create that omits a `field.enum` carrying `@default` stores the default (echo and GET by id), and a supplied member is kept | generated only | | `projection/` | 12 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only | | `report/` | 37 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | | `decimal/` | 11 | `field.decimal` is an exact canonical string on the wire (ADR-0019 Amendment 1): read, filter, sort, write and a report over decimals. A scenario's `engines:` key is exact; the `-15` twins run on SQLite and D1 as well. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | diff --git a/fixtures/api-contract-conformance/enum-default/README.md b/fixtures/api-contract-conformance/enum-default/README.md new file mode 100644 index 0000000000..88b177eba6 --- /dev/null +++ b/fixtures/api-contract-conformance/enum-default/README.md @@ -0,0 +1,40 @@ +# `api-contract-conformance/enum-default/` — an enum `@default` applies on create + +Cross-port REST contract for a `field.enum` that declares `@default`: when a create request +omits the field, the generated create handler stores the default, and a value the body does +supply is kept. + +The model is one `Ticket` whose `priority` is an enum of `LOW`, `NORMAL`, `HIGH` with +`@default: "NORMAL"`. + +## The contract + +- **An omitted defaulted enum takes its default on create.** `POST /api/tickets` with no + `priority` answers `201` and the echoed row carries `priority: "NORMAL"`; a `GET` by id then + returns the same value, so the default was stored and not only echoed. +- **A supplied member is kept.** A body with `priority: "HIGH"` answers with `HIGH`, so a port + that always writes the default fails. + +## What each port's lane provisions + +A port's generated create either writes the default itself (Java fills the absent field before it +binds the DTO; Kotlin's data class defaults it) or omits the column from the INSERT and lets the +store apply it (TypeScript, C# and Python). A lane of the second kind provisions the `priority` +column with the `DEFAULT 'NORMAL'` that `meta migrate` emits for an enum `@default`, so every +lane runs the model against the schema the project's own migration would produce. + +## Files + +``` +enum-default/ +├── README.md +├── meta.json # Ticket (tickets table) +├── seed.json # two rows, priorities HIGH and LOW +└── scenarios/ + ├── create-omitted-takes-default.yaml # POST without priority, then GET by id + └── create-supplied-kept.yaml # POST with priority HIGH, then GET by id +``` + +Generated lane only: what is under test is whether each port's GENERATOR emits a create +handler that applies the default, which a hand-rolled reference server would satisfy by +construction. diff --git a/fixtures/api-contract-conformance/enum-default/meta.json b/fixtures/api-contract-conformance/enum-default/meta.json new file mode 100644 index 0000000000..1a524b3821 --- /dev/null +++ b/fixtures/api-contract-conformance/enum-default/meta.json @@ -0,0 +1,17 @@ +{ + "metadata.root": { + "package": "acme::defaults", + "children": [ + { "object.entity": { + "name": "Ticket", + "children": [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id", "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "title", "@required": true, "@maxLength": 100, "@filterable": true, "@sortable": true } }, + { "field.enum": { "name": "priority", "@values": ["LOW", "NORMAL", "HIGH"], "@default": "NORMAL", "@filterable": true } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ] + }} + ] + } +} diff --git a/fixtures/api-contract-conformance/enum-default/scenarios/create-omitted-takes-default.yaml b/fixtures/api-contract-conformance/enum-default/scenarios/create-omitted-takes-default.yaml new file mode 100644 index 0000000000..e035465764 --- /dev/null +++ b/fixtures/api-contract-conformance/enum-default/scenarios/create-omitted-takes-default.yaml @@ -0,0 +1,28 @@ +name: create-omitted-takes-default +description: > + A create that omits a `field.enum` carrying `@default` stores the default. The echo of the + create carries it, and so does a GET by id afterwards, which proves it was written to the + store and not only added to the response. The seed holds ids 1 and 2, so the created row is 3. +requests: + - id: r1 + method: POST + path: /api/tickets + body: + title: "Broken monitor" + expect: + status: 201 + body: + row: + title: "Broken monitor" + priority: "NORMAL" + hasId: true + - id: r2 + method: GET + path: /api/tickets/3 + expect: + status: 200 + body: + equals: + id: 3 + title: "Broken monitor" + priority: "NORMAL" diff --git a/fixtures/api-contract-conformance/enum-default/scenarios/create-supplied-kept.yaml b/fixtures/api-contract-conformance/enum-default/scenarios/create-supplied-kept.yaml new file mode 100644 index 0000000000..719cae72ca --- /dev/null +++ b/fixtures/api-contract-conformance/enum-default/scenarios/create-supplied-kept.yaml @@ -0,0 +1,29 @@ +name: create-supplied-kept +description: > + A create that supplies a member of a defaulted `field.enum` keeps it. The supplied value is + deliberately not the default, so a generated handler that always writes the default fails + here. The seed holds ids 1 and 2, so the created row is 3. +requests: + - id: r1 + method: POST + path: /api/tickets + body: + title: "Leaking pipe" + priority: "HIGH" + expect: + status: 201 + body: + row: + title: "Leaking pipe" + priority: "HIGH" + hasId: true + - id: r2 + method: GET + path: /api/tickets/3 + expect: + status: 200 + body: + equals: + id: 3 + title: "Leaking pipe" + priority: "HIGH" diff --git a/fixtures/api-contract-conformance/enum-default/seed.json b/fixtures/api-contract-conformance/enum-default/seed.json new file mode 100644 index 0000000000..a61fcdb84b --- /dev/null +++ b/fixtures/api-contract-conformance/enum-default/seed.json @@ -0,0 +1,6 @@ +{ + "rows": [ + { "id": 1, "title": "Printer jam", "priority": "HIGH" }, + { "id": 2, "title": "Coffee machine", "priority": "LOW" } + ] +} diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index e9ad9cce50..c526c39bf5 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -101,7 +101,6 @@ "ERR_SQL_BODY_ON_WRITABLE_KIND": "#208: a source.rdb declares @sql with a writable @kind (\"table\", the default). A hand-written CREATE TABLE would bypass the column-diff machinery; tables are fully modeled or @unmanaged, never opaque-bodied.", "ERR_ORIGIN_UNDER_SQL_BODY": "#208: a host object whose read source carries @sql also declares an origin.*-bearing (derived) field, or (on object.projection) an @filter (#207) — two sources of truth for the same body (the synthesized derivation/outer-WHERE vs. the author's verbatim SQL). Fail-closed.", "ERR_ENUM_EXTENDS_VALUES_CONFLICT": "A field.enum both extends a shared package-level abstract enum and declares its own @values. One shared enum type has one member set — the own @values would be silently dropped in codegen. Remove the own @values to inherit the shared set, or extend a concrete (non-shared) enum instead.", - "ERR_ENUM_INT_VALUE_MAP_ARRAY": "A field.enum carries @intValueMap together with isArray=true. Int-backing is a persistence-layer codec and no port implements it element-wise over an array column, so the combination would silently persist member SYMBOLS into an integer array. An array-of-enum stays string-backed: drop @intValueMap, or make the field scalar.", "ERR_ENUM_NORMALIZE_COLLISION": "Two declared members of one field.enum normalize to the same key under the default `@normalize: strip` (upper-case, delete everything outside A-Z0-9), e.g. READ_ONLY and READONLY. A reply naming one of them is indistinguishable from the other, so the extract engine would coerce it to the wrong member. Rename a member so the two differ after that fold, or set `@normalize: collapse` or `none` on the field. Not raised under collapse or none.", "ERR_REQUIREMENT_RETIRED_HAS_IMPLEMENTORS": "A requirement.* with @status: retired declares @implementedBy. Refused rather than exempted (FR-039): a retired capability has no implementation by definition, so forbidding the attribute makes the dangling-reference class unreachable instead of silently tolerated.", "ERR_REQUIREMENT_SUPERSEDED_BY_NOT_RETIRED": "@supersededBy on a requirement whose @status is not `retired`. The attribute names what REPLACED a withdrawn capability; on a live one there is nothing to have replaced it.", diff --git a/fixtures/conformance/enum-int-backed-array-inherited/expected.json b/fixtures/conformance/enum-int-backed-array-inherited/expected.json new file mode 100644 index 0000000000..dad7e40037 --- /dev/null +++ b/fixtures/conformance/enum-int-backed-array-inherited/expected.json @@ -0,0 +1,51 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "field.enum": { + "name": "Status", + "package": "acme", + "abstract": true, + "@intValueMap": { + "ARCHIVED": 9, + "DRAFT": 0, + "PUBLISHED": 5 + }, + "@values": [ + "DRAFT", + "PUBLISHED", + "ARCHIVED" + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.enum": { + "name": "status", + "extends": "acme::Status", + "isArray": true + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-enum-intvaluemap-array-inherited/input/meta.enums.json b/fixtures/conformance/enum-int-backed-array-inherited/input/meta.enums.json similarity index 100% rename from fixtures/conformance/error-enum-intvaluemap-array-inherited/input/meta.enums.json rename to fixtures/conformance/enum-int-backed-array-inherited/input/meta.enums.json diff --git a/fixtures/conformance/enum-int-backed-array/expected.json b/fixtures/conformance/enum-int-backed-array/expected.json new file mode 100644 index 0000000000..ce86c7edff --- /dev/null +++ b/fixtures/conformance/enum-int-backed-array/expected.json @@ -0,0 +1,43 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Ticket", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.enum": { + "name": "labels", + "isArray": true, + "@intValueMap": { + "HIGH": 3, + "LOW": 1, + "MEDIUM": 2 + }, + "@values": [ + "LOW", + "MEDIUM", + "HIGH" + ] + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-enum-intvaluemap-array/input/meta.enums.json b/fixtures/conformance/enum-int-backed-array/input/meta.enums.json similarity index 100% rename from fixtures/conformance/error-enum-intvaluemap-array/input/meta.enums.json rename to fixtures/conformance/enum-int-backed-array/input/meta.enums.json diff --git a/fixtures/conformance/error-enum-intvaluemap-array-inherited/expected-errors.json b/fixtures/conformance/error-enum-intvaluemap-array-inherited/expected-errors.json deleted file mode 100644 index dacdbb47de..0000000000 --- a/fixtures/conformance/error-enum-intvaluemap-array-inherited/expected-errors.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "errors": [ - { - "code": "ERR_ENUM_INT_VALUE_MAP_ARRAY", - "source": { - "format": "json", - "files": [ - "meta.enums.json" - ], - "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[1]['field.enum']" - } - } - ], - "warnings": [] -} diff --git a/fixtures/conformance/error-enum-intvaluemap-array/expected-errors.json b/fixtures/conformance/error-enum-intvaluemap-array/expected-errors.json deleted file mode 100644 index 6b7767484f..0000000000 --- a/fixtures/conformance/error-enum-intvaluemap-array/expected-errors.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "errors": [ - { - "code": "ERR_ENUM_INT_VALUE_MAP_ARRAY", - "source": { - "format": "json", - "files": [ - "meta.enums.json" - ], - "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['field.enum']" - } - } - ], - "warnings": [] -} diff --git a/fixtures/conformance/error-port-scoped-attr-wrong-type/expected-errors.json b/fixtures/conformance/error-port-scoped-attr-wrong-type/expected-errors.json new file mode 100644 index 0000000000..51720f937f --- /dev/null +++ b/fixtures/conformance/error-port-scoped-attr-wrong-type/expected-errors.json @@ -0,0 +1,9 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { "format": "json", "files": [ "meta.diagnosis.json" ], "jsonPath": "$['metadata.root'].children[0]['object.entity']" } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json b/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json new file mode 100644 index 0000000000..5a7b506c8a --- /dev/null +++ b/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json @@ -0,0 +1,11 @@ +{ + "metadata.root": { + "package": "acme::clinical", + "children": [ + { "object.entity": { "name": "Diagnosis", "@dbSetName": ["Diagnosis"], "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ] } } + ] + } +} diff --git a/fixtures/conformance/object-port-scoped-attr-dbsetname/expected.json b/fixtures/conformance/object-port-scoped-attr-dbsetname/expected.json new file mode 100644 index 0000000000..0454a28d5e --- /dev/null +++ b/fixtures/conformance/object-port-scoped-attr-dbsetname/expected.json @@ -0,0 +1,94 @@ +{ + "metadata.root": { + "package": "acme::clinical", + "children": [ + { + "object.entity": { + "name": "Diagnosis", + "@dbSetName": "Diagnosis", + "children": [ + { + "source.rdb": { + "@table": "diagnoses" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "code" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "code", + "@of": "acme::clinical::Diagnosis.code" + } + }, + { + "measure.aggregate": { + "name": "diagnoses", + "@agg": "count", + "@of": [ + "acme::clinical::Diagnosis.id" + ] + } + } + ] + } + }, + { + "object.projection": { + "name": "DiagnosisSummary", + "@dbSetName": "DiagnosisSummary", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_diagnosis_summary" + } + }, + { + "field.string": { + "name": "code" + } + } + ] + } + }, + { + "object.report": { + "name": "DiagnosisCounts", + "@dbSetName": "DiagnosisCounts", + "@dimensions": [ + "code" + ], + "@from": "acme::clinical::Diagnosis", + "@measures": [ + "diagnoses" + ], + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_diagnosis_counts" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/object-port-scoped-attr-dbsetname/input/meta.diagnosis.json b/fixtures/conformance/object-port-scoped-attr-dbsetname/input/meta.diagnosis.json new file mode 100644 index 0000000000..f0e736fca8 --- /dev/null +++ b/fixtures/conformance/object-port-scoped-attr-dbsetname/input/meta.diagnosis.json @@ -0,0 +1,23 @@ +{ + "metadata.root": { + "package": "acme::clinical", + "children": [ + { "object.entity": { "name": "Diagnosis", "@dbSetName": "Diagnosis", "children": [ + { "source.rdb": { "@table": "diagnoses" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "code" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "code", "@of": "Diagnosis.code" } }, + { "measure.aggregate": { "name": "diagnoses", "@agg": "count", "@of": "Diagnosis.id" } } + ] } }, + { "object.projection": { "name": "DiagnosisSummary", "@dbSetName": "DiagnosisSummary", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_diagnosis_summary" } }, + { "field.string": { "name": "code" } } + ] } }, + { "object.report": { "name": "DiagnosisCounts", "@dbSetName": "DiagnosisCounts", "@from": "Diagnosis", + "@dimensions": ["code"], "@measures": ["diagnoses"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_diagnosis_counts" } } + ] } } + ] + } +} diff --git a/fixtures/conformance/yaml-twin-exclusions.json b/fixtures/conformance/yaml-twin-exclusions.json index 4ff2e3e187..82275a6fe7 100644 --- a/fixtures/conformance/yaml-twin-exclusions.json +++ b/fixtures/conformance/yaml-twin-exclusions.json @@ -3,6 +3,7 @@ "notes": "Gate 1 (YAML twin, ADR-0059): fixtures whose input is deliberately format-native, so reading the same text as YAML is expected to report a different error. Each entry names why. Every port also skips the provider-extension-* fixtures whose test-only providers it cannot compose.", "exclusions": { "error-attr-child-not-object": "Asserts a JSON-shaped body error; the YAML desugar reads a scalar body as { name: } (ADR-0006 rule 2).", + "error-port-scoped-attr-wrong-type": "Asserts a JSON value-type error on a list given to a string attr; the YAML coercion guard (ADR-0006 D2) reports ERR_YAML_COERCION under its own code.", "error-measure-default-on-count-non-numeric": "Asserts a JSON value-type error on the quoted \"zero\"; the YAML coercion guard (ADR-0006 D2) reports ERR_YAML_COERCION under its own code.", "error-attr-wrong-type": "Asserts a JSON value-type error that the YAML coercion guard (ADR-0006 D2) reports under its own code.", "error-bare-key-no-concrete-default": "A bare type key is YAML sugar for the default subtype (ADR-0006 rule 1); in canonical JSON it is an error.", diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md index 3e7f4ec5a2..a4d72cca64 100644 --- a/fixtures/metamodel-docs/expected/providers.md +++ b/fixtures/metamodel-docs/expected/providers.md @@ -29,9 +29,9 @@ Core metaobjects metamodel types and subtypes. - `identity.primary`: `@fields`, `@generation` - `identity.reference`: `@enforce`, `@fields`, `@references` - `identity.secondary`: `@fields` -- `object.entity`: `@discriminator`, `@discriminatorValue` -- `object.projection`: `@filter` -- `object.report`: `@dimensions`, `@filter`, `@from`, `@measures`, `@segment`, `@spine` +- `object.entity`: `@dbSetName`, `@discriminator`, `@discriminatorValue` +- `object.projection`: `@dbSetName`, `@filter` +- `object.report`: `@dbSetName`, `@dimensions`, `@filter`, `@from`, `@measures`, `@segment`, `@spine` - `origin.aggregate`: `@agg`, `@distinct`, `@filter`, `@of`, `@orderBy`, `@via` - `origin.computed`: `@expr` - `origin.first`: `@filter`, `@of`, `@orderBy`, `@via` diff --git a/fixtures/metamodel-docs/expected/types/object.md b/fixtures/metamodel-docs/expected/types/object.md index 7ed952f657..e02f41c316 100644 --- a/fixtures/metamodel-docs/expected/types/object.md +++ b/fixtures/metamodel-docs/expected/types/object.md @@ -43,6 +43,7 @@ An object that owns its data: own identity, writable sources, and lifecycle. The | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@dbSetName` | string | no | | | metaobjects-core-types | C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. | | `@discriminator` | string | no | | | metaobjects-core-types | FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string). | | `@discriminatorValue` | string | no | | | metaobjects-core-types | FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity. | @@ -74,6 +75,7 @@ A derived read-only representation of entities. Its fields are extends-bound / o | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@dbSetName` | string | no | | | metaobjects-core-types | C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. | | `@filter` | filter | no | | | metaobjects-core-types | Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed). | **Allowed children** @@ -97,6 +99,7 @@ A declared report (FR-044): a fixed combination of dimensions and measures of ON | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@dbSetName` | string | no | | | metaobjects-core-types | C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. | | `@dimensions` | string[] | no | | | metaobjects-core-types | Dimension names of @from; a time dimension is written 'name:grain'. Absent means one global row. | | `@filter` | filter | no | | | metaobjects-core-types | Optional row scope over @from's fields; may use relative-date values. | | `@from` | string | yes | | | metaobjects-core-types | The object.entity whose rows the report aggregates. | diff --git a/fixtures/naming-conformance/README.md b/fixtures/naming-conformance/README.md index 073c3678c6..97382e9bde 100644 --- a/fixtures/naming-conformance/README.md +++ b/fixtures/naming-conformance/README.md @@ -84,11 +84,42 @@ physical table name) axis: that rule still always lengthens its input, so it cannot produce this collision — only the API-surface axis can, and only the API-surface axis is checked here. +## Shape-only cases, and names that collide only in one scope + +There is no run-wide "two short names pluralize the same" rule: a name collides only +inside the scope where the generated artifact lives — the DbSet properties of one +generated context, the route segments of one routes entry, the members of one module +or class. An object that emits nothing (abstract, or declaring and inheriting no +`source.rdb`) is in no scope. A layered model's abstract `enterprise::Payer` beside the +application's concrete `core::Payer` is therefore never a collision, and two concrete +`Payer`s in two packages are one only where a port puts both in one scope. + +```jsonc +{ + "shapeOnlyCases": [ + { "shapeOnly": { "package": "enterprise", "name": "Payer", "abstract": true, "source": false }, + "emitting": { "package": "core", "name": "Payer" }, "note": "..." } + ], + "crossPackageCollisionCases": [ + { "entityA": { "package": "enterprise", "name": "Payer" }, + "entityB": { "package": "core", "name": "Payer" }, "collidesOn": "Payers", "note": "..." } + ] +} +``` + +Each port builds both objects in their own packages (each with an `id` primary +identity; `emitting` always declares a source), runs its scoped checks in both +orders, and must ACCEPT every `shapeOnlyCases` entry. A `crossPackageCollisionCases` +pair is REFUSED when the port puts both in one scope, naming the colliding name. +Physical names are not this corpus's concern: `(schema, name)` uniqueness belongs to +the schema layer (`meta migrate`'s `ERR_DUPLICATE_SQL_NAME`). + ## Per-port runner Each port's existing naming unit-test file reads this JSON and asserts both -`cases` columns against its own two pluralizers, and asserts each -`collisionCases` pair is REFUSED by that port's collision check: +`cases` columns against its own two pluralizers, asserts each +`collisionCases` pair (one package, so one scope) is REFUSED by that port's scoped +checks, and asserts each `shapeOnlyCases` pair is ACCEPTED: | Port | Test file | |---|---| diff --git a/fixtures/naming-conformance/already-plural-pluralize.json b/fixtures/naming-conformance/already-plural-pluralize.json index e52a9a434d..6d21b1e152 100644 --- a/fixtures/naming-conformance/already-plural-pluralize.json +++ b/fixtures/naming-conformance/already-plural-pluralize.json @@ -70,5 +70,74 @@ "collidesOn": "api_keys", "note": "route-segment twins — the pluralized PascalCase names differ (ApiKeys / APIKeys) but both snake_case to api_key and serve /api_keys" } + ], + "_shapeOnlyComment": "Expected-PASS cases: a name collides only inside the scope of the artifact that carries it (one DbSet context, one routes entry, one module or class), and an object that emits nothing (abstract, or declaring and inheriting no source.rdb) takes part in no scope, so it never collides. Every port must ACCEPT each case: build `shapeOnly` and `emitting` as objects in their own packages (`abstract` and `source` say whether the shape-only object is abstract and whether it declares a source.rdb; both objects declare an `id` primary identity, and `emitting` always has a source), then run that port's scoped checks over both. `crossPackageCollisionCases`: two EMITTING entities of the same name in two packages collide when the port puts both in ONE scope (one context, one routes entry); in separate scopes they are two names.", + "shapeOnlyCases": [ + { + "shapeOnly": { + "package": "enterprise", + "name": "Payer", + "abstract": true, + "source": false + }, + "emitting": { + "package": "core", + "name": "Payer" + }, + "note": "abstract and sourceless: the shape a layered library declares for the application to extend" + }, + { + "shapeOnly": { + "package": "enterprise", + "name": "Payer", + "abstract": true, + "source": true + }, + "emitting": { + "package": "core", + "name": "Payer" + }, + "note": "abstract with a source: an abstract object never has an instance, so nothing is generated for it" + }, + { + "shapeOnly": { + "package": "enterprise", + "name": "Payer", + "abstract": false, + "source": false + }, + "emitting": { + "package": "core", + "name": "Payer" + }, + "note": "concrete and sourceless (#248): no table, route or finder; the adopter owns its data access" + }, + { + "shapeOnly": { + "package": "enterprise", + "name": "Address", + "abstract": true, + "source": false + }, + "emitting": { + "package": "core", + "name": "Addresses" + }, + "note": "the already-plural pair, with one side shape-only" + } + ], + "crossPackageCollisionCases": [ + { + "entityA": { + "package": "enterprise", + "name": "Payer" + }, + "entityB": { + "package": "core", + "name": "Payer" + }, + "collidesOn": "Payers", + "note": "two emitting entities with one short name in two packages: refused once both land in one scope, accepted in two" + } ] } diff --git a/fixtures/naming-conformance/physical-names/cases.json b/fixtures/naming-conformance/physical-names/cases.json index 4a8444d1d5..7f3256c749 100644 --- a/fixtures/naming-conformance/physical-names/cases.json +++ b/fixtures/naming-conformance/physical-names/cases.json @@ -1,5 +1,9 @@ { - "_comment": "Cross-port physical naming (ADR-0058). Each case is the `naming` block of a project's .metaobjects/config.json. A port parses it with the reader it uses for that file, loads meta.naming.json, and resolves every table and column below through the resolver its codegen, migrate (TypeScript), runtime and verify use. `tables` keys are object names; `columns` keys are `.`. Overrides (@table, a source's structural name, @column) win under every rule; the entity-name fallback keeps the frozen legacy pluralizer under every rule. `invalid` blocks must be refused when the config is read. `upperFirst` upper-cases the field name's first letter and changes nothing else (`requestURL` → `RequestURL`). A key `..` names a member of a `@storage: flattened` value object: its column is the flattened prefix (`@flattenedColumnPrefix` on the embedding field, verbatim, `\"\"` allowed; else that field's resolved column and `_`) followed by the member's resolved column; a prefix inherited through `extends` counts.", + "_comment": "Cross-port physical naming (ADR-0058). Each case is the `naming` block of a project's .metaobjects/config.json. A port parses it with the reader it uses for that file, loads meta.naming.json, and resolves every table and column below through the resolver its codegen, migrate (TypeScript), runtime and verify use. `tables` keys are object names; `columns` keys are `.`. Overrides (@table, a source's structural name, @column) win under every rule; the entity-name fallback keeps the frozen legacy pluralizer under every rule. `invalid` blocks must be refused when the config is read. `pascal` upper-cases the field name's first letter and changes nothing else (`requestURL` → `RequestURL`, `ssn` → `Ssn`); an acronym `@column` (`NPI`) stays as written. A key `..` names a member of a `@storage: flattened` value object: its column is the flattened prefix (`@flattenedColumnPrefix` on the embedding field, verbatim, `\"\"` allowed; else that field's resolved column and `_`) followed by the member's resolved column; a prefix inherited through `extends` counts. `strategies` are application-registered naming strategies: before reading any block, a port registers each one through its own extension point (the registry an application uses), as a function of the bare name: a name in `upperCase` gives that name upper-cased (ASCII), any other name gives `prefix` (absent: \"\") followed by the `delegate` built-in's result. `for` is the setting it may be named in (`columns` or `tables`). A config naming a strategy no registered strategy answers, built in or registered, is refused when it is read, and a block with `messageIncludes` must be refused with a message containing each string; the message also names the port.", + "strategies": [ + { "name": "pascal-acronyms", "for": "columns", "delegate": "pascal", "upperCase": ["id", "ssn"] }, + { "name": "tbl-snake", "for": "tables", "delegate": "snake_case", "prefix": "tbl_" } + ], "cases": [ { "name": "default", @@ -7,7 +11,7 @@ "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests", "Shipment": "shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", - "Person.firstName": "first_name", "Person.lastName": "family_name", + "Person.firstName": "first_name", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "ssn", "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url", @@ -20,7 +24,7 @@ "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests", "Shipment": "shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", - "Person.firstName": "first_name", "Person.lastName": "family_name", + "Person.firstName": "first_name", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "ssn", "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url", @@ -33,7 +37,7 @@ "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests", "Shipment": "shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "createdAt", "OrderItem.unitPriceCents": "unitPriceCents", "OrderItem.sku": "SKU_CODE", - "Person.firstName": "firstName", "Person.lastName": "family_name", + "Person.firstName": "firstName", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "ssn", "AuditEntry.eventType": "eventType", "Category.displayName": "displayName", "HTTPRequest.requestURL": "requestURL", @@ -46,7 +50,7 @@ "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests", "Shipment": "Shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", - "Person.firstName": "first_name", "Person.lastName": "family_name", + "Person.firstName": "first_name", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "ssn", "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url", @@ -59,7 +63,7 @@ "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests", "Shipment": "Shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created-at", "OrderItem.unitPriceCents": "unit-price-cents", "OrderItem.sku": "SKU_CODE", - "Person.firstName": "first-name", "Person.lastName": "family_name", + "Person.firstName": "first-name", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "ssn", "AuditEntry.eventType": "event-type", "Category.displayName": "display-name", "HTTPRequest.requestURL": "request-url", @@ -67,24 +71,40 @@ } }, { - "name": "upperFirst columns, literal tables", - "naming": { "columns": "upperFirst", "tables": "literal" }, + "name": "pascal columns, literal tables", + "naming": { "columns": "pascal", "tables": "literal" }, "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests", "Shipment": "Shipments" }, "columns": { "OrderItem.id": "Id", "OrderItem.createdAt": "CreatedAt", "OrderItem.unitPriceCents": "UnitPriceCents", "OrderItem.sku": "SKU_CODE", - "Person.firstName": "FirstName", "Person.lastName": "family_name", + "Person.firstName": "FirstName", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "Ssn", "AuditEntry.eventType": "EventType", "Category.displayName": "DisplayName", "HTTPRequest.requestURL": "RequestURL", "Shipment.id": "Id", "Shipment.shipTo.street": "ShipTo_Street", "Shipment.shipTo.postalCode": "ShipTo_PostalCode", "Shipment.shipTo.country": "ShipTo_CTRY", "Shipment.billTo.postalCode": "bill_PostalCode", "Shipment.returnTo.postalCode": "PostalCode", "Shipment.giftTo.postalCode": "g_PostalCode", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_PostalCode" } + }, + { + "name": "app-registered column and table strategies", + "naming": { "columns": "pascal-acronyms", "tables": "tbl-snake" }, + "tables": { "OrderItem": "tbl_order_items", "Person": "people", "AuditEntry": "tbl_audit_log", "Category": "tbl_categories", "HTTPRequest": "tbl_http_requests", "Shipment": "tbl_shipments" }, + "columns": { + "OrderItem.id": "ID", "OrderItem.createdAt": "CreatedAt", "OrderItem.unitPriceCents": "UnitPriceCents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "FirstName", "Person.lastName": "family_name", "Person.npi": "NPI", "Person.ssn": "SSN", + "AuditEntry.eventType": "EventType", + "Category.displayName": "DisplayName", + "HTTPRequest.requestURL": "RequestURL", + "Shipment.id": "ID", "Shipment.shipTo.street": "ShipTo_Street", "Shipment.shipTo.postalCode": "ShipTo_PostalCode", "Shipment.shipTo.country": "ShipTo_CTRY", "Shipment.billTo.postalCode": "bill_PostalCode", "Shipment.returnTo.postalCode": "PostalCode", "Shipment.giftTo.postalCode": "g_PostalCode", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_PostalCode" + } } ], "invalid": [ { "name": "unknown column rule", "naming": { "columns": "camelCase" } }, { "name": "kebab-case is not a table rule", "naming": { "tables": "kebab-case" } }, - { "name": "upperFirst is not a table rule", "naming": { "tables": "upperFirst" } }, + { "name": "pascal is not a table rule", "naming": { "tables": "pascal" } }, { "name": "unknown key", "naming": { "column": "literal" } }, - { "name": "not an object", "naming": "snake_case"} + { "name": "not an object", "naming": "snake_case"}, + { "name": "a column strategy nothing registered", "naming": { "columns": "pascal-acronym" }, "messageIncludes": ["naming.columns", "\"pascal-acronym\"", "pascal-acronyms"] }, + { "name": "a table strategy nothing registered", "naming": { "tables": "tbl_snake" }, "messageIncludes": ["naming.tables", "\"tbl_snake\"", "tbl-snake"] }, + { "name": "an app column strategy is not a table rule", "naming": { "tables": "pascal-acronyms" }, "messageIncludes": ["naming.tables", "\"pascal-acronyms\""] } ] } diff --git a/fixtures/naming-conformance/physical-names/meta.naming.json b/fixtures/naming-conformance/physical-names/meta.naming.json index f3e394ca94..e1608864be 100644 --- a/fixtures/naming-conformance/physical-names/meta.naming.json +++ b/fixtures/naming-conformance/physical-names/meta.naming.json @@ -23,6 +23,8 @@ { "field.long": { "name": "id" } }, { "field.string": { "name": "firstName" } }, { "field.string": { "name": "lastName", "@column": "family_name" } }, + { "field.string": { "name": "npi", "@column": "NPI" } }, + { "field.string": { "name": "ssn" } }, { "identity.primary": { "name": "id", "@fields": "id" } } ] } diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index 1c5c7b2780..9784098268 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -30,7 +30,8 @@ fixtures/persistence-conformance/ ├── canonical/ # SHARED "kitchen-sink" metadata + committed schema DDL │ ├── meta.*.json # the kitchen-sink metadata every query scenario reads │ ├── schema.postgres.sql # TS-produced canonical DDL every port executes to set up its DB -│ ├── schema.sqlite.sql # the same for SQLite/D1 (`meta migrate --dialect sqlite`); report scenarios only +│ ├── schema.sqlite.sql # the same for SQLite/D1 (`meta migrate --dialect sqlite`); report scenarios only; +│ │ # leaves out objects with a source `@schema` (SQLite has no schemas) │ └── schema.mysql.sql # the same for MySQL 8.4 (adopter tables + `buildReportViews`); report scenarios only ├── migrations/ # per-scenario schema-evolution tests (TS-only) │ └── .yaml @@ -198,6 +199,18 @@ trailing fraction zeros, zero as `"0"` (`^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`). | `report-decimal-exact` | postgres, mysql | the `EntryTotals` view over the seed: a sum is `"12345678901234567.9"`, an empty sum is `null`, a `@default: 0` sum is `"0"`, a zero-denominator ratio is `null` | | `report-decimal-exact-15` | all four | the A, D, E, F buckets of the same report, run on SQLite and D1 as well | +### Int-backed enum array scenario + +`IntEnumArray` (`int_enum_arrays`) holds a scalar `state` and an array `history`, both extending +the shared abstract enum `PublishState`, whose `@intValueMap` (DRAFT=0, PUBLISHED=5, ARCHIVED=9) +they inherit. The array column is `integer[]` on Postgres, a JSON array of numbers in `TEXT` on +SQLite and D1, and a `JSON` array of numbers on MySQL. It carries no CHECK (membership of an enum +array is app-level); the scalar keeps its integer CHECK. + +| Scenario | Engines | What it pins | +|---|---|---| +| `roundtrip-int-enum-array` | all four | seeded raw ints (`[5,0]`, `[]`, null) read back as members in order; an unmapped stored element (42) fails the read; a filter on the scalar that shares the enum; `op: roundtrip` of a two-element, an empty and a null array through the write codec | + A runner holds a decimal to **the type, not the value's shape**: before comparing it, every field the model declares `decimal` (an entity field, or a report field from `report-shapes.json`) must be `null` or a string in the canonical spelling, or the scenario fails with "decimal arrived as a diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index cd2aa0a35c..4dc7e65449 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -517,6 +517,35 @@ "@measures": ["entries", "total", "totalOrZero", "mean", "top", "flaggedShare", "perFlagged"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_entry_totals" } } ] } }, + { "field.enum": { + "name": "PublishState", "abstract": true, + "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"], + "@intValueMap": { "DRAFT": 0, "PUBLISHED": 5, "ARCHIVED": 9 } } }, + { "object.entity": { + "name": "IntEnumArray", + "children": [ + { "source.rdb": { "@table": "int_enum_arrays" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "state", "extends": "PublishState" } }, + { "field.enum": { "name": "history", "extends": "PublishState", "isArray": true } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] } }, + + { "object.entity": { + "name": "LedgerEntry", + "children": [ + { "source.rdb": { "@table": "ledger_entries" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "memo", "@maxLength": 40 } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] } }, + + { "object.entity": { + "name": "ArchivedLedgerEntry", + "children": [ + { "source.rdb": { "@table": "ledger_entries", "@schema": "archive" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "memo", "@maxLength": 40 } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] } }, + { "template.prompt": { "name": "coachNote", "@payloadRef": "ProgramBrief", diff --git a/fixtures/persistence-conformance/canonical/schema.mysql.sql b/fixtures/persistence-conformance/canonical/schema.mysql.sql index 3f2037f6fc..f1eb4337ea 100644 --- a/fixtures/persistence-conformance/canonical/schema.mysql.sql +++ b/fixtures/persistence-conformance/canonical/schema.mysql.sql @@ -107,6 +107,12 @@ CREATE TABLE `all_types` ( `labels` JSON ); +CREATE TABLE `int_enum_arrays` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `state` INT, + `history` JSON +); + CREATE VIEW `v_program_minutes` AS SELECT w.`programId` AS `program`, diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index a5a9bd9317..69674a624c 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -5,6 +5,8 @@ -- Regenerate: `bun run gen:schema` in this package. Drift-checked by -- schema-artifact.test.ts (CI fails if this file diverges from metadata). +CREATE SCHEMA IF NOT EXISTS "archive"; + CREATE TABLE "programs" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "title" VARCHAR(200) NOT NULL, @@ -160,6 +162,26 @@ CREATE TABLE "entries" ( CONSTRAINT "entries_pkey" PRIMARY KEY ("id") ); +CREATE TABLE "int_enum_arrays" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "state" INTEGER, + "history" INTEGER[], + CONSTRAINT "int_enum_arrays_pkey" PRIMARY KEY ("id"), + CONSTRAINT "int_enum_arrays_state_chk" CHECK ("state" IN (0, 5, 9)) +); + +CREATE TABLE "ledger_entries" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "memo" VARCHAR(40), + CONSTRAINT "ledger_entries_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "archive"."ledger_entries" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "memo" VARCHAR(40), + CONSTRAINT "ledger_entries_pkey" PRIMARY KEY ("id") +); + CREATE UNIQUE INDEX "byTitle" ON "programs" ("title"); CREATE INDEX "idx_programs_title_status" ON "programs" ("title", "status"); diff --git a/fixtures/persistence-conformance/canonical/schema.sqlite.sql b/fixtures/persistence-conformance/canonical/schema.sqlite.sql index c61ed55174..c66bf32e10 100644 --- a/fixtures/persistence-conformance/canonical/schema.sqlite.sql +++ b/fixtures/persistence-conformance/canonical/schema.sqlite.sql @@ -158,6 +158,18 @@ CREATE TABLE "entries" ( "flagged" INTEGER NOT NULL ); +CREATE TABLE "int_enum_arrays" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "state" INTEGER, + "history" TEXT, + CONSTRAINT "int_enum_arrays_state_chk" CHECK ("state" IN (0, 5, 9)) +); + +CREATE TABLE "ledger_entries" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "memo" VARCHAR(40) +); + CREATE UNIQUE INDEX "byTitle" ON "programs" ("title"); CREATE INDEX "idx_programs_title_status" ON "programs" ("title", "status"); diff --git a/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml b/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml index 0bf7bec008..5092df00a5 100644 --- a/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml +++ b/fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml @@ -45,6 +45,8 @@ expect: # un-appliable DDL against an integer column, so this pins the lowering. - '"intEnumVal" INTEGER' - 'CHECK ("intEnumVal" IN (0, 5, 9))' + # An int-backed enum ARRAY stores its mapped ints as INTEGER[], with no CHECK. + - '"history" INTEGER[]' - 'CREATE UNIQUE INDEX "byTitle" ON "programs" ("title");' - 'ALTER TABLE "weeks" ADD CONSTRAINT "weeks_programId_fk"' # An identity.reference onto a TPH subtype binds the base's table (a subtype has none). @@ -75,6 +77,11 @@ expect: # gadgets / gadget_events: the report-via-redeclared-field scenario's tables. - { table_name: "gadget_events" } - { table_name: "gadgets" } + # int_enum_arrays: the int-backed enum array (roundtrip-int-enum-array). + - { table_name: "int_enum_arrays" } + # ledger_entries: the default-schema half of schema-qualified-same-name (its twin is in + # the archive schema, which this public-only listing leaves out). + - { table_name: "ledger_entries" } - { table_name: "measurements" } - { table_name: "nodes" } - { table_name: "people" } diff --git a/fixtures/persistence-conformance/migrations/sequence-start-change-keeps-counter.yaml b/fixtures/persistence-conformance/migrations/sequence-start-change-keeps-counter.yaml new file mode 100644 index 0000000000..f817bf16f5 --- /dev/null +++ b/fixtures/persistence-conformance/migrations/sequence-start-change-keeps-counter.yaml @@ -0,0 +1,25 @@ +name: sequence-start-change-keeps-counter +description: | + An existing table's increment key gains `@sequenceStart: 1000`. The migration records the + new start (`ALTER SEQUENCE … START WITH 1000`) and nothing else: it never RESTARTs the + sequence and never moves its current value, because the live rows already hold the ids it + handed out. Every port applies `@sequenceStart` with this one meaning — the start of a + fresh sequence, never a reset of a live one. + + The seed inserts two rows WITHOUT ids, so the sequence itself hands out 1 and 2. After the + up migration the counter still stands at 2 and the next insert takes 3. + +seed-metadata: ./states/program-v1/ +seed-data: | + INSERT INTO "programs" ("title") VALUES ('Foundations'), ('Strength'); + +target-metadata: ./states/program-seq-v2/ + +expect: + blocked: [] + up-contains: + - 'ALTER SEQUENCE "programs_id_seq" START WITH 1000;' + apply-up-then-query: + sql: SELECT nextval('"programs_id_seq"') AS "next" + rows: + - { next: "3" } diff --git a/fixtures/persistence-conformance/migrations/states/program-seq-v2/meta.json b/fixtures/persistence-conformance/migrations/states/program-seq-v2/meta.json new file mode 100644 index 0000000000..b0c51798e2 --- /dev/null +++ b/fixtures/persistence-conformance/migrations/states/program-seq-v2/meta.json @@ -0,0 +1,16 @@ +{ + "metadata.root": { + "package": "fitness", + "children": [ + { "object.entity": { + "name": "Program", + "children": [ + { "source.rdb": { "@table": "programs" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "title", "@required": true, "@maxLength": 200 } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment", "@sequenceStart": 1000 } } + ] + }} + ] + } +} diff --git a/fixtures/persistence-conformance/queries/roundtrip-int-enum-array.yaml b/fixtures/persistence-conformance/queries/roundtrip-int-enum-array.yaml new file mode 100644 index 0000000000..96e75d42dc --- /dev/null +++ b/fixtures/persistence-conformance/queries/roundtrip-int-enum-array.yaml @@ -0,0 +1,89 @@ +name: roundtrip-int-enum-array +description: | + An int-backed field.enum ARRAY (`IntEnumArray.history`) persists each element as + its declared integer and reads each one back as its member. The map is INHERITED + from the shared abstract enum `PublishState` (DRAFT=0, PUBLISHED=5, ARCHIVED=9), + which a scalar column (`state`) on the same entity also uses — the shape adopters + write. The failure this pins is a codec that maps only scalars, so an array + silently stores member SYMBOLS: on Postgres the column is `integer[]`; on SQLite, + D1 and MySQL it is a JSON array of numbers (`[5,0]`), never `["PUBLISHED","DRAFT"]`. + + The seed rows hold RAW integers, written as SQL, so the reads below prove the + decode against stored ints rather than against whatever the port's own write + produced. The query DSL cannot read a column's raw value back, so the write + direction is held by the roundtrips together with those seeded reads: on Postgres + `integer[]` refuses a symbol element outright, and on the JSON-text engines a port + that wrote symbols would need a read that also passes symbols through, which the + seeded reads would not match. + + - an unmapped stored int (42) fails the read, exactly as an unmapped scalar does; + - an empty array is `[]` and a null array is `null` (`[]` ≠ null); + - element order is preserved; + - no CHECK on the array column (membership stays app-level), so the seed with an + unmapped element is accepted by every engine. + + Ids are seeded from 101 so the runtime-generated ids of the roundtrip inserts + (identity / AUTO_INCREMENT / AUTOINCREMENT) never collide with them. +engines: [postgres, mysql, sqlite, d1] +seed-data: | + INSERT INTO "int_enum_arrays" ("id","state","history") VALUES + (101, 5, '{5,0}'), + (102, 0, '{}'), + (103, NULL, NULL), + (104, 5, '{5,42}'); +seed-data-engine: + sqlite: | + INSERT INTO "int_enum_arrays" ("id","state","history") VALUES + (101, 5, '[5,0]'), + (102, 0, '[]'), + (103, NULL, NULL), + (104, 5, '[5,42]'); + mysql: | + INSERT INTO `int_enum_arrays` (`id`,`state`,`history`) VALUES + (101, 5, '[5,0]'), + (102, 0, '[]'), + (103, NULL, NULL), + (104, 5, '[5,42]'); +queries: + # A list (not three gets) so a non-Postgres engine's BIGINT id is mapped onto the wire + # string the same way every other all-engine scenario's rows are. + - name: seeded-ints-read-as-members-in-order + op: list + entity: IntEnumArray + filter: { id: { in: [101, 102, 103] } } + sort: [{ field: id, dir: asc }] + expect: + - { id: "101", state: PUBLISHED, history: [PUBLISHED, DRAFT] } + - { id: "102", state: DRAFT, history: [] } + - { id: "103", state: null, history: null } + + - name: seeded-unmapped-element-fails-the-read + op: get + entity: IntEnumArray + by: { id: 104 } + expect-error: true + + # The scalar column sharing the enum still filters by member (operand mapped to 5). + - name: scalar-sharing-the-enum-filters-by-member + op: count + entity: IntEnumArray + filter: { state: { eq: PUBLISHED } } + expect: 2 + + - name: roundtrip-two-elements + op: roundtrip + entity: IntEnumArray + insert: { state: ARCHIVED, history: [PUBLISHED, DRAFT] } + expect: { state: ARCHIVED, history: [PUBLISHED, DRAFT] } + + - name: roundtrip-empty-array + op: roundtrip + entity: IntEnumArray + insert: { state: DRAFT, history: [] } + expect: { state: DRAFT, history: [] } + + - name: roundtrip-null-array + op: roundtrip + entity: IntEnumArray + insert: { state: null, history: null } + expect: { state: null, history: null } diff --git a/fixtures/persistence-conformance/queries/schema-qualified-same-name.yaml b/fixtures/persistence-conformance/queries/schema-qualified-same-name.yaml new file mode 100644 index 0000000000..fc7fccc403 --- /dev/null +++ b/fixtures/persistence-conformance/queries/schema-qualified-same-name.yaml @@ -0,0 +1,58 @@ +name: schema-qualified-same-name +description: | + Two entities map tables with the same name in different database schemas: + `LedgerEntry` reads `ledger_entries` (the default schema) and `ArchivedLedgerEntry` + reads `archive.ledger_entries` (`source.rdb @schema: archive`). Each must read and + write its own table. The failure this pins is a runtime that drops `@schema` and + addresses the bare table name, so both entities silently read the default schema's + rows — the lists below would then come back identical. + + Postgres only: SQLite and D1 have no schemas, and `meta migrate` refuses a declared + one there. + + Ids are seeded from 101 so the runtime-generated ids of the roundtrip inserts never + collide with them. +engines: [postgres] +seed-data: | + INSERT INTO "ledger_entries" ("id","memo") VALUES + (101, 'current-a'), + (102, 'current-b'); + INSERT INTO "archive"."ledger_entries" ("id","memo") VALUES + (101, 'archived-a'); +queries: + - name: default-schema-table + op: list + entity: LedgerEntry + sort: [{ field: id, dir: asc }] + expect: + - { id: "101", memo: current-a } + - { id: "102", memo: current-b } + + - name: schema-qualified-table + op: list + entity: ArchivedLedgerEntry + sort: [{ field: id, dir: asc }] + expect: + - { id: "101", memo: archived-a } + + - name: get-reads-the-declared-schema + op: get + entity: ArchivedLedgerEntry + by: { id: 101 } + expect: { id: "101", memo: archived-a } + + - name: count-reads-the-declared-schema + op: count + entity: ArchivedLedgerEntry + expect: 1 + + - name: roundtrip-writes-the-declared-schema + op: roundtrip + entity: ArchivedLedgerEntry + insert: { memo: archived-new } + expect: { memo: archived-new } + + - name: roundtrip-leaves-the-default-schema-untouched + op: count + entity: LedgerEntry + expect: 2 diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index da9d8569e9..8d8df3d420 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3482,6 +3482,16 @@ "rules": "object.entity owns data — it declares its own identity, its primary source must be a writable @kind (read-only kinds may appear only in a read role), and it carries lifecycle. A field carrying origin.* is derived ⇒ read-only wherever it lives, including on an entity. Templates (template.*) may be nested so a prompt can be co-located with its owning entity. See ADR-0028 (object taxonomy).", "whenToUse": "The thing owns its own data and lifecycle — a table you create/read/update/delete. The default for any persisted resource.", "attrs": [ + { + "name": "dbSetName", + "valueType": "string", + "isArray": false, + "required": false, + "ports": [ + "csharp" + ], + "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + }, { "name": "discriminator", "valueType": "string", @@ -3584,6 +3594,16 @@ "rules": "object.projection is a derived read-only representation: every field is extends-bound, origin-derived, or self-declared-under-external-assembly, and all are read-only at the subtype level. Identity is optional and, when present, MUST extend an entity identity. Sources are restricted to read-only @kinds. The declared field set IS the exposure — an inclusive list, fail-closed. A projection NEVER declares relationships (derivation is expressed via @via, not a relationship child) and NEVER co-locates templates — hence its child set omits both relationship and template. The assembly origins (origin.aggregate / origin.computed / origin.first) live HERE, not on object.value; a SOURCELESS projection is the legal shape for a wire-assembled read model, and a template-level @payloadRef/@responseRef may target one (#210). See ADR-0028 (object taxonomy, projection).", "whenToUse": "You need a derived, read-only view over entities — a report, summary, or joined/aggregated read model. Declare it (with origin.* children) instead of hand-writing the SELECT/joins; it stays read-only and regenerates.", "attrs": [ + { + "name": "dbSetName", + "valueType": "string", + "isArray": false, + "required": false, + "ports": [ + "csharp" + ], + "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + }, { "name": "filter", "valueType": "filter", @@ -3643,6 +3663,16 @@ "description": "A declared report (FR-044): a fixed combination of dimensions and measures of ONE entity (@from), compiled to a read-only view. One row per distinct dimension tuple; no dimensions means exactly one row. Fields are DERIVED, not declared: one per dimension (a time dimension's field is , e.g. purchasedAtDay) and one per measure. Every measure must belong to @from (two fact tables are two reports). @filter and @segment scope rows before grouping and combine by AND. Read-only: no writes, no get-by-id.", "whenToUse": "Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries.", "attrs": [ + { + "name": "dbSetName", + "valueType": "string", + "isArray": false, + "required": false, + "ports": [ + "csharp" + ], + "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + }, { "name": "dimensions", "valueType": "string", diff --git a/scripts/check-doc-examples.ts b/scripts/check-doc-examples.ts index 239c65f50d..6c341be22d 100644 --- a/scripts/check-doc-examples.ts +++ b/scripts/check-doc-examples.ts @@ -141,7 +141,6 @@ const FAIL_CODES = new Set([ "ERR_UNKNOWN_ATTR", // #337 — @verifiedBy after retirement "ERR_BAD_ATTR_VALUE", // #337/#343 — @status: abandoned "ERR_INVALID_INDEX", // #342 — @fields together with @expr - "ERR_ENUM_INT_VALUE_MAP_ARRAY", // @intValueMap on an @isArray enum "ERR_ENUM_EXTENDS_VALUES_CONFLICT", "ERR_UNKNOWN_TYPE", "ERR_UNKNOWN_SUBTYPE", diff --git a/server/csharp/MetaObjects.Cli.Tests/VerifyAppNamingStrategyTests.cs b/server/csharp/MetaObjects.Cli.Tests/VerifyAppNamingStrategyTests.cs new file mode 100644 index 0000000000..8e206b7f75 --- /dev/null +++ b/server/csharp/MetaObjects.Cli.Tests/VerifyAppNamingStrategyTests.cs @@ -0,0 +1,103 @@ +using Xunit; + +namespace MetaObjects.Cli.Tests; + +/// +/// ADR-0058 Amendment 1 — an application registers its own naming strategy in its codegen runner +/// (codegen/Program.cs), which stock dotnet meta cannot see. dotnet meta verify --codegen +/// hands its gate to that runner, so it must not refuse the strategy's name before the hand-off: +/// stock verify read .metaobjects/config.json first, and a project whose config names an +/// app strategy failed verify while dotnet meta gen succeeded. A run with no hand-off still +/// refuses the unknown name, as the config read always has. +/// +/// The codegen project here is a stand-in that reports its arguments: the point is the hand-off +/// happening, not what the runner does (EjectEndToEndTests runs a real one). One small build. +/// +/// +public sealed class VerifyAppNamingStrategyTests : IDisposable +{ + private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-verify-appnaming-" + Guid.NewGuid().ToString("N")); + + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Patient", "children": [ + { "source.rdb": { "@table": "patients" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "firstName" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + public VerifyAppNamingStrategyTests() + { + Directory.CreateDirectory(Path.Combine(_tmp, "metaobjects")); + Directory.CreateDirectory(Path.Combine(_tmp, ".metaobjects")); + Directory.CreateDirectory(Path.Combine(_tmp, "gen")); + Directory.CreateDirectory(Path.Combine(_tmp, "prompts")); + File.WriteAllText(Path.Combine(_tmp, "metaobjects", "meta.acme.json"), Model); + File.WriteAllText(Path.Combine(_tmp, ".metaobjects", "config.json"), + """{ "schema_version": 1, "naming": { "columns": "acme-app-columns" } }"""); + } + + public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } } + + private void WriteStandInRunner() + { + var codegen = Path.Combine(_tmp, "codegen"); + Directory.CreateDirectory(codegen); + File.WriteAllText(Path.Combine(codegen, "Codegen.csproj"), """ + + + Exe + net8.0 + enable + + + """); + File.WriteAllText(Path.Combine(codegen, "Generators.cs"), "internal static class Generators { }\n"); + File.WriteAllText(Path.Combine(codegen, "Program.cs"), + "Console.WriteLine(\"stand-in runner: \" + string.Join(' ', args));\nreturn 0;\n"); + } + + [Fact] + public void Verify_codegen_hands_off_before_it_reads_an_app_naming_strategy() + { + WriteStandInRunner(); + + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", "--codegen", "--out", "gen"); + + Assert.Contains("stand-in runner: verify --codegen --out", stdout); + Assert.DoesNotContain("acme-app-columns", stderr); + Assert.True(exit == 0, $"exit {exit}\nstdout:\n{stdout}\nstderr:\n{stderr}"); + } + + [Fact] + public void Verify_with_no_hand_off_still_refuses_the_unknown_name() + { + var (exit, stdout, stderr) = CliProcess.Run(_tmp, "verify", "--templates", "--prompts", "prompts"); + + Assert.Equal(2, exit); + Assert.Contains("\"naming.columns\" names \"acme-app-columns\"", stderr); + Assert.DoesNotContain("stand-in runner", stdout); + } + + // The --templates and --db gates run in THIS process after the hand-off, and this process never + // registered the strategy: refused as the config read refuses it (exit 2, the standard message, + // and why), never an unhandled exception from the first name a gate resolves. + [Theory] + [InlineData("--templates", "--prompts", "prompts")] + [InlineData("--db")] + public void A_gate_this_process_runs_refuses_the_app_strategy_cleanly_after_the_hand_off(params string[] gate) + { + WriteStandInRunner(); + + var (exit, stdout, stderr) = CliProcess.Run(_tmp, ["verify", "--codegen", "--out", "gen", .. gate]); + + Assert.Contains("stand-in runner: verify --codegen --out", stdout); + Assert.Equal(2, exit); + Assert.Contains("\"naming.columns\" names \"acme-app-columns\"", stderr); + Assert.Contains("run in this process", stderr); + Assert.DoesNotContain("Unhandled exception", stderr); + } +} diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index cbae532ed8..34b1f78c9c 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -318,13 +318,13 @@ static int RunEject(string[] rest) // // Never exits without a usable result: either hands back the resolved location or // prints a diagnostic and terminates the process. -static ResolvedMetadata ResolveMetadataDirOrExit(string? metadataDir) +static ResolvedMetadata ResolveMetadataDirOrExit(string? metadataDir, bool validateNaming = true) { // The ladder itself lives in MetaObjects.Config.MetadataLocation, shared with an // ejected codegen/Program.cs; this wrapper only turns its refusals into exit 2. try { - return MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory()); + return MetadataLocation.Resolve(metadataDir, Directory.GetCurrentDirectory(), validateNaming); } catch (MetaObjects.MetaModelException e) { @@ -450,7 +450,12 @@ static int RunVerify(string[] rest) // Rung 1 (explicit positional) is honored as-is; an omitted metadataDir // falls back to the port-neutral .metaobjects/config.json ladder. - var resolvedMeta = ResolveMetadataDirOrExit(metadataDir); + // + // ADR-0058 Amendment 1: a `naming` strategy may be one only the project's codegen runner + // registers (codegen/Program.cs), and the --codegen gate below is handed to that runner. So + // the name is not refused here; it is checked after the hand-off, by this process only when + // no runner ran (the runner reads the same file and refuses it there). + var resolvedMeta = ResolveMetadataDirOrExit(metadataDir, validateNaming: false); // --out wins; otherwise the project's declared `csharp.outDir`, the directory gen writes. outDir ??= resolvedMeta.OutDir; @@ -486,6 +491,7 @@ static int RunVerify(string[] rest) // "no --out" usage message unchanged). var codegenHandedOff = false; var codegenHandoffExit = 0; + var ownedRunnerRan = false; var projectRoot = metadataDir is not null ? GenCommand.ProjectRootFor(metadataDir) : Directory.GetCurrentDirectory(); @@ -523,9 +529,26 @@ static int RunVerify(string[] rest) if (templateRoot is not null) codegenArgs.AddRange(["--template-root", templateRoot]); if (lax) codegenArgs.Add("--lax"); codegenHandedOff = CodegenHandoff.TryRun(projectRoot, codegenArgs.ToArray(), out codegenHandoffExit); + ownedRunnerRan = codegenHandedOff; } } + // No runner registered anything for this verify: refuse a naming value nothing in THIS + // process answers, exactly as the config read always has (the same message, exit 2). + if (!ownedRunnerRan) ResolveMetadataDirOrExit(metadataDir); + // A runner ran --codegen with the strategy registered, but --templates and --db run HERE, + // which never registered it: refuse it the same way, saying why, before a gate resolves a name. + else if ((templates || db) + && !(MetaObjects.Meta.NamingStrategies.TryGetColumn(resolvedMeta.Naming.Columns.Name, out _) + && MetaObjects.Meta.NamingStrategies.TryGetTable(resolvedMeta.Naming.Tables.Name, out _))) + { + Console.Error.WriteLine( + "dotnet meta verify: --codegen ran in codegen/Generators.cs's runner, but --templates and --db " + + "run in this process, which has not registered the naming strategy .metaobjects/config.json names. " + + "Run those gates from a host that registers it."); + ResolveMetadataDirOrExit(metadataDir); + } + var opts = new VerifyCommand.Options { MetadataDir = resolvedMeta.Directory, @@ -559,7 +582,18 @@ static int RunVerify(string[] rest) Strict = !lax, }; - var result = VerifyCommand.RunSubverbs(opts); + VerifyCommand.SubverbResult result; + try + { + result = VerifyCommand.RunSubverbs(opts); + } + catch (MetaObjects.MetaModelException e) + { + // A gate resolved a name through a naming strategy this process has not registered (a + // bare verify's templates gate after the hand-off, say): the refusal the config read gives. + Console.Error.WriteLine($"error: {e.Code}: {e.Message}"); + return 2; + } if (result.EmittedDefaultNote) Console.WriteLine(VerifyCommand.SUBVERB_NOTE); diff --git a/server/csharp/MetaObjects.Codegen.Tests/ApiDocsPackageKeyedTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsPackageKeyedTests.cs new file mode 100644 index 0000000000..e7ce9abd37 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsPackageKeyedTests.cs @@ -0,0 +1,64 @@ +// The api reference is keyed by package: a page sits under its package path, so two same-named +// nodes in two packages (a layered model's enterprise::party::Address beside core::common::Address) +// are two pages, and the index tells them apart. Two names in ONE package that differ only in case +// (ProductNdc, ProductNDC) are one file on a case-insensitive filesystem, so they are refused by +// name rather than written as one page that silently replaces the other. + +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public sealed class ApiDocsPackageKeyedTests +{ + private static string Value(string pkg, string name) => $$""" + { "metadata.root": { "package": "{{pkg}}", "children": [ + { "object.value": { "name": "{{name}}", "children": [ { "field.string": { "name": "line1" } } ] } } + ] } } + """; + + private static List Pages(params string[] sources) + { + var load = new MetaDataLoader().Load( + sources.Select((s, i) => (IMetaDataSource)new InMemoryStringSource(s, id: $"src{i}.json")).ToList()); + Assert.Empty(load.Errors); + var ctx = new GenContext + { + Entities = load.Root.Objects(), + Root = load.Root, + Config = new GenConfig { OutDir = "/unused", Namespace = "Acme.Generated" }, + }; + return new ApiDocsGenerator().Generate(ctx).ToList(); + } + + [Fact] + public void Two_same_named_nodes_in_two_packages_are_two_pages_the_index_tells_apart() + { + var pages = Pages(Value("enterprise::party", "Address"), Value("core::common", "Address")); + var paths = pages.Select(p => p.Path).ToList(); + Assert.Contains("api/csharp/enterprise/party/Address.md", paths); + Assert.Contains("api/csharp/core/common/Address.md", paths); + var index = pages.Single(p => p.Path == "api/csharp/README.md").Content; + Assert.Contains("- [Address (core::common)](./core/common/Address.md)", index); + Assert.Contains("- [Address (enterprise::party)](./enterprise/party/Address.md)", index); + } + + [Fact] + public void A_unique_short_name_keeps_its_bare_label() + { + var index = Pages(Value("acme::shop", "Cart")).Single(p => p.Path == "api/csharp/README.md").Content; + Assert.Contains("- [Cart](./acme/shop/Cart.md)", index); + } + + [Fact] + public void Names_that_differ_only_in_case_in_one_package_are_refused() + { + var ex = Assert.Throws( + () => Pages(Value("acme::products", "ProductNdc"), Value("acme::products", "ProductNDC"))); + Assert.Contains("acme::products::ProductNdc", ex.Message); + Assert.Contains("acme::products::ProductNDC", ex.Message); + Assert.Contains("case-insensitive", ex.Message); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs index 0d083ab4b7..96278f7f30 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs @@ -7,6 +7,7 @@ using System.IO; using System.Text.Json; using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; using MetaObjects.Loader; using MetaObjects.Meta; using static MetaObjects.Core.Field.FieldConstants; @@ -108,13 +109,47 @@ public void Pluralize_leaves_an_already_plural_word_unchanged(string input, stri [InlineData("Bonus", "Bonuses")] [InlineData("Alias", "Aliases")] [InlineData("Gas", "Gases")] - // Documented pre-existing imperfection, explicitly out of scope — not "Analyses". - [InlineData("Analysis", "Analysises")] public void Pluralize_keeps_existing_behavior_for_s_u_i_a_plus_s_endings(string input, string expected) { Assert.Equal(expected, CSharpNaming.Pluralize(input)); } + // A Greek "-sis" noun takes "-ses" (Diagnosis -> Diagnoses), not the sibilant "+es" that + // produced "Diagnosises" in every DbSet and finder name. The other common endings are pinned + // beside it so the new rule cannot widen past "-sis". + [Theory] + [InlineData("Diagnosis", "Diagnoses")] + [InlineData("Analysis", "Analyses")] + [InlineData("Basis", "Bases")] + [InlineData("OrderAnalysis", "OrderAnalyses")] + [InlineData("Notification", "Notifications")] + [InlineData("Reservation", "Reservations")] + [InlineData("Category", "Categories")] + [InlineData("Day", "Days")] + [InlineData("Key", "Keys")] + [InlineData("Status", "Statuses")] + [InlineData("Bus", "Buses")] + public void Pluralize_handles_sis_tion_y_and_s_endings(string input, string expected) + { + Assert.Equal(expected, CSharpNaming.Pluralize(input)); + } + + [Fact] + public void DbSetName_and_reverse_finder_name_use_the_sis_plural() + { + Assert.Equal("Diagnoses", CSharpNaming.DbSetName(Entity("Diagnosis"))); + Assert.Equal("FindDiagnosesByPatient", CSharpNaming.ReverseFinderName(Entity("Diagnosis"), "patientId")); + } + + [Fact] + public void RoutePath_keeps_the_cross_port_rule_for_a_sis_noun() + { + // The REST collection segment is a wire contract every port spells the same way + // (fixtures/naming-conformance); the "-sis" rule is a C# identifier fix only, so the + // route does not move until every port moves with it. + Assert.Equal("diagnosises", CSharpNaming.RoutePath(Entity("Diagnosis"))); + } + [Fact] public void Pluralize_documented_known_miss_Lens_reads_as_already_plural() { @@ -188,94 +223,269 @@ public void NamingConformance_apiAndLegacyPluralsMatch(NamingCase c) Assert.Equal(c.LegacyPlural.ToLowerInvariant(), source.PhysicalName); } - // -- AssertNoCollectionNameCollisions -------------------------- + // -- collection names collide only inside the scope that carries them ------------------- + // + // There is no run-wide check: the generated context refuses two DbSets of one name, a routes + // entry two objects that serve one segment. An object that emits nothing never takes part. + // fixtures/naming-conformance/already-plural-pluralize.json drives the shared cases. - private static IReadOnlyList MultiEntityRoot(params (string Name, bool IsValue)[] specs) + private static MetaRoot MultiEntityRoot(params (string Name, bool IsValue)[] specs) { - var children = string.Join(",\n", specs.Select(s => s.IsValue + // Each entity its own table: the pairs below collide on a generated name, not on a relation. + var children = string.Join(",\n", specs.Select((s, i) => s.IsValue ? $@"{{ ""object.value"": {{ ""name"": ""{s.Name}"", ""children"": [ {{ ""field.string"": {{ ""name"": ""text"" }} }} ] }} }}" : $@"{{ ""object.entity"": {{ ""name"": ""{s.Name}"", ""children"": [ - {{ ""source.rdb"": {{ ""@table"": ""{s.Name.ToLowerInvariant()}"" }} }}, + {{ ""source.rdb"": {{ ""@table"": ""{s.Name.ToLowerInvariant()}_{i}"" }} }}, {{ ""field.long"": {{ ""name"": ""id"" }} }}, {{ ""identity.primary"": {{ ""@fields"": ""id"" }} }} ] }} }}")); var json = $@"{{ ""metadata.root"": {{ ""package"": ""acme"", ""children"": [{children}] }} }}"; var r = new MetaDataLoader().Load([new InMemoryStringSource(json, id: "multi.json")]); Assert.Empty(r.Errors); - return r.Root.Objects(); + return r.Root; } - [Fact] - public void AssertNoCollectionNameCollisions_doesNotThrow_forDistinctPlurals() + public sealed record NamedObject(string Package, string Name, bool Abstract, bool Source, string? Schema = null, string? DbSetName = null); + + private static JsonElement NamingCorpus(string section) { - var entities = MultiEntityRoot(("Post", false), ("Author", false), ("Category", false)); - CSharpNaming.AssertNoCollectionNameCollisions(entities); // does not throw + var path = Path.Combine( + CorpusPaths.RepoRoot(), "fixtures", "naming-conformance", "already-plural-pluralize.json"); + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.GetProperty(section).Clone(); } - [Fact] - public void AssertNoCollectionNameCollisions_throws_forAddressAndAddresses() + private static NamedObject ReadObject(JsonElement o) => new( + o.GetProperty("package").GetString()!, + o.GetProperty("name").GetString()!, + o.TryGetProperty("abstract", out var a) && a.GetBoolean(), + !o.TryGetProperty("source", out var s) || s.GetBoolean()); + + // Each object in its own package (one source file per package), declaring an `id` key. + private static MetaRoot PackagedObjects(params NamedObject[] objects) { - var entities = MultiEntityRoot(("Address", false), ("Addresses", false)); - var ex = Assert.Throws( - () => CSharpNaming.AssertNoCollectionNameCollisions(entities)); - Assert.Contains("Address", ex.Message); - Assert.Contains("Addresses", ex.Message); + var sources = objects.Select((o, i) => + { + var objAttrs = (o.Abstract ? "\"abstract\": true, " : "") + (o.DbSetName is { } d ? $"\"@dbSetName\": \"{d}\", " : ""); + var schema = o.Schema is { } sc ? $@", ""@schema"": ""{sc}""" : ""; + var source = o.Source + ? $@"{{ ""source.rdb"": {{ ""@table"": ""{o.Name.ToLowerInvariant()}s""{schema} }} }}," + : ""; + var json = $@"{{ ""metadata.root"": {{ ""package"": ""{o.Package}"", ""children"": [ + {{ ""object.entity"": {{ ""name"": ""{o.Name}"", {objAttrs}""children"": [ + {source} + {{ ""field.long"": {{ ""name"": ""id"" }} }}, + {{ ""identity.primary"": {{ ""@fields"": ""id"" }} }} + ] }} }} + ] }} }}"; + return (IMetaDataSource)new InMemoryStringSource(json, id: $"{o.Package}-{i}.json"); + }).ToList(); + var r = new MetaDataLoader().Load(sources); + Assert.Empty(r.Errors.Select(e => e.Code + ": " + e.Message)); + return r.Root; } + private static GenContext Ctx(MetaRoot root) => new() + { + Entities = root.Objects(), + Root = root, + Config = new GenConfig + { + OutDir = "/unused", Namespace = "Acme.Generated", + PackageNamespaces = new() { ["enterprise"] = "Acme.Enterprise", ["core"] = "Acme.Core" }, + }, + }; + + private static List Run(IGenerator g, MetaRoot root) => g.Generate(Ctx(root)).ToList(); + + private static Func InPackage(string pkg) => + o => o.ResolutionKey().StartsWith(pkg + "::", StringComparison.Ordinal); + [Fact] - public void AssertNoCollectionNameCollisions_throws_forOrderAndOrders() + public void Distinct_plurals_share_one_context_and_one_routes_entry() { - var entities = MultiEntityRoot(("Order", false), ("Orders", false)); - Assert.Throws( - () => CSharpNaming.AssertNoCollectionNameCollisions(entities)); + var root = MultiEntityRoot(("Post", false), ("Author", false), ("Category", false)); + Run(new DbContextGenerator(), root); + Run(new RoutesGenerator(), root); } [Fact] - public void AssertNoCollectionNameCollisions_excludesValueObjects() + public void A_value_object_named_like_an_entitys_plural_claims_no_DbSet() { - // "Address" (entity) legacy-pluralizes to "Addresses"; an unrelated object.value - // named "Addresses" never gets a DbSet/route/finder, so it must not trip this. - var entities = MultiEntityRoot(("Address", false), ("Addresses", true)); - CSharpNaming.AssertNoCollectionNameCollisions(entities); // does not throw + var root = MultiEntityRoot(("Address", false), ("Addresses", true)); + Run(new DbContextGenerator(), root); + Run(new RoutesGenerator(), root); } - public sealed record CollisionCase(string EntityA, string EntityB); + public sealed record CollisionCase(string EntityA, string EntityB, string CollidesOn); public static TheoryData NamingConformanceCollisionCases() { - var path = Path.Combine( - CorpusPaths.RepoRoot(), "fixtures", "naming-conformance", "already-plural-pluralize.json"); - using var doc = JsonDocument.Parse(File.ReadAllText(path)); var data = new TheoryData(); - foreach (var c in doc.RootElement.GetProperty("collisionCases").EnumerateArray()) - { - data.Add(new CollisionCase( - c.GetProperty("entityA").GetString()!, - c.GetProperty("entityB").GetString()!)); - } + foreach (var c in NamingCorpus("collisionCases").EnumerateArray()) + data.Add(new CollisionCase(c.GetProperty("entityA").GetString()!, c.GetProperty("entityB").GetString()!, + c.GetProperty("collidesOn").GetString()!)); return data; } [Theory] [MemberData(nameof(NamingConformanceCollisionCases))] - public void NamingConformance_collisionCasesAreRefused(CollisionCase c) + public void NamingConformance_collisionCases_are_refused_in_the_scope_they_share(CollisionCase c) + { + // One package, so one context and one routes entry: each pair collides on its DbSet + // name (Address/Addresses) or its route segment (ApiKey/APIKey). + var root = MultiEntityRoot((c.EntityA, false), (c.EntityB, false)); + var ex = Record.Exception(() => { Run(new DbContextGenerator(), root); Run(new RoutesGenerator(), root); }); + var refusal = Assert.IsType(ex); + Assert.Contains($"\"{c.CollidesOn}\"", refusal.Message); + } + + public static TheoryData ShapeOnlyCaseNotes() { - var entities = MultiEntityRoot((c.EntityA, false), (c.EntityB, false)); - Assert.Throws( - () => CSharpNaming.AssertNoCollectionNameCollisions(entities)); + var data = new TheoryData(); + foreach (var c in NamingCorpus("shapeOnlyCases").EnumerateArray()) + data.Add(c.GetProperty("note").GetString()!); + return data; + } + + [Theory] + [MemberData(nameof(ShapeOnlyCaseNotes))] + public void NamingConformance_an_object_that_emits_nothing_claims_no_name(string note) + { + var c = NamingCorpus("shapeOnlyCases").EnumerateArray().Single(x => x.GetProperty("note").GetString() == note); + foreach (var root in new[] { + PackagedObjects(ReadObject(c.GetProperty("shapeOnly")), ReadObject(c.GetProperty("emitting"))), + PackagedObjects(ReadObject(c.GetProperty("emitting")), ReadObject(c.GetProperty("shapeOnly"))) }) + { + Run(new DbContextGenerator(), root); + Run(new RoutesGenerator(), root); + } + } + + [Fact] + public void NamingConformance_two_emitting_entities_in_one_context_are_refused_by_DbSet_name() + { + foreach (var c in NamingCorpus("crossPackageCollisionCases").EnumerateArray()) + { + var root = PackagedObjects(ReadObject(c.GetProperty("entityA")), ReadObject(c.GetProperty("entityB"))); + var ex = Assert.Throws(() => Run(new DbContextGenerator(), root)); + Assert.Contains($"DbSet property \"{c.GetProperty("collidesOn").GetString()}\"", ex.Message); + Assert.Contains("@dbSetName", ex.Message); + } + } + + [Fact] + public void Two_emitting_entities_in_one_routes_entry_are_refused_by_segment() + { + var root = PackagedObjects(new("enterprise", "Payer", false, true, "enterprise"), new("core", "Payer", false, true, "core")); + var ex = Assert.Throws(() => Run(new RoutesGenerator(), root)); + Assert.Contains("REST collection segment \"payers\"", ex.Message); } [Fact] - public void AssertNoCollectionNameCollisions_throws_forRouteSegmentTwins() - { - // ApiKey / APIKey pluralize to different names but both snake_case to /api_keys. - var entities = MultiEntityRoot(("ApiKey", false), ("APIKey", false)); - var ex = Assert.Throws( - () => CSharpNaming.AssertNoCollectionNameCollisions(entities)); - Assert.Contains("\"ApiKey\"", ex.Message); - Assert.Contains("\"APIKey\"", ex.Message); - Assert.Contains("api_keys", ex.Message); + public void One_name_in_two_packages_two_schemas_two_contexts_and_two_route_entries_passes_schema_qualified() + { + var root = PackagedObjects(new("enterprise", "Payer", false, true, "enterprise"), new("core", "Payer", false, true, "core")); + foreach (var pkg in new[] { "enterprise", "core" }) + { + var context = Assert.Single(Run(new DbContextGenerator { Include = InPackage(pkg) }, root)).Content; + Assert.Contains("DbSet Payers", context); + Assert.NotEmpty(Run(new RoutesGenerator { Include = InPackage(pkg) }, root)); + } + var entities = Run(new EntityGenerator(), root).Select(f => f.Content).ToList(); + Assert.Contains(entities, c => c.Contains("[Table(\"payers\", Schema = \"enterprise\")]") && c.Contains("namespace Acme.Enterprise")); + Assert.Contains(entities, c => c.Contains("[Table(\"payers\", Schema = \"core\")]") && c.Contains("namespace Acme.Core")); + } + + [Fact] + public void DbSetName_names_the_property_and_resolves_a_same_context_collision() + { + var root = PackagedObjects( + new("enterprise", "Payer", false, true, "enterprise", DbSetName: "EnterprisePayers"), + new("core", "Payer", false, true, "core")); + var context = Assert.Single(Run(new DbContextGenerator(), root)).Content; + Assert.Contains("EnterprisePayers { get; set; }", context); + Assert.Contains("Payers { get; set; }", context); + } + + // A @dbSetName that is no C# identifier, or names a DbContext member, would fail at compile + // time (or hide Database / Model); codegen refuses it by name. + [Theory] + [InlineData("Payer Rows")] + [InlineData("1Payers")] + [InlineData("Database")] + [InlineData("Model")] + public void A_dbSetName_that_cannot_be_the_property_is_refused_at_codegen(string bad) + { + var root = PackagedObjects(new NamedObject("core", "Payer", false, true, DbSetName: bad)); + var ex = Assert.Throws(() => Run(new DbContextGenerator(), root)); + Assert.Contains("@dbSetName", ex.Message); + Assert.Contains($"\"{bad}\"", ex.Message); + } + + // One context, one relation per (schema, name). Two objects that map to one + // table would silently share it; the DbSet rule no longer catches them once @dbSetName splits + // the property names. + [Theory] + [InlineData("billing")] + [InlineData(null)] + public void Two_objects_mapping_one_relation_in_one_context_are_refused_naming_both(string? schema) + { + var root = PackagedObjects( + new("enterprise", "Payer", false, true, schema, DbSetName: "EnterprisePayers"), + new("core", "Payer", false, true, schema)); + var ex = Assert.Throws(() => Run(new DbContextGenerator(), root)); + Assert.Contains("ERR_DUPLICATE_SQL_NAME", ex.Message); + Assert.Contains("\"enterprise::Payer\"", ex.Message); + Assert.Contains("\"core::Payer\"", ex.Message); + Assert.Contains(schema is null ? "\"payers\"" : "\"billing\".\"payers\"", ex.Message); + } + + [Fact] + public void One_table_name_in_two_schemas_in_one_context_passes_schema_qualified() + { + var root = PackagedObjects( + new("enterprise", "Payer", false, true, "enterprise", DbSetName: "EnterprisePayers"), + new("core", "Payer", false, true, "core")); + Assert.Single(Run(new DbContextGenerator(), root)); + var entities = Run(new EntityGenerator(), root).Select(f => f.Content).ToList(); + Assert.Contains(entities, c => c.Contains("[Table(\"payers\", Schema = \"enterprise\")]")); + Assert.Contains(entities, c => c.Contains("[Table(\"payers\", Schema = \"core\")]")); + } + + [Fact] + public void An_abstract_sourceless_shape_beside_a_concrete_entity_runs_end_to_end() + { + // The layered-model shape: enterprise::Payer is abstract and sourceless, core::Payer + // concrete. Through the real entry point, with the stock generators. + var root = PackagedObjects(new("enterprise", "Payer", true, false), new("core", "Payer", false, true, "core")); + var config = new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "mo-" + Guid.NewGuid().ToString("N")), Namespace = "Acme.Generated" }; + try + { + CodegenRunner.Run(config, root, [new EntityGenerator(), new DbContextGenerator(), new RoutesGenerator(), new NamesGenerator()]); + } + finally + { + if (Directory.Exists(config.OutDir)) Directory.Delete(config.OutDir, recursive: true); + } + } + + [Fact] + public void A_view_mapping_carries_its_schema() + { + const string json = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.projection": { "name": "PayerSummary", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_payer_summary", "@schema": "reporting" } }, + { "field.long": { "name": "id" } } + ] } } + ] } } + """; + var r = new MetaDataLoader().Load([new InMemoryStringSource(json, id: "view.json")]); + Assert.Empty(r.Errors); + var context = Assert.Single(Run(new DbContextGenerator(), r.Root)).Content; + Assert.Contains(".ToView(\"v_payer_summary\", \"reporting\")", context); } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs index 0d83f5a5ec..de5e3f9867 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs @@ -219,6 +219,39 @@ public void Generated_AppDbContext_and_entities_compile_against_EF_Core_8() Assert.Contains("[Column(OrderNames.StatusColumn)]", orderSrc); } + // A large model's configuration is split into ordered methods: one OnModelCreating holding every + // line has a stack frame too big for a thread-pool thread in a Debug build (each converter lambda is + // an expression tree built from locals), and a test host overflows its stack building the model. + [Fact] + public void A_large_model_config_is_split_into_ordered_methods_that_compile() + { + var ctx = Ctx(Load()); + var whole = Assert.Single(new DbContextGenerator().Generate(ctx)).Content; + Assert.DoesNotContain("ConfigureModel01", whole); + + var split = Assert.Single(new DbContextGenerator { ModelConfigChunkSize = 3 }.Generate(ctx)).Content; + Assert.Contains(" ConfigureModel01(modelBuilder);\n ConfigureModel02(modelBuilder);", split); + Assert.Contains(" private void ConfigureModel01(ModelBuilder modelBuilder)", split); + + // The same lines, in the same order, end up in the methods. + static List ConfigLines(string src) => src.Split('\n') + .Where(l => l.StartsWith(" modelBuilder.", StringComparison.Ordinal)) + .ToList(); + Assert.Equal(ConfigLines(whole), ConfigLines(split)); + + var sources = new EntityGenerator().Generate(ctx) + .Concat(new NamesGenerator().Generate(ctx)) + .Select(f => f.Content) + .Append(split); + var comp = CSharpCompilation.Create( + "efcore_split_" + Guid.NewGuid().ToString("N"), + sources.Select(c => CSharpSyntaxTree.ParseText(c, new CSharpParseOptions(LanguageVersion.CSharp12))), + BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + } + // The map's value object is serialized by the MapJsonb helper's shared JsonSerializerOptions, // and System.Text.Json writes enums as NUMBERS by default — so a field.enum member of the // map's @objectRef value object persisted as its ORDINAL, while the same member of the same diff --git a/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs index 18bceea325..b8bbc191fd 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs @@ -16,7 +16,7 @@ // collision in its own right: Pluralize("Addresses") == "Addresses" too (it is already- // plural, so it is left unchanged), so the "Addresses" entity's OWN DbSet would collide with // the "Address" entity's DbSet (CS0102 duplicate member) — a SEPARATE defect from the nameof -// hazard above, caught earlier and refused outright by `CSharpNaming.AssertNoCollectionNameCollisions` +// hazard above, caught earlier and refused by the generated context's DbSet-name check (`CSharpNaming.AssertUniqueInScope`) // (called from `CodegenRunner.Run`, see `Address_and_Addresses_is_refused_as_a_collection_name_collision` // below) before `DbContextGenerator` ever runs. `Fk_property_is_named_by_typed_lambda_not_nameof` // below still exercises `DbContextGenerator` directly (bypassing that runner-level gate, as an @@ -49,7 +49,7 @@ public sealed class DbContextForeignKeyConfigTests /// `Address` + `Addresses`, both persisted, with `Addresses` carrying the reference. /// Pluralize("Address") == "Addresses", so the context declares `DbSet<Address> Addresses` /// — the exact identifier the FK line for entity `Addresses` has to name. This model is - /// ALSO now refused outright by `CSharpNaming.AssertNoCollectionNameCollisions` (see the + /// ALSO now refused by the generated context's DbSet-name check (`CSharpNaming.AssertUniqueInScope`) (see the /// class-level comment above) — only `Fk_property_is_named_by_typed_lambda_not_nameof` /// below still exercises it, by calling `DbContextGenerator` directly rather than through /// the runner-level gate. @@ -138,10 +138,12 @@ private static MetaRoot Load(string model) [Fact] public void Fk_property_is_named_by_typed_lambda_not_nameof() { - // Calls DbContextGenerator directly — bypassing CodegenRunner's collision gate, - // as an ejected/embedded generator legitimately can (ADR-0034) — to pin the - // nameof-vs-lambda fix on its own merits, independent of the gate. - var src = Assert.Single(new DbContextGenerator().Generate(Ctx(Load(DbSetNameCollisionModel)))).Content; + // The pair shares one context only once @dbSetName gives Address another DbSet; the + // entity type Addresses still meets a DbSet property of its own name, which is the + // hazard the typed lambda pins. + var renamed = DbSetNameCollisionModel.Replace( + "\"name\": \"Address\", \"children\"", "\"name\": \"Address\", \"@dbSetName\": \"AddressRows\", \"children\""); + var src = Assert.Single(new DbContextGenerator().Generate(Ctx(Load(renamed)))).Content; Assert.Contains( "modelBuilder.Entity().HasOne
().WithMany()" @@ -161,21 +163,18 @@ public void Composite_fk_is_named_by_an_anonymous_type_lambda() [Fact] public void Address_and_Addresses_is_refused_as_a_collection_name_collision() { - // The expected-error replacement for the old "...still_compiles" test: this - // model never reaches DbContextGenerator in real usage any more — it is - // refused at the CodegenRunner choke point first. - var root = Load(DbSetNameCollisionModel); + // The expected-error replacement for the old "...still_compiles" test: the two + // land in one context, which refuses the DbSet name they share. var ex = Assert.Throws( - () => CSharpNaming.AssertNoCollectionNameCollisions(root.Objects())); + () => new DbContextGenerator().Generate(Ctx(Load(DbSetNameCollisionModel))).ToList()); Assert.Contains("Address", ex.Message); - Assert.Contains("Addresses", ex.Message); + Assert.Contains("DbSet property \"Addresses\"", ex.Message); } [Fact] - public void CodegenRunner_refuses_the_Address_and_Addresses_model_before_any_generator_runs() + public void CodegenRunner_refuses_the_Address_and_Addresses_model() { - // Integration-level: proves the gate is actually WIRED into the real entry - // point (CodegenRunner.Run), not just callable as a standalone function. + // Integration-level: the context's check runs on the real entry point (CodegenRunner.Run). var root = Load(DbSetNameCollisionModel); var config = new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "mo-" + Guid.NewGuid().ToString("N")), Namespace = "Acme.Generated" }; Assert.Throws( diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorGoldenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorGoldenTests.cs new file mode 100644 index 0000000000..a625d9e131 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorGoldenTests.cs @@ -0,0 +1,225 @@ +// GeneratorGoldenTests — the output of DbContextGenerator and EntityGenerator (and NamesGenerator, +// round 2) for models that exercise every kind of field-type configuration, compared byte for byte +// against committed files. The fine-grained `protected virtual` hooks must not move a single line +// of default output; a refactor that did would fail here, not in an adopter's diff. +// +// How the files were established: this test was run with GOLDEN_WRITE=1 against the generators as +// they were before the hooks existed (no hooks at all), and the hooked generators' output was +// diffed against that capture. Every line that differs comes from one of these fixes, each its own +// CHANGELOG entry, and from nothing else: +// - a literal @default is HasDefaultValue(v).HasSentinel(v) (Auth.Active, BridgeAuth.Quantity); +// - a TPH subtype's own fields get their EF configuration (BridgeAuth's precision, durations, +// primitive collection); +// - a reference declared on an abstract TPH intermediate is configured on each concrete subtype +// (CallJob, MailJob; CaseJob when abstract shapes are emitted); +// - a Names whose base lives in another namespace imports it (`using Acme.Base;`); +// - under EmitAbstractShapes the DbContext configures the key and columns a concrete entity +// inherits from its shape (Workflow). +// The committed files are the hooked output, so they are that capture plus exactly those lines. +// +// Recapture only for a deliberate change to default output: GOLDEN_WRITE=1 dotnet test --filter +// GeneratorGoldenTests, then review the diff line by line. Compare mode is the default. + +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class GeneratorGoldenTests +{ + // A scalar duration declared BEFORE an array duration, then decimal/timestamp/uri/inet/map/enum + // array fields, a TPH hierarchy, flattened and jsonb value objects, a self-reference and a + // write-through entity (Invoice) with its InvoiceView read model. + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "street", "@required": true, "@maxLength": 120 } }, + { "field.enum": { "name": "kind", "@values": ["HOME", "WORK"] } }, + { "field.duration": { "name": "window" } }, + { "field.map": { "name": "hints", "@valueType": "string" } } + ]}}, + { "object.entity": { "name": "Auth", "@discriminator": "type", "children": [ + { "source.rdb": { "@table": "auths" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "type", "@values": ["Bridge", "Plain"] } }, + { "field.duration": { "name": "ttl" } }, + { "field.duration": { "name": "slots", "isArray": true } }, + { "field.decimal": { "name": "amount", "@precision": 10, "@scale": 2 } }, + { "field.timestamp": { "name": "createdAt" } }, + { "field.uri": { "name": "homepage" } }, + { "field.inet": { "name": "addr" } }, + { "field.string": { "name": "tags", "isArray": true } }, + { "field.enum": { "name": "statuses", "isArray": true, "@values": ["DRAFT", "LIVE"] } }, + { "field.object": { "name": "home", "@objectRef": "Address", "@storage": "flattened" } }, + { "field.object": { "name": "config", "@objectRef": "Address", "@storage": "jsonb" } }, + { "field.map": { "name": "labels", "@valueType": "string" } }, + { "field.boolean": { "name": "active", "@default": true } }, + { "field.long": { "name": "parentId" } }, + { "identity.primary": { "@fields": "id" } }, + { "identity.reference": { "name": "refParent", "@fields": "parentId", "@references": "Auth" } } + ]}}, + { "object.entity": { "name": "BridgeAuth", "extends": "Auth", "@discriminatorValue": "Bridge", "children": [ + { "field.int": { "name": "quantity", "@required": true, "@default": 3 } }, + { "field.duration": { "name": "grace" } }, + { "field.duration": { "name": "retries", "isArray": true } }, + { "field.decimal": { "name": "fee", "@precision": 8, "@scale": 2 } } + ]}}, + { "object.entity": { "name": "Client", "children": [ + { "source.rdb": { "@kind": "table", "@table": "clients" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true } }, + { "identity.primary": { "name": "pk", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Invoice", "children": [ + { "source.rdb": { "@role": "primary", "@kind": "table", "@table": "invoices" } }, + { "source.rdb": { "@role": "replica", "@kind": "view", "@view": "v_invoice_with_client" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "clientId", "@required": true } }, + { "field.duration": { "name": "term" } }, + { "field.duration": { "name": "reminders", "isArray": true } }, + { "field.decimal": { "name": "total", "@precision": 12, "@scale": 2 } }, + { "field.uri": { "name": "docUrl" } }, + { "field.object": { "name": "billingAddress", "@objectRef": "Address", "@storage": "jsonb" } }, + { "field.string": { "name": "clientName", "extends": "Client.name", "children": [ + { "origin.passthrough": { "@from": "Client.name" } } + ]}}, + { "relationship.association": { "name": "client", "@cardinality": "one", "@objectRef": "Client" } }, + { "identity.primary": { "name": "pk", "@fields": "id" } }, + { "identity.reference": { "name": "refClient", "@fields": "clientId", "@references": "Client" } } + ]}} + ]}} + """; + + // Found from the repo root, not [CallerFilePath]: a CI build (CI=true turns on + // ContinuousIntegrationBuild) maps source paths to /_/, where no golden file exists. + private static string GoldenDir() => + Path.Combine(CorpusPaths.RepoRoot(), "server", "csharp", "MetaObjects.Codegen.Tests", "Golden", "pre-hooks"); + + [Fact] + public void Default_output_matches_the_output_captured_before_the_hooks() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "golden.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, + }; + var files = new DbContextGenerator().Generate(ctx).Concat(new EntityGenerator().Generate(ctx)).ToList(); + Assert.Contains(files, f => f.Path == "InvoiceView.g.cs"); + + var dir = GoldenDir(); + if (Environment.GetEnvironmentVariable("GOLDEN_WRITE") == "1") + { + Directory.CreateDirectory(dir); + foreach (var f in files) File.WriteAllText(Path.Combine(dir, f.Path), f.Content); + return; + } + var expected = Directory.GetFiles(dir).Select(p => Path.GetFileName(p)).Order().ToList(); + Assert.Equal(expected, files.Select(f => f.Path).Order().ToList()); + foreach (var f in files) + Assert.Equal(File.ReadAllText(Path.Combine(dir, f.Path)), f.Content); + } + + // The constructs the second round of hooks reaches (captured as described at the top): + // an abstract base in ANOTHER package's namespace (its Names lives there too), a TPH + // base with an abstract intermediate that declares a reference, a concrete subtype with its + // own reference, a jsonb value object, and the names artifact itself. Run twice: with + // abstract shapes emitted (the intermediate is a class) and without (it is folded into its + // subtypes). + private const string BasePackage = """ + { "metadata.root": { "package": "acme::base", "children": [ + { "object.entity": { "name": "BaseEntity", "abstract": true, "children": [ + { "field.long": { "name": "id" } }, + { "field.timestamp": { "name": "createdAt" } } + ]}} + ]}} + """; + + private const string WorkPackage = """ + { "metadata.root": { "package": "acme::work", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "text", "@maxLength": 200 } }, + { "field.enum": { "name": "level", "@values": ["LOW", "HIGH"] } } + ]}}, + { "object.entity": { "name": "Workflow", "extends": "acme::base::BaseEntity", "children": [ + { "source.rdb": { "@table": "workflows" } }, + { "field.string": { "name": "title", "@maxLength": 80 } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Job", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "jobs" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["Call", "Mail", "Plain"] } }, + { "field.object": { "name": "note", "@objectRef": "Note", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "CaseJob", "extends": "Job", "abstract": true, "children": [ + { "field.long": { "name": "workflowId" } }, + { "field.string": { "name": "subject", "@maxLength": 120 } }, + { "identity.reference": { "name": "refWorkflow", "@fields": "workflowId", "@references": "Workflow" } } + ]}}, + { "object.entity": { "name": "CallJob", "extends": "CaseJob", "@discriminatorValue": "Call", "children": [ + { "field.string": { "name": "phone", "@maxLength": 20 } } + ]}}, + { "object.entity": { "name": "MailJob", "extends": "CaseJob", "@discriminatorValue": "Mail", "children": [ + { "field.string": { "name": "address", "@maxLength": 120 } } + ]}}, + { "object.entity": { "name": "PlainJob", "extends": "Job", "@discriminatorValue": "Plain", "children": [ + { "field.long": { "name": "ownerId" } }, + { "identity.reference": { "name": "refOwner", "@fields": "ownerId", "@references": "Workflow" } } + ]}} + ]}} + """; + + internal static List GenerateRound2(bool shapes) + { + var ctx = Round2Context(shapes); + return new DbContextGenerator().Generate(ctx) + .Concat(new EntityGenerator().Generate(ctx)) + .Concat(new NamesGenerator().Generate(ctx)) + .ToList(); + } + + internal static GenContext Round2Context(bool shapes) + { + var r = new MetaDataLoader().Load( + [ + new InMemoryStringSource(BasePackage, id: "golden-base.json"), + new InMemoryStringSource(WorkPackage, id: "golden-work.json"), + ]); + Assert.Empty(r.Errors); + return new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig + { + OutDir = "/tmp", Namespace = "Acme", IncludeNames = true, EmitAbstractShapes = shapes, + PackageNamespaces = new() { ["acme::base"] = "Acme.Base", ["acme::work"] = "Acme.Work" }, + }, + }; + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Round_2_output_matches_the_output_captured_before_the_second_hooks(bool shapes) + { + var files = GenerateRound2(shapes); + Assert.Contains(files, f => f.Path == "WorkflowNames.g.cs"); + + var dir = Path.Combine(GoldenDir(), "..", "pre-hooks-2", shapes ? "shapes" : "no-shapes"); + if (Environment.GetEnvironmentVariable("GOLDEN_WRITE") == "1") + { + Directory.CreateDirectory(dir); + foreach (var f in files) File.WriteAllText(Path.Combine(dir, f.Path), f.Content); + return; + } + var expected = Directory.GetFiles(dir).Select(p => Path.GetFileName(p)).Order().ToList(); + Assert.Equal(expected, files.Select(f => f.Path).Order().ToList()); + foreach (var f in files) + Assert.Equal(File.ReadAllText(Path.Combine(dir, f.Path)), f.Content); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorHooksTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorHooksTests.cs new file mode 100644 index 0000000000..5c14e509e9 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorHooksTests.cs @@ -0,0 +1,1083 @@ +// GeneratorHooksTests — the fine-grained emit hooks on DbContextGenerator and EntityGenerator. +// +// An adopter that needs one line of generated output different overrides ONE small hook, calls +// base, and adjusts what it returns — instead of regex surgery over the finished file or a +// whole-body re-emit. Three families: +// +// * Default_output_is_unchanged — a subclass overriding nothing is byte-identical to the +// stock generator (the existing snapshot suite, which +// passes untouched, is the other half of that proof). +// * _is_called_for_every_ — a recording subclass sees every element the hook +// emits, over a model with a TPH hierarchy, a flattened +// value object, a jsonb value object, an enum array and +// a self-reference. +// * Override_of__changes_only_its_lines — an override moves exactly the lines that hook +// owns and nothing else. +// +// Lines hooks return are complete OnModelCreating lines (8-space indent included), the same form +// the generator keeps them in, so a hook's result can be added to the body as it stands. + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class GeneratorHooksTests +{ + // Auth is a TPH base (BridgeAuth its subtype) that carries: an enum, an enum array, a flattened + // value object, a jsonb value object, a map, a @dbColumnType column, a @default, a decimal, a + // timestamp and a reference to ITSELF (parentId -> Auth). BridgeAuth adds its own enum array, + // flattened value object and default. + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "street", "@required": true, "@maxLength": 120 } }, + { "field.enum": { "name": "kind", "@values": ["HOME", "WORK"] } } + ]}}, + { "object.entity": { "name": "Auth", "@discriminator": "type", "children": [ + { "source.rdb": { "@table": "auths" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "type", "@values": ["Bridge", "Plain"] } }, + { "field.string": { "name": "reference", "@required": true, "@maxLength": 80 } }, + { "field.string": { "name": "note", "@maxLength": 80 } }, + { "field.string": { "name": "uid", "@dbColumnType": "uuid" } }, + { "field.boolean": { "name": "active", "@default": true } }, + { "field.decimal": { "name": "amount", "@precision": 10, "@scale": 2 } }, + { "field.timestamp": { "name": "createdAt" } }, + { "field.enum": { "name": "statuses", "isArray": true, "@values": ["DRAFT", "LIVE"] } }, + { "field.object": { "name": "home", "@objectRef": "Address", "@storage": "flattened" } }, + { "field.object": { "name": "config", "@objectRef": "Address", "@storage": "jsonb" } }, + { "field.map": { "name": "labels", "@valueType": "string" } }, + { "field.long": { "name": "parentId" } }, + { "identity.primary": { "@fields": "id" } }, + { "identity.reference": { "name": "refParent", "@fields": "parentId", "@references": "Auth" } } + ]}}, + { "object.entity": { "name": "BridgeAuth", "extends": "Auth", "@discriminatorValue": "Bridge", "children": [ + { "field.int": { "name": "quantity", "@required": true, "@default": 3 } }, + { "field.enum": { "name": "tiers", "isArray": true, "@values": ["A", "B"] } }, + { "field.object": { "name": "site", "@objectRef": "Address", "@storage": "flattened" } } + ]}} + ]}} + """; + + // Two plain entities and nothing that needs fluent configuration: no OnModelCreating by default. + private const string PlainModel = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Tag", "children": [ + { "source.rdb": { "@table": "tags" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "label", "@maxLength": 40 } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private static MetaRoot Load(string model = Model) + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "hooks.json")]); + Assert.Empty(r.Errors); + return r.Root; + } + + private static GenContext Ctx(MetaRoot root) => new() + { + Entities = root.Objects(), Root = root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, + }; + + private static string Db(DbContextGenerator g, MetaRoot root) => + Assert.Single(g.Generate(Ctx(root))).Content; + + private static IReadOnlyList Entities(EntityGenerator g, MetaRoot root) => + g.Generate(Ctx(root)).ToList(); + + private static string[] Lines(string s) => s.Replace("\r\n", "\n").Split('\n'); + + // The lines of `after` that are not in `before`, and vice versa (multiset difference). + private static (List Removed, List Added) Diff(string before, string after) + { + var b = Lines(before).ToList(); + var a = Lines(after).ToList(); + var removed = new List(); + foreach (var l in b) + { + if (!a.Remove(l)) removed.Add(l); + } + return (removed, a); + } + + // ------------------------------------------------------------ recorders + + private sealed class RecordingDb : DbContextGenerator + { + public readonly List Calls = []; + + protected override string ContextBaseClass(GenContext ctx) { Calls.Add("ContextBaseClass"); return base.ContextBaseClass(ctx); } + protected override IEnumerable ContextPreambleLines(GenContext ctx) { Calls.Add("ContextPreambleLines"); return base.ContextPreambleLines(ctx); } + protected override IEnumerable ContextTrailerLines(GenContext ctx) { Calls.Add("ContextTrailerLines"); return base.ContextTrailerLines(ctx); } + protected override bool AlwaysEmitOnModelCreating(GenContext ctx) { Calls.Add("AlwaysEmitOnModelCreating"); return base.AlwaysEmitOnModelCreating(ctx); } + protected override string DbSetLine(MetaObject entity, GenContext ctx) { Calls.Add($"DbSetLine:{entity.Name}"); return base.DbSetLine(entity, ctx); } + protected override string? DbColumnTypeConfig(string owner, MetaField f) { Calls.Add($"DbColumnTypeConfig:{owner}.{f.Name}"); return base.DbColumnTypeConfig(owner, f); } + protected override IEnumerable ReferenceConfig(MetaObject entity, MetaReferenceIdentity reference, GenContext ctx) { Calls.Add($"ReferenceConfig:{entity.Name}.{reference.Name}"); return base.ReferenceConfig(entity, reference, ctx); } + protected override IEnumerable DefaultValueConfig(MetaObject entity, MetaField f, GenContext ctx) { Calls.Add($"DefaultValueConfig:{entity.Name}.{f.Name}"); return base.DefaultValueConfig(entity, f, ctx); } + protected override string? EnumConversionCall(string owner, MetaObject entity, MetaField f, GenContext ctx) { Calls.Add($"EnumConversionCall:{entity.Name}.{f.Name}"); return base.EnumConversionCall(owner, entity, f, ctx); } + protected override IEnumerable FieldTypeConfig(string owner, MetaObject entity, MetaField f, GenContext ctx) { Calls.Add($"FieldTypeConfig:{entity.Name}.{f.Name}"); return base.FieldTypeConfig(owner, entity, f, ctx); } + protected override string? OwnedTypeConfig(string owner, MetaObject entity, MetaField field, GenContext ctx) { Calls.Add($"OwnedTypeConfig:{entity.Name}.{field.Name}"); return base.OwnedTypeConfig(owner, entity, field, ctx); } + protected override IEnumerable TphSubtypeFieldConfig(MetaObject root, MetaObject subtype, GenContext ctx) { Calls.Add($"TphSubtypeFieldConfig:{root.Name}>{subtype.Name}"); return base.TphSubtypeFieldConfig(root, subtype, ctx); } + protected override IEnumerable EntityConfigTrailer(MetaObject entity, GenContext ctx) { Calls.Add($"EntityConfigTrailer:{entity.Name}"); return base.EntityConfigTrailer(entity, ctx); } + protected override string ContextClassName(GenContext ctx) { Calls.Add("ContextClassName"); return base.ContextClassName(ctx); } + protected override string ContextNamespace(GenContext ctx) { Calls.Add("ContextNamespace"); return base.ContextNamespace(ctx); } + protected override bool EmitContextConstructor(GenContext ctx) { Calls.Add("EmitContextConstructor"); return base.EmitContextConstructor(ctx); } + protected override bool ContextIsPartial(GenContext ctx) { Calls.Add("ContextIsPartial"); return base.ContextIsPartial(ctx); } + protected override IEnumerable ContextMemberLines(GenContext ctx) { Calls.Add("ContextMemberLines"); return base.ContextMemberLines(ctx); } + protected override IEnumerable ContextUsings(GenContext ctx) { Calls.Add("ContextUsings"); return base.ContextUsings(ctx); } + } + + private sealed class RecordingEntity : EntityGenerator + { + public readonly List Calls = []; + + protected override string PropertyClrType(MetaObject owner, MetaField field, GenContext ctx) { Calls.Add($"PropertyClrType:{owner.Name}.{field.Name}"); return base.PropertyClrType(owner, field, ctx); } + protected override string? PropertyInitializer(MetaObject owner, MetaField field, GenContext ctx) { Calls.Add($"PropertyInitializer:{owner.Name}.{field.Name}"); return base.PropertyInitializer(owner, field, ctx); } + protected override EmittedFile? EmitValueObjectPoco(MetaObject vo, GenContext ctx) { Calls.Add($"EmitValueObjectPoco:{vo.Name}"); return base.EmitValueObjectPoco(vo, ctx); } + protected override IEnumerable ValueObjectMemberAttributes(MetaObject vo, MetaField f, GenContext ctx) { Calls.Add($"ValueObjectMemberAttributes:{vo.Name}.{f.Name}"); return base.ValueObjectMemberAttributes(vo, f, ctx); } + protected override IEnumerable ColumnAttributeArguments(MetaObject owner, MetaField f, GenContext ctx) { Calls.Add($"ColumnAttributeArguments:{owner.Name}.{f.Name}"); return base.ColumnAttributeArguments(owner, f, ctx); } + protected override IEnumerable TphDerivedTypeAttributes(MetaObject root, GenContext ctx) { Calls.Add($"TphDerivedTypeAttributes:{root.Name}"); return base.TphDerivedTypeAttributes(root, ctx); } + } + + private sealed class RecordingNames : NamesGenerator + { + public readonly List Calls = []; + + protected override EmittedFile? Render(MetaObject entity, GenContext ctx, bool fragment) { Calls.Add($"Render:{entity.Name}:{fragment}"); return base.Render(entity, ctx, fragment); } + } + + // ------------------------------------------------------------ default output + + [Fact] + public void Default_output_is_unchanged() + { + var root = Load(); + Assert.Equal(Db(new DbContextGenerator(), root), Db(new RecordingDb(), root)); + + var stock = Entities(new EntityGenerator(), root); + var hooked = Entities(new RecordingEntity(), root); + Assert.Equal(stock.Select(f => f.Path), hooked.Select(f => f.Path)); + Assert.Equal(stock.Select(f => f.Content), hooked.Select(f => f.Content)); + + var names = new NamesGenerator().Generate(Ctx(root)).ToList(); + var hookedNames = new RecordingNames().Generate(Ctx(root)).ToList(); + Assert.Equal(names.Select(f => (f.Path, f.Content)), hookedNames.Select(f => (f.Path, f.Content))); + } + + // ------------------------------------------------------------ is_called_for_every + + [Fact] + public void ContextBaseClass_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextBaseClass"); + } + + [Fact] + public void ContextPreambleLines_is_called_for_every_OnModelCreating() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextPreambleLines"); + } + + [Fact] + public void ContextTrailerLines_is_called_for_every_OnModelCreating() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextTrailerLines"); + } + + [Fact] + public void AlwaysEmitOnModelCreating_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load(PlainModel)); + Assert.Contains("AlwaysEmitOnModelCreating", g.Calls); + } + + [Fact] + public void DbSetLine_is_called_for_every_persisted_entity() + { + var g = new RecordingDb(); + Db(g, Load()); + // The TPH subtype shares the base's DbSet, so only the root asks. + Assert.Equal(["DbSetLine:Auth"], g.Calls.Where(c => c.StartsWith("DbSetLine:"))); + } + + [Fact] + public void DbColumnTypeConfig_is_called_for_every_dbColumnType_field() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Equal(["DbColumnTypeConfig:Auth.uid"], g.Calls.Where(c => c.StartsWith("DbColumnTypeConfig:"))); + } + + [Fact] + public void ReferenceConfig_is_called_for_every_enforced_reference_including_a_self_reference() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Equal(["ReferenceConfig:Auth.refParent"], g.Calls.Where(c => c.StartsWith("ReferenceConfig:"))); + } + + [Fact] + public void DefaultValueConfig_is_called_for_every_field_of_every_configured_entity() + { + var root = Load(); + var g = new RecordingDb(); + Db(g, root); + foreach (var f in root.FindObject("Auth")!.Fields()) + Assert.Contains($"DefaultValueConfig:Auth.{f.Name}", g.Calls); + // A TPH subtype: only the fields it declares itself. + Assert.Contains("DefaultValueConfig:BridgeAuth.quantity", g.Calls); + Assert.Contains("DefaultValueConfig:BridgeAuth.tiers", g.Calls); + Assert.DoesNotContain("DefaultValueConfig:BridgeAuth.reference", g.Calls); + } + + [Fact] + public void EnumConversionCall_is_called_for_every_enum_field_including_arrays_and_value_object_members() + { + var g = new RecordingDb(); + Db(g, Load()); + foreach (var expected in new[] + { + "Auth.type", "Auth.statuses", "BridgeAuth.tiers", + "Address.kind", // a flattened value object's member + }) + Assert.Contains($"EnumConversionCall:{expected}", g.Calls); + } + + [Fact] + public void FieldTypeConfig_is_called_for_every_field_of_every_configured_entity() + { + var root = Load(); + var g = new RecordingDb(); + Db(g, root); + foreach (var f in root.FindObject("Auth")!.Fields()) + Assert.Contains($"FieldTypeConfig:Auth.{f.Name}", g.Calls); + // The subtype's OWN fields, configured on its own builder. + Assert.Contains("FieldTypeConfig:BridgeAuth.quantity", g.Calls); + Assert.Contains("FieldTypeConfig:BridgeAuth.tiers", g.Calls); + Assert.Contains("FieldTypeConfig:BridgeAuth.site", g.Calls); + } + + [Fact] + public void OwnedTypeConfig_is_called_for_every_object_field_flattened_or_jsonb() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Equal( + ["OwnedTypeConfig:Auth.home", "OwnedTypeConfig:Auth.config", "OwnedTypeConfig:BridgeAuth.site"], + g.Calls.Where(c => c.StartsWith("OwnedTypeConfig:"))); + } + + [Fact] + public void TphSubtypeFieldConfig_is_called_for_every_subtype_of_every_root() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Equal(["TphSubtypeFieldConfig:Auth>BridgeAuth"], g.Calls.Where(c => c.StartsWith("TphSubtypeFieldConfig:"))); + } + + [Fact] + public void EntityConfigTrailer_is_called_for_every_configured_entity() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Equal(["EntityConfigTrailer:Auth"], g.Calls.Where(c => c.StartsWith("EntityConfigTrailer:"))); + } + + [Fact] + public void PropertyClrType_is_called_for_every_emitted_property() + { + var root = Load(); + var g = new RecordingEntity(); + Entities(g, root); + foreach (var f in root.FindObject("Auth")!.Fields()) + Assert.Contains($"PropertyClrType:Auth.{f.Name}", g.Calls); + foreach (var n in new[] { "quantity", "tiers", "site" }) + Assert.Contains($"PropertyClrType:BridgeAuth.{n}", g.Calls); + foreach (var n in new[] { "street", "kind" }) + Assert.Contains($"PropertyClrType:Address.{n}", g.Calls); + } + + [Fact] + public void PropertyInitializer_is_called_for_every_emitted_property() + { + var root = Load(); + var g = new RecordingEntity(); + Entities(g, root); + foreach (var f in root.FindObject("Auth")!.Fields()) + Assert.Contains($"PropertyInitializer:Auth.{f.Name}", g.Calls); + Assert.Contains("PropertyInitializer:BridgeAuth.quantity", g.Calls); + Assert.Contains("PropertyInitializer:Address.street", g.Calls); + } + + [Fact] + public void EmitValueObjectPoco_is_called_for_every_anchored_value_object() + { + var g = new RecordingEntity(); + Entities(g, Load()); + Assert.Equal(["EmitValueObjectPoco:Address"], g.Calls.Where(c => c.StartsWith("EmitValueObjectPoco:"))); + } + + // ------------------------------------------------------------ Override_..._changes_only_its_lines + + private sealed class BaseClassDb : DbContextGenerator + { + protected override string ContextBaseClass(GenContext ctx) => "AdopterDbContext"; + } + + [Fact] + public void Override_of_ContextBaseClass_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new BaseClassDb(), root)); + Assert.Equal(["public class AppDbContext : DbContext"], removed); + Assert.Equal(["public class AppDbContext : AdopterDbContext"], added); + } + + private sealed class PreambleTrailerDb : DbContextGenerator + { + protected override IEnumerable ContextPreambleLines(GenContext ctx) => + base.ContextPreambleLines(ctx).Append(" base.OnModelCreating(modelBuilder);"); + protected override IEnumerable ContextTrailerLines(GenContext ctx) => + base.ContextTrailerLines(ctx).Append(" OnModelCreatingExtensions(modelBuilder);"); + } + + [Fact] + public void Override_of_ContextPreambleLines_changes_only_its_lines() + { + var root = Load(); + var stock = Db(new DbContextGenerator(), root); + var src = Db(new PreambleTrailerDb(), root); + var (removed, added) = Diff(stock, src); + Assert.Empty(removed); + Assert.Equal( + [" base.OnModelCreating(modelBuilder);", " OnModelCreatingExtensions(modelBuilder);"], + added); + var body = Lines(src); + var open = Array.IndexOf(body, " protected override void OnModelCreating(ModelBuilder modelBuilder)"); + Assert.Equal(" base.OnModelCreating(modelBuilder);", body[open + 2]); + } + + [Fact] + public void Override_of_ContextTrailerLines_changes_only_its_lines() + { + var root = Load(); + var src = Db(new PreambleTrailerDb(), root); + var body = Lines(src); + var trailer = Array.IndexOf(body, " OnModelCreatingExtensions(modelBuilder);"); + // The trailer is the last statement of OnModelCreating: the next line closes the method. + Assert.Equal(" }", body[trailer + 1]); + } + + private sealed class AlwaysDb : DbContextGenerator + { + protected override bool AlwaysEmitOnModelCreating(GenContext ctx) => true; + } + + [Fact] + public void Override_of_AlwaysEmitOnModelCreating_changes_only_its_lines() + { + var root = Load(PlainModel); + var stock = Db(new DbContextGenerator(), root); + Assert.DoesNotContain("OnModelCreating", stock); + var (removed, added) = Diff(stock, Db(new AlwaysDb(), root)); + Assert.Empty(removed); + Assert.Equal( + new[] { "", " protected override void OnModelCreating(ModelBuilder modelBuilder)", " {", " }" }.Order(), + added.Order()); + } + + private sealed class DbSetDb : DbContextGenerator + { + protected override string DbSetLine(MetaObject entity, GenContext ctx) => + base.DbSetLine(entity, ctx).Replace("public DbSet", "public virtual DbSet"); + } + + [Fact] + public void Override_of_DbSetLine_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new DbSetDb(), root)); + Assert.Equal([" public DbSet Auths { get; set; } = default!;"], removed); + Assert.Equal([" public virtual DbSet Auths { get; set; } = default!;"], added); + } + + private sealed class DbColumnTypeDb : DbContextGenerator + { + protected override string? DbColumnTypeConfig(string owner, MetaField f) => null; + } + + [Fact] + public void Override_of_DbColumnTypeConfig_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new DbColumnTypeDb(), root)); + Assert.Empty(added); + var line = Assert.Single(removed); + Assert.Contains(".Property(x => x.Uid).HasColumnType(\"uuid\")", line); + } + + private sealed class NoReferenceDb : DbContextGenerator + { + protected override IEnumerable ReferenceConfig(MetaObject entity, MetaReferenceIdentity reference, GenContext ctx) => []; + } + + [Fact] + public void Override_of_ReferenceConfig_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new NoReferenceDb(), root)); + Assert.Empty(added); + var line = Assert.Single(removed); + Assert.Contains("modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.ParentId)", line); + } + + private sealed class DefaultValueDb : DbContextGenerator + { + protected override IEnumerable DefaultValueConfig(MetaObject entity, MetaField f, GenContext ctx) => + base.DefaultValueConfig(entity, f, ctx).Select(l => l + " // adopter"); + } + + [Fact] + public void Override_of_DefaultValueConfig_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new DefaultValueDb(), root)); + // `active` on the root and `quantity` on the subtype. + Assert.Equal(2, removed.Count); + Assert.Equal(removed.Select(l => l + " // adopter"), added); + Assert.All(removed, l => Assert.Contains("HasDefaultValue(", l)); + } + + private sealed class EnumDb : DbContextGenerator + { + protected override string? EnumConversionCall(string owner, MetaObject entity, MetaField f, GenContext ctx) => + base.EnumConversionCall(owner, entity, f, ctx) + "/*adopter*/"; + } + + [Fact] + public void Override_of_EnumConversionCall_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new EnumDb(), root)); + Assert.NotEmpty(removed); + Assert.Equal(removed.Count, added.Count); + Assert.All(added, l => Assert.Contains("/*adopter*/", l)); + Assert.All(removed, l => Assert.Contains("HasConversion", l)); + } + + private sealed class FieldTypeDb : DbContextGenerator + { + protected override IEnumerable FieldTypeConfig(string owner, MetaObject entity, MetaField f, GenContext ctx) => + base.FieldTypeConfig(owner, entity, f, ctx).Concat( + f.Name == "note" ? [$" // adopter line for {owner}.{f.Name}"] : []); + } + + [Fact] + public void Override_of_FieldTypeConfig_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new FieldTypeDb(), root)); + Assert.Empty(removed); + Assert.Equal([" // adopter line for Auth.note"], added); + } + + private sealed class OwnedDb : DbContextGenerator + { + protected override string? OwnedTypeConfig(string owner, MetaObject entity, MetaField field, GenContext ctx) => + field.Name == "config" ? null : base.OwnedTypeConfig(owner, entity, field, ctx); + } + + [Fact] + public void Override_of_OwnedTypeConfig_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new OwnedDb(), root)); + Assert.Empty(added); + // The jsonb value object's block (it holds an enum, so it is the multi-line form) is + // gone; the flattened ones are not. + Assert.Contains(removed, l => l.Contains("modelBuilder.Entity().OwnsOne(x => x.Config")); + Assert.DoesNotContain(removed, l => l.Contains("OwnsOne(x => x.Home")); + Assert.DoesNotContain(removed, l => l.Contains("OwnsOne(x => x.Site")); + } + + private sealed class TphDb : DbContextGenerator + { + protected override IEnumerable TphSubtypeFieldConfig(MetaObject root, MetaObject subtype, GenContext ctx) => + [$" // adopter {root.Name}>{subtype.Name}"]; + } + + [Fact] + public void Override_of_TphSubtypeFieldConfig_changes_only_its_lines() + { + var root = Load(); + var stock = Db(new DbContextGenerator(), root); + var (removed, added) = Diff(stock, Db(new TphDb(), root)); + Assert.Equal([" // adopter Auth>BridgeAuth"], added); + // Everything removed is configuration of the subtype's own builder, nothing of the root's. + Assert.NotEmpty(removed); + Assert.Contains(removed, l => l.Contains("modelBuilder.Entity()")); + Assert.DoesNotContain(removed, l => l.Contains("Entity()")); + } + + private sealed class TrailerDb : DbContextGenerator + { + protected override IEnumerable EntityConfigTrailer(MetaObject entity, GenContext ctx) => + [$" modelBuilder.Entity<{entity.Name}>().HasIndex(x => x.Id);"]; + } + + [Fact] + public void Override_of_EntityConfigTrailer_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new TrailerDb(), root)); + Assert.Empty(removed); + Assert.Equal([" modelBuilder.Entity().HasIndex(x => x.Id);"], added); + } + + private sealed class PropTypeEntity : EntityGenerator + { + protected override string PropertyClrType(MetaObject owner, MetaField field, GenContext ctx) => + field.Name == "note" ? "global::System.Text.StringBuilder?" : base.PropertyClrType(owner, field, ctx); + } + + [Fact] + public void Override_of_PropertyClrType_changes_only_its_lines() + { + var root = Load(); + var stock = Entities(new EntityGenerator(), root); + var hooked = Entities(new PropTypeEntity(), root); + var s = stock.Single(f => f.Path == "Auth.g.cs").Content; + var h = hooked.Single(f => f.Path == "Auth.g.cs").Content; + var (removed, added) = Diff(s, h); + Assert.Equal([" public string? Note { get; set; }"], removed); + Assert.Equal([" public global::System.Text.StringBuilder? Note { get; set; }"], added); + foreach (var f in stock.Where(f => f.Path != "Auth.g.cs")) + Assert.Equal(f.Content, hooked.Single(x => x.Path == f.Path).Content); + } + + private sealed class PropInitEntity : EntityGenerator + { + protected override string? PropertyInitializer(MetaObject owner, MetaField field, GenContext ctx) => + field.Name == "note" ? "\"n/a\"" : base.PropertyInitializer(owner, field, ctx); + } + + [Fact] + public void Override_of_PropertyInitializer_changes_only_its_lines() + { + var root = Load(); + var s = Entities(new EntityGenerator(), root).Single(f => f.Path == "Auth.g.cs").Content; + var h = Entities(new PropInitEntity(), root).Single(f => f.Path == "Auth.g.cs").Content; + var (removed, added) = Diff(s, h); + Assert.Equal([" public string? Note { get; set; }"], removed); + Assert.Equal([" public string? Note { get; set; } = \"n/a\";"], added); + } + + private sealed class DropAddressEntity : EntityGenerator + { + protected override EmittedFile? EmitValueObjectPoco(MetaObject vo, GenContext ctx) => + vo.Name == "Address" ? null : base.EmitValueObjectPoco(vo, ctx); + } + + [Fact] + public void Override_of_EmitValueObjectPoco_changes_only_its_lines() + { + var root = Load(); + var stock = Entities(new EntityGenerator(), root); + var hooked = Entities(new DropAddressEntity(), root); + Assert.Contains(stock, f => f.Path == "Address.g.cs"); + Assert.DoesNotContain(hooked, f => f.Path == "Address.g.cs"); + Assert.Equal( + stock.Where(f => f.Path != "Address.g.cs").Select(f => (f.Path, f.Content)), + hooked.Select(f => (f.Path, f.Content))); + } + + // ------------------------------------------------------------ ordering over the chunk split + + [Fact] + public void Preamble_chunks_trailer_order_over_200_lines() + { + var sb = new StringBuilder("""{ "metadata.root": { "package": "acme", "children": ["""); + for (var i = 0; i < 70; i++) + { + if (i > 0) sb.Append(','); + sb.Append($$$""" + { "object.entity": { "name": "E{{{i:D2}}}", "children": [ + { "source.rdb": { "@table": "e{{{i:D2}}}" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "status", "@values": ["A", "B"] } }, + { "field.decimal": { "name": "amt", "@precision": 10, "@scale": 2 } }, + { "field.timestamp": { "name": "at" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + """); + } + sb.Append("]}}"); + var root = Load(sb.ToString()); + + var src = Db(new PreambleTrailerDb(), root); + var lines = Lines(src); + var open = Array.IndexOf(lines, " protected override void OnModelCreating(ModelBuilder modelBuilder)"); + Assert.True(open >= 0); + var close = Array.IndexOf(lines, " }", open); + + // The body is: preamble, ConfigureModelNN calls in order, trailer. + Assert.Equal(" {", lines[open + 1]); + Assert.Equal(" base.OnModelCreating(modelBuilder);", lines[open + 2]); + Assert.Equal(" OnModelCreatingExtensions(modelBuilder);", lines[close - 1]); + var calls = lines[(open + 3)..(close - 1)]; + Assert.True(calls.Length >= 2, "a model over 200 lines is split into at least two methods"); + for (var i = 0; i < calls.Length; i++) + Assert.Equal($" ConfigureModel{i + 1:D2}(modelBuilder);", calls[i]); + // And the chunk methods themselves follow the method, in order. + var defs = lines.Where(l => l.StartsWith(" private void ConfigureModel")).ToList(); + Assert.Equal(calls.Length, defs.Count); + for (var i = 0; i < defs.Count; i++) + Assert.Equal($" private void ConfigureModel{i + 1:D2}(ModelBuilder modelBuilder)", defs[i]); + } + + // ============================================================ the second round of hooks + + // ------------------------------------------------------------ is_called_for_every + + [Fact] + public void ValueObjectMemberAttributes_is_called_for_every_value_object_member() + { + var g = new RecordingEntity(); + Entities(g, Load()); + Assert.Equal( + ["ValueObjectMemberAttributes:Address.street", "ValueObjectMemberAttributes:Address.kind"], + g.Calls.Where(c => c.StartsWith("ValueObjectMemberAttributes:"))); + } + + [Fact] + public void ColumnAttributeArguments_is_called_for_every_column_attribute() + { + var root = Load(); + var g = new RecordingEntity(); + var files = Entities(g, root); + var calls = g.Calls.Where(c => c.StartsWith("ColumnAttributeArguments:")).ToList(); + // One call per [Column(...)] the stock output carries, entity, TPH subtype and map alike. + var columns = files.Sum(f => Lines(f.Content).Count(l => l.TrimStart().StartsWith("[Column("))); + Assert.Equal(columns, calls.Count); + foreach (var expected in new[] { "Auth.id", "Auth.type", "Auth.uid", "Auth.statuses", "Auth.labels", "BridgeAuth.quantity", "BridgeAuth.tiers" }) + Assert.Contains($"ColumnAttributeArguments:{expected}", calls); + // A value object's members carry no [Column], so none is asked about. + Assert.DoesNotContain(calls, c => c.StartsWith("ColumnAttributeArguments:Address.")); + } + + [Fact] + public void TphDerivedTypeAttributes_is_called_for_every_tph_base() + { + var g = new RecordingEntity(); + Entities(g, Load()); + Assert.Equal(["TphDerivedTypeAttributes:Auth"], g.Calls.Where(c => c.StartsWith("TphDerivedTypeAttributes:"))); + } + + [Fact] + public void ContextClassName_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextClassName"); + } + + [Fact] + public void ContextNamespace_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextNamespace"); + } + + [Fact] + public void EmitContextConstructor_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "EmitContextConstructor"); + } + + [Fact] + public void ContextIsPartial_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextIsPartial"); + } + + [Fact] + public void ContextMemberLines_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextMemberLines"); + } + + [Fact] + public void ContextUsings_is_called_for_every_context() + { + var g = new RecordingDb(); + Db(g, Load()); + Assert.Single(g.Calls, c => c == "ContextUsings"); + } + + [Fact] + public void Render_is_called_for_every_names_artifact() + { + var files = GeneratorGoldenTests.GenerateRound2(shapes: true); + var g = new RecordingNames(); + var emitted = g.Generate(GeneratorGoldenTests.Round2Context(shapes: true)).ToList(); + Assert.Equal(files.Where(f => f.Path.EndsWith("Names.g.cs")).Select(f => f.Path).Order(), emitted.Select(f => f.Path).Order()); + // Pass 1 (the participants) and pass 2 (the bases they extend, as fragments). + Assert.Contains("Render:Workflow:False", g.Calls); + Assert.Contains("Render:BaseEntity:True", g.Calls); + Assert.Equal(emitted.Count, g.Calls.Count); + } + + // ------------------------------------------------------------ Override_..._changes_only_its_lines + + private sealed class NoVoAttributesEntity : EntityGenerator + { + protected override IEnumerable ValueObjectMemberAttributes(MetaObject vo, MetaField f, GenContext ctx) => []; + } + + [Fact] + public void Override_of_ValueObjectMemberAttributes_changes_only_its_lines() + { + var root = Load(); + var stock = Entities(new EntityGenerator(), root); + var hooked = Entities(new NoVoAttributesEntity(), root); + var (removed, added) = Diff( + stock.Single(f => f.Path == "Address.g.cs").Content, + hooked.Single(f => f.Path == "Address.g.cs").Content); + Assert.Empty(added); + Assert.Equal([" [JsonPropertyName(\"street\")]", " [JsonPropertyName(\"kind\")]"], removed); + foreach (var f in stock.Where(f => f.Path != "Address.g.cs")) + Assert.Equal(f.Content, hooked.Single(x => x.Path == f.Path).Content); + } + + private sealed class TypeNameEntity : EntityGenerator + { + protected override IEnumerable ColumnAttributeArguments(MetaObject owner, MetaField f, GenContext ctx) => + f.Attr("dbColumnType") is string t + ? base.ColumnAttributeArguments(owner, f, ctx).Append($"TypeName = \"{t}\"") + : base.ColumnAttributeArguments(owner, f, ctx); + } + + [Fact] + public void Override_of_ColumnAttributeArguments_changes_only_its_lines() + { + var root = Load(); + var stock = Entities(new EntityGenerator(), root); + var hooked = Entities(new TypeNameEntity(), root); + var (removed, added) = Diff( + stock.Single(f => f.Path == "Auth.g.cs").Content, + hooked.Single(f => f.Path == "Auth.g.cs").Content); + Assert.Equal([" [Column(\"uid\")]"], removed); + Assert.Equal([" [Column(\"uid\", TypeName = \"uuid\")]"], added); + foreach (var f in stock.Where(f => f.Path != "Auth.g.cs")) + Assert.Equal(f.Content, hooked.Single(x => x.Path == f.Path).Content); + } + + private sealed class NoColumnEntity : EntityGenerator + { + protected override IEnumerable ColumnAttributeArguments(MetaObject owner, MetaField f, GenContext ctx) => + f.Name == "note" ? [] : base.ColumnAttributeArguments(owner, f, ctx); + } + + [Fact] + public void ColumnAttributeArguments_returning_none_leaves_the_attribute_out() + { + var root = Load(); + var (removed, added) = Diff( + Entities(new EntityGenerator(), root).Single(f => f.Path == "Auth.g.cs").Content, + Entities(new NoColumnEntity(), root).Single(f => f.Path == "Auth.g.cs").Content); + Assert.Empty(added); + Assert.Equal([" [Column(\"note\")]"], removed); + } + + private sealed class NoDerivedTypesEntity : EntityGenerator + { + protected override IEnumerable TphDerivedTypeAttributes(MetaObject root, GenContext ctx) => []; + } + + [Fact] + public void Override_of_TphDerivedTypeAttributes_changes_only_its_lines() + { + var root = Load(); + var stock = Entities(new EntityGenerator(), root); + var hooked = Entities(new NoDerivedTypesEntity(), root); + var (removed, added) = Diff( + stock.Single(f => f.Path == "Auth.g.cs").Content, + hooked.Single(f => f.Path == "Auth.g.cs").Content); + Assert.Empty(added); + Assert.Equal(["[System.Text.Json.Serialization.JsonDerivedType(typeof(BridgeAuth))]"], removed); + foreach (var f in stock.Where(f => f.Path != "Auth.g.cs")) + Assert.Equal(f.Content, hooked.Single(x => x.Path == f.Path).Content); + } + + private sealed class NamedDb : DbContextGenerator + { + protected override string ContextClassName(GenContext ctx) => "StoreContext"; + } + + [Fact] + public void Override_of_ContextClassName_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new NamedDb(), root)); + Assert.Equal( + ["public class AppDbContext : DbContext", " public AppDbContext(DbContextOptions options) : base(options) { }"], + removed); + Assert.Equal( + ["public class StoreContext : DbContext", " public StoreContext(DbContextOptions options) : base(options) { }"], + added); + } + + [Fact] + // GenConfig.ContextTypeName names the type callables and reverse finders TAKE; an adopter who + // sets it owns that type (often `ShopContext : AppDbContext`). It never named the generated + // class, and does not now: the output is the stock output, byte for byte. + public void A_set_ContextTypeName_leaves_the_generated_class_AppDbContext() + { + var root = Load(); + var ctx = new GenContext + { + Entities = root.Objects(), Root = root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ContextTypeName = "ShopContext" }, + }; + var src = Assert.Single(new DbContextGenerator().Generate(ctx)).Content; + Assert.Equal(Db(new DbContextGenerator(), root), src); + Assert.Contains("public class AppDbContext : DbContext", src); + Assert.DoesNotContain("ShopContext", src); + } + + private sealed class NamespaceDb : DbContextGenerator + { + protected override string ContextNamespace(GenContext ctx) => "Acme.Data"; + } + + [Fact] + public void Override_of_ContextNamespace_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new NamespaceDb(), root)); + Assert.Equal(["namespace Acme.Generated;"], removed); + // The entities stay where they are, so the context now needs their namespace. + Assert.Equal(["using Acme.Generated;", "namespace Acme.Data;"], added); + } + + private sealed class NoCtorDb : DbContextGenerator + { + protected override bool EmitContextConstructor(GenContext ctx) => false; + } + + [Fact] + public void Override_of_EmitContextConstructor_changes_only_its_lines() + { + var root = Load(); + var stock = Db(new DbContextGenerator(), root); + var src = Db(new NoCtorDb(), root); + var (removed, added) = Diff(stock, src); + Assert.Empty(added); + Assert.Equal([" public AppDbContext(DbContextOptions options) : base(options) { }", ""], removed); + var body = Lines(src); + var open = Array.IndexOf(body, "public class AppDbContext : DbContext"); + Assert.Equal("{", body[open + 1]); + Assert.StartsWith(" public DbSet<", body[open + 2]); + } + + private sealed class PartialDb : DbContextGenerator + { + protected override bool ContextIsPartial(GenContext ctx) => true; + } + + [Fact] + public void Override_of_ContextIsPartial_changes_only_its_lines() + { + var root = Load(); + var (removed, added) = Diff(Db(new DbContextGenerator(), root), Db(new PartialDb(), root)); + Assert.Equal(["public class AppDbContext : DbContext"], removed); + Assert.Equal(["public partial class AppDbContext : DbContext"], added); + } + + private sealed class MembersDb : DbContextGenerator + { + protected override IEnumerable ContextMemberLines(GenContext ctx) => + [" partial void OnModelCreatingExtensions(ModelBuilder modelBuilder);"]; + } + + [Fact] + public void Override_of_ContextMemberLines_changes_only_its_lines() + { + var root = Load(); + var stock = Db(new DbContextGenerator(), root); + var src = Db(new MembersDb(), root); + var (removed, added) = Diff(stock, src); + Assert.Empty(removed); + Assert.Equal([" partial void OnModelCreatingExtensions(ModelBuilder modelBuilder);", ""], added); + // After the constructor and its blank line, before the DbSets. + var body = Lines(src); + var member = Array.IndexOf(body, " partial void OnModelCreatingExtensions(ModelBuilder modelBuilder);"); + Assert.StartsWith(" public AppDbContext(", body[member - 2]); + Assert.Equal("", body[member + 1]); + Assert.StartsWith(" public DbSet<", body[member + 2]); + } + + private sealed class UsingsDb : DbContextGenerator + { + protected override IEnumerable ContextUsings(GenContext ctx) => + base.ContextUsings(ctx).Where(l => l != "using Acme.Base;").Append("using Audit.EntityFramework;"); + } + + [Fact] + public void Override_of_ContextUsings_changes_only_its_lines() + { + var ctx = GeneratorGoldenTests.Round2Context(shapes: true); + var stock = Assert.Single(new DbContextGenerator().Generate(ctx)).Content; + var src = Assert.Single(new UsingsDb().Generate(ctx)).Content; + var (removed, added) = Diff(stock, src); + Assert.Equal(["using Acme.Base;"], removed); + Assert.Equal(["using Audit.EntityFramework;"], added); + // Still in the using block: the line after it is the blank before the namespace. + var body = Lines(src); + var at = Array.IndexOf(body, "using Audit.EntityFramework;"); + Assert.Equal("using Acme.Work;", body[at - 1]); + Assert.Equal("", body[at + 1]); + Assert.Equal("namespace Acme;", body[at + 2]); + } + + // The default is the using block the context has always had: EF Core, the Metadata namespace + // when a read-only field needs it, then every other entity namespace, ordinal order. + [Fact] + public void ContextUsings_default_is_the_using_block() + { + var ctx = GeneratorGoldenTests.Round2Context(shapes: true); + var lines = Lines(Assert.Single(new DbContextGenerator().Generate(ctx)).Content); + Assert.Equal( + ["using Microsoft.EntityFrameworkCore;", "using Acme.Base;", "using Acme.Work;"], + lines.Where(l => l.StartsWith("using "))); + } + + private sealed class NoWorkflowNames : NamesGenerator + { + protected override EmittedFile? Render(MetaObject entity, GenContext ctx, bool fragment) => + entity.Name == "Workflow" ? null : base.Render(entity, ctx, fragment); + } + + [Fact] + public void Override_of_Render_changes_only_its_lines() + { + var ctx = GeneratorGoldenTests.Round2Context(shapes: true); + var stock = new NamesGenerator().Generate(ctx).ToList(); + var hooked = new NoWorkflowNames().Generate(ctx).ToList(); + Assert.Contains(stock, f => f.Path == "WorkflowNames.g.cs"); + Assert.DoesNotContain(hooked, f => f.Path == "WorkflowNames.g.cs"); + Assert.Equal( + stock.Where(f => f.Path != "WorkflowNames.g.cs").Select(f => (f.Path, f.Content)), + hooked.Select(f => (f.Path, f.Content))); + } + + // ------------------------------------------------------------ the two fixes + + // Names extends Names, and the base's artifact lands in the BASE's namespace. With + // no `using` for it the subtype's file did not compile (CS0246 on the base class name). + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Names_for_cross_namespace_base_compiles(bool shapes) + { + var files = GeneratorGoldenTests.GenerateRound2(shapes); + var workflow = files.Single(f => f.Path == "WorkflowNames.g.cs").Content; + Assert.Contains("public abstract class WorkflowNames : BaseEntityNames", workflow); + Assert.Contains("using Acme.Base;", workflow); + Assert.Contains("namespace Acme.Base;", files.Single(f => f.Path == "BaseEntityNames.g.cs").Content); + // A base in the SAME namespace needs none. + Assert.DoesNotContain("using Acme.Work;", files.Single(f => f.Path == "CallJobNames.g.cs").Content); + + var comp = CSharpCompilation.Create( + "names_xns_" + Guid.NewGuid().ToString("N"), + files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))), + DbContextCompileTests.BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + } + + // The TPH pass read only the concrete subtypes' own identities, so a reference an abstract + // intermediate declares was never configured. It is, once, on the intermediate's own builder + // when the intermediate is a class, else on each concrete subtype it was folded into. + // A concrete subtype UNDER a concrete subtype below the intermediate inherits its parent's + // foreign key, so only the topmost concrete level is configured: once per FK, not per leaf. + [Fact] + public void Reference_on_abstract_tph_intermediate_is_configured_once_down_a_concrete_chain() + { + const string model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Workflow", "children": [ + { "source.rdb": { "@table": "workflows" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Job", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "jobs" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["Call", "Urgent", "Mail"] } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "CaseJob", "extends": "Job", "abstract": true, "children": [ + { "field.long": { "name": "workflowId" } }, + { "identity.reference": { "name": "refWorkflow", "@fields": "workflowId", "@references": "Workflow" } } + ]}}, + { "object.entity": { "name": "CallJob", "extends": "CaseJob", "@discriminatorValue": "Call" } }, + { "object.entity": { "name": "UrgentCallJob", "extends": "CallJob", "@discriminatorValue": "Urgent" } }, + { "object.entity": { "name": "MailJob", "extends": "CaseJob", "@discriminatorValue": "Mail" } } + ]}} + """; + var lines = Lines(Db(new DbContextGenerator(), Load(model))); + var fks = lines.Where(l => l.Contains(".HasOne()")).ToList(); + Assert.Equal(2, fks.Count); + Assert.Single(fks, l => l.Contains("Entity()")); + Assert.Single(fks, l => l.Contains("Entity()")); + Assert.DoesNotContain(fks, l => l.Contains("Entity()")); + } + + [Fact] + public void Reference_on_abstract_tph_intermediate_is_configured() + { + const string onCaseJob = " modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.WorkflowId).OnDelete(DeleteBehavior.NoAction);"; + var shapes = Lines(GeneratorGoldenTests.GenerateRound2(shapes: true).Single(f => f.Path == "AppDbContext.g.cs").Content); + Assert.Single(shapes, l => l == onCaseJob); + Assert.DoesNotContain(shapes, l => l.Contains("Entity().HasOne()")); + + var folded = Lines(GeneratorGoldenTests.GenerateRound2(shapes: false).Single(f => f.Path == "AppDbContext.g.cs").Content); + Assert.DoesNotContain(folded, l => l.Contains("Entity()")); + foreach (var leaf in new[] { "CallJob", "MailJob" }) + Assert.Single(folded, l => l == onCaseJob.Replace("Entity", $"Entity<{leaf}>")); + Assert.DoesNotContain(folded, l => l.Contains("Entity().HasOne().WithMany().HasForeignKey(e => e.WorkflowId)")); + + var g = new RecordingDb(); + g.Generate(GeneratorGoldenTests.Round2Context(shapes: true)).ToList(); + Assert.Single(g.Calls, c => c == "ReferenceConfig:CaseJob.refWorkflow"); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorIncludeTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorIncludeTests.cs index c1103f4aed..e7d3471a19 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/GeneratorIncludeTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorIncludeTests.cs @@ -58,6 +58,20 @@ public void Entity_include_scopes_entities_and_value_objects() Assert.Contains("ShipTo.g.cs", scoped); } + [Fact] + public void A_scoped_closure_drops_the_value_objects_include_rejects() + { + // Opt-in: Include then applies to the value objects reached through the root too, so a + // value object it rejects gets no POCO even though an accepted entity points at it — the + // adopter supplies that type (or re-types the field) itself. + var scoped = Paths(new EntityGenerator { Include = o => o.Name is not "ShipTo", ScopesValueObjectClosure = true }); + Assert.Contains("Order.g.cs", scoped); + Assert.Contains("CatalogEntry.g.cs", scoped); + Assert.DoesNotContain("ShipTo.g.cs", scoped); + // Without the option, the reached value object is still emitted. + Assert.Contains("ShipTo.g.cs", Paths(new EntityGenerator { Include = o => o.Name is not "ShipTo" })); + } + [Fact] public void DbContext_include_maps_only_the_accepted_objects() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/AppDbContext.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/AppDbContext.g.cs new file mode 100644 index 0000000000..7abf4bc70d --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/AppDbContext.g.cs @@ -0,0 +1,31 @@ +// +// Generated by MetaObjects dbcontext-generator. Do not edit by hand. +#nullable enable +using Microsoft.EntityFrameworkCore; +using Acme.Base; +using Acme.Work; + +namespace Acme; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Jobs { get; set; } = default!; + public DbSet Workflows { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(x => x.Note, b => + { + b.ToJson(JobNames.NoteColumn); + b.Property(p => p.Level).HasConversion(); + }); + modelBuilder.Entity().Property(x => x.Kind).HasConversion(); + modelBuilder.Entity().HasDiscriminator(e => e.Kind).HasValue(Job.JobKind.Call).HasValue(Job.JobKind.Mail).HasValue(Job.JobKind.Plain); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.OwnerId).OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.WorkflowId).OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.WorkflowId).OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity().Property(x => x.CreatedAt).HasColumnType("timestamp with time zone"); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/BaseEntityNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/BaseEntityNames.g.cs new file mode 100644 index 0000000000..d7076ca9a4 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/BaseEntityNames.g.cs @@ -0,0 +1,27 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Base; + +/// +/// GENERATED — per-object physical database names for BaseEntity (spec A1/A2/A6). +/// +public abstract class BaseEntityNames +{ + public const string Type = "object"; + public const string SubType = "entity"; + public const string Name = "BaseEntity"; + + public const string CreatedAtField = "createdAt"; + public const string CreatedAtColumn = "created_at"; + public const string IdField = "id"; + public const string IdColumn = "id"; + + public static readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["createdAt"] = CreatedAtColumn, + ["id"] = IdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CallJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CallJob.g.cs new file mode 100644 index 0000000000..f5fc2aaa10 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CallJob.g.cs @@ -0,0 +1,21 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public class CallJob : Job +{ + [Column(CallJobNames.WorkflowIdColumn)] + public long? WorkflowId { get; set; } + [Column(CallJobNames.SubjectColumn)] + [MaxLength(120)] + public string? Subject { get; set; } + [Column(CallJobNames.PhoneColumn)] + [MaxLength(20)] + public string? Phone { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CallJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CallJobNames.g.cs new file mode 100644 index 0000000000..9731ab0ff0 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CallJobNames.g.cs @@ -0,0 +1,29 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for CallJob (spec A1/A2/A6). +/// +public abstract class CallJobNames : CaseJobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "CallJob"; + + public const string PhoneField = "phone"; + public const string PhoneColumn = "phone"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["phone"] = PhoneColumn, + ["subject"] = SubjectColumn, + ["workflowId"] = WorkflowIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CaseJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CaseJobNames.g.cs new file mode 100644 index 0000000000..f7d8a027f8 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/CaseJobNames.g.cs @@ -0,0 +1,34 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for CaseJob (spec A1/A2/A6). +/// +public abstract class CaseJobNames : JobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "CaseJob"; + + public const string SubjectField = "subject"; + public const string SubjectColumn = "subject"; + public const string WorkflowIdField = "workflowId"; + public const string WorkflowIdColumn = "workflow_id"; + + public const string IdentityRefWorkflowType = "identity"; + public const string IdentityRefWorkflowSubType = "reference"; + public const string IdentityRefWorkflowName = "refWorkflow"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["subject"] = SubjectColumn, + ["workflowId"] = WorkflowIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Job.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Job.g.cs new file mode 100644 index 0000000000..57dd6a3a2a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Job.g.cs @@ -0,0 +1,24 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +[Table(JobNames.SourcePrimaryTable)] +[System.Text.Json.Serialization.JsonDerivedType(typeof(CallJob))] +[System.Text.Json.Serialization.JsonDerivedType(typeof(MailJob))] +[System.Text.Json.Serialization.JsonDerivedType(typeof(PlainJob))] +public abstract class Job +{ + public enum JobKind { Call, Mail, Plain } + [Key] + [Column(JobNames.IdColumn)] + public long Id { get; set; } + [Column(JobNames.KindColumn)] + public JobKind? Kind { get; set; } + public Note? Note { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/JobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/JobNames.g.cs new file mode 100644 index 0000000000..afbd55dc4f --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/JobNames.g.cs @@ -0,0 +1,39 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for Job (spec A1/A2/A6). +/// +public abstract class JobNames +{ + public const string Type = "object"; + public const string SubType = "entity"; + public const string Name = "Job"; + + public const string SourcePrimaryType = "source"; + public const string SourcePrimarySubType = "rdb"; + public const string SourcePrimaryKind = "table"; + public const string SourcePrimaryTable = "jobs"; + + public const string IdField = "id"; + public const string IdColumn = "id"; + public const string KindField = "kind"; + public const string KindColumn = "kind"; + public const string NoteField = "note"; + public const string NoteColumn = "note"; + + public const string IdentityPrimaryType = "identity"; + public const string IdentityPrimarySubType = "primary"; + public const string IdentityPrimaryName = "primary"; + + public static readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/MailJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/MailJob.g.cs new file mode 100644 index 0000000000..a1459033e0 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/MailJob.g.cs @@ -0,0 +1,21 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public class MailJob : Job +{ + [Column(MailJobNames.WorkflowIdColumn)] + public long? WorkflowId { get; set; } + [Column(MailJobNames.SubjectColumn)] + [MaxLength(120)] + public string? Subject { get; set; } + [Column(MailJobNames.AddressColumn)] + [MaxLength(120)] + public string? Address { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/MailJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/MailJobNames.g.cs new file mode 100644 index 0000000000..579e7a0bcb --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/MailJobNames.g.cs @@ -0,0 +1,29 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for MailJob (spec A1/A2/A6). +/// +public abstract class MailJobNames : CaseJobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "MailJob"; + + public const string AddressField = "address"; + public const string AddressColumn = "address"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["address"] = AddressColumn, + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["subject"] = SubjectColumn, + ["workflowId"] = WorkflowIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Note.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Note.g.cs new file mode 100644 index 0000000000..dd6450635d --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Note.g.cs @@ -0,0 +1,20 @@ +// +// Generated by MetaObjects entity-generator (owned value object). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace Acme.Work; + +public class Note +{ + public enum NoteLevel { LOW, HIGH } + [JsonPropertyName("text")] + [MaxLength(200)] + public string? Text { get; set; } + [JsonPropertyName("level")] + public NoteLevel? Level { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/PlainJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/PlainJob.g.cs new file mode 100644 index 0000000000..78af18a7d7 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/PlainJob.g.cs @@ -0,0 +1,15 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public class PlainJob : Job +{ + [Column(PlainJobNames.OwnerIdColumn)] + public long? OwnerId { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/PlainJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/PlainJobNames.g.cs new file mode 100644 index 0000000000..e4a2185c85 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/PlainJobNames.g.cs @@ -0,0 +1,31 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for PlainJob (spec A1/A2/A6). +/// +public abstract class PlainJobNames : JobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "PlainJob"; + + public const string OwnerIdField = "ownerId"; + public const string OwnerIdColumn = "owner_id"; + + public const string IdentityRefOwnerType = "identity"; + public const string IdentityRefOwnerSubType = "reference"; + public const string IdentityRefOwnerName = "refOwner"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["ownerId"] = OwnerIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Workflow.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Workflow.g.cs new file mode 100644 index 0000000000..8721bef5c6 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/Workflow.g.cs @@ -0,0 +1,23 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Acme.Base; + +namespace Acme.Work; + +[Table(WorkflowNames.SourcePrimaryTable)] +public class Workflow +{ + [Key] + [Column(WorkflowNames.IdColumn)] + public long Id { get; set; } + [Column(WorkflowNames.CreatedAtColumn)] + public DateTimeOffset? CreatedAt { get; set; } + [Column(WorkflowNames.TitleColumn)] + [MaxLength(80)] + public string? Title { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/WorkflowNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/WorkflowNames.g.cs new file mode 100644 index 0000000000..98c0b4495c --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/no-shapes/WorkflowNames.g.cs @@ -0,0 +1,36 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; +using Acme.Base; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for Workflow (spec A1/A2/A6). +/// +public abstract class WorkflowNames : BaseEntityNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "Workflow"; + + public const string SourcePrimaryType = "source"; + public const string SourcePrimarySubType = "rdb"; + public const string SourcePrimaryKind = "table"; + public const string SourcePrimaryTable = "workflows"; + + public const string TitleField = "title"; + public const string TitleColumn = "title"; + + public const string IdentityPrimaryType = "identity"; + public const string IdentityPrimarySubType = "primary"; + public const string IdentityPrimaryName = "primary"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["createdAt"] = CreatedAtColumn, + ["id"] = IdColumn, + ["title"] = TitleColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/AppDbContext.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/AppDbContext.g.cs new file mode 100644 index 0000000000..6aefdd872a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/AppDbContext.g.cs @@ -0,0 +1,33 @@ +// +// Generated by MetaObjects dbcontext-generator. Do not edit by hand. +#nullable enable +using Microsoft.EntityFrameworkCore; +using Acme.Base; +using Acme.Work; + +namespace Acme; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Jobs { get; set; } = default!; + public DbSet Workflows { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(x => x.Note, b => + { + b.ToJson(JobNames.NoteColumn); + b.Property(p => p.Level).HasConversion(); + }); + modelBuilder.Entity().Property(x => x.Kind).HasConversion(); + modelBuilder.Entity().HasDiscriminator(e => e.Kind).HasValue(Job.JobKind.Call).HasValue(Job.JobKind.Mail).HasValue(Job.JobKind.Plain); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.OwnerId).OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.WorkflowId).OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity().Property(x => x.CreatedAt).HasColumnType("timestamp with time zone"); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().Property(x => x.Id).HasColumnName(WorkflowNames.IdColumn); + modelBuilder.Entity().Property(x => x.CreatedAt).HasColumnName(WorkflowNames.CreatedAtColumn); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/BaseEntity.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/BaseEntity.g.cs new file mode 100644 index 0000000000..b8764327d8 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/BaseEntity.g.cs @@ -0,0 +1,13 @@ +// +// Generated by MetaObjects entity-generator (abstract shape). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; + +namespace Acme.Base; + +public abstract class BaseEntity +{ + public long? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/BaseEntityNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/BaseEntityNames.g.cs new file mode 100644 index 0000000000..d7076ca9a4 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/BaseEntityNames.g.cs @@ -0,0 +1,27 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Base; + +/// +/// GENERATED — per-object physical database names for BaseEntity (spec A1/A2/A6). +/// +public abstract class BaseEntityNames +{ + public const string Type = "object"; + public const string SubType = "entity"; + public const string Name = "BaseEntity"; + + public const string CreatedAtField = "createdAt"; + public const string CreatedAtColumn = "created_at"; + public const string IdField = "id"; + public const string IdColumn = "id"; + + public static readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["createdAt"] = CreatedAtColumn, + ["id"] = IdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CallJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CallJob.g.cs new file mode 100644 index 0000000000..38d3eea2f9 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CallJob.g.cs @@ -0,0 +1,16 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public class CallJob : CaseJob +{ + [Column(CallJobNames.PhoneColumn)] + [MaxLength(20)] + public string? Phone { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CallJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CallJobNames.g.cs new file mode 100644 index 0000000000..9731ab0ff0 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CallJobNames.g.cs @@ -0,0 +1,29 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for CallJob (spec A1/A2/A6). +/// +public abstract class CallJobNames : CaseJobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "CallJob"; + + public const string PhoneField = "phone"; + public const string PhoneColumn = "phone"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["phone"] = PhoneColumn, + ["subject"] = SubjectColumn, + ["workflowId"] = WorkflowIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CaseJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CaseJob.g.cs new file mode 100644 index 0000000000..03ca833499 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CaseJob.g.cs @@ -0,0 +1,18 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public abstract class CaseJob : Job +{ + [Column(CaseJobNames.WorkflowIdColumn)] + public long? WorkflowId { get; set; } + [Column(CaseJobNames.SubjectColumn)] + [MaxLength(120)] + public string? Subject { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CaseJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CaseJobNames.g.cs new file mode 100644 index 0000000000..f7d8a027f8 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/CaseJobNames.g.cs @@ -0,0 +1,34 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for CaseJob (spec A1/A2/A6). +/// +public abstract class CaseJobNames : JobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "CaseJob"; + + public const string SubjectField = "subject"; + public const string SubjectColumn = "subject"; + public const string WorkflowIdField = "workflowId"; + public const string WorkflowIdColumn = "workflow_id"; + + public const string IdentityRefWorkflowType = "identity"; + public const string IdentityRefWorkflowSubType = "reference"; + public const string IdentityRefWorkflowName = "refWorkflow"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["subject"] = SubjectColumn, + ["workflowId"] = WorkflowIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Job.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Job.g.cs new file mode 100644 index 0000000000..57dd6a3a2a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Job.g.cs @@ -0,0 +1,24 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +[Table(JobNames.SourcePrimaryTable)] +[System.Text.Json.Serialization.JsonDerivedType(typeof(CallJob))] +[System.Text.Json.Serialization.JsonDerivedType(typeof(MailJob))] +[System.Text.Json.Serialization.JsonDerivedType(typeof(PlainJob))] +public abstract class Job +{ + public enum JobKind { Call, Mail, Plain } + [Key] + [Column(JobNames.IdColumn)] + public long Id { get; set; } + [Column(JobNames.KindColumn)] + public JobKind? Kind { get; set; } + public Note? Note { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/JobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/JobNames.g.cs new file mode 100644 index 0000000000..afbd55dc4f --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/JobNames.g.cs @@ -0,0 +1,39 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for Job (spec A1/A2/A6). +/// +public abstract class JobNames +{ + public const string Type = "object"; + public const string SubType = "entity"; + public const string Name = "Job"; + + public const string SourcePrimaryType = "source"; + public const string SourcePrimarySubType = "rdb"; + public const string SourcePrimaryKind = "table"; + public const string SourcePrimaryTable = "jobs"; + + public const string IdField = "id"; + public const string IdColumn = "id"; + public const string KindField = "kind"; + public const string KindColumn = "kind"; + public const string NoteField = "note"; + public const string NoteColumn = "note"; + + public const string IdentityPrimaryType = "identity"; + public const string IdentityPrimarySubType = "primary"; + public const string IdentityPrimaryName = "primary"; + + public static readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/MailJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/MailJob.g.cs new file mode 100644 index 0000000000..0aaa4f0a90 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/MailJob.g.cs @@ -0,0 +1,16 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public class MailJob : CaseJob +{ + [Column(MailJobNames.AddressColumn)] + [MaxLength(120)] + public string? Address { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/MailJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/MailJobNames.g.cs new file mode 100644 index 0000000000..579e7a0bcb --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/MailJobNames.g.cs @@ -0,0 +1,29 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for MailJob (spec A1/A2/A6). +/// +public abstract class MailJobNames : CaseJobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "MailJob"; + + public const string AddressField = "address"; + public const string AddressColumn = "address"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["address"] = AddressColumn, + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["subject"] = SubjectColumn, + ["workflowId"] = WorkflowIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Note.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Note.g.cs new file mode 100644 index 0000000000..dd6450635d --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Note.g.cs @@ -0,0 +1,20 @@ +// +// Generated by MetaObjects entity-generator (owned value object). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace Acme.Work; + +public class Note +{ + public enum NoteLevel { LOW, HIGH } + [JsonPropertyName("text")] + [MaxLength(200)] + public string? Text { get; set; } + [JsonPropertyName("level")] + public NoteLevel? Level { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/PlainJob.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/PlainJob.g.cs new file mode 100644 index 0000000000..78af18a7d7 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/PlainJob.g.cs @@ -0,0 +1,15 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Work; + +public class PlainJob : Job +{ + [Column(PlainJobNames.OwnerIdColumn)] + public long? OwnerId { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/PlainJobNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/PlainJobNames.g.cs new file mode 100644 index 0000000000..e4a2185c85 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/PlainJobNames.g.cs @@ -0,0 +1,31 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for PlainJob (spec A1/A2/A6). +/// +public abstract class PlainJobNames : JobNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "PlainJob"; + + public const string OwnerIdField = "ownerId"; + public const string OwnerIdColumn = "owner_id"; + + public const string IdentityRefOwnerType = "identity"; + public const string IdentityRefOwnerSubType = "reference"; + public const string IdentityRefOwnerName = "refOwner"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["id"] = IdColumn, + ["kind"] = KindColumn, + ["note"] = NoteColumn, + ["ownerId"] = OwnerIdColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Workflow.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Workflow.g.cs new file mode 100644 index 0000000000..12e46df65a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/Workflow.g.cs @@ -0,0 +1,18 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Acme.Base; + +namespace Acme.Work; + +[Table(WorkflowNames.SourcePrimaryTable)] +public class Workflow : BaseEntity +{ + [Column(WorkflowNames.TitleColumn)] + [MaxLength(80)] + public string? Title { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/WorkflowNames.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/WorkflowNames.g.cs new file mode 100644 index 0000000000..98c0b4495c --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks-2/shapes/WorkflowNames.g.cs @@ -0,0 +1,36 @@ +// +// Generated by MetaObjects names-generator. Do not edit by hand. +#nullable enable +using System.Collections.Generic; +using Acme.Base; + +namespace Acme.Work; + +/// +/// GENERATED — per-object physical database names for Workflow (spec A1/A2/A6). +/// +public abstract class WorkflowNames : BaseEntityNames +{ + public new const string Type = "object"; + public new const string SubType = "entity"; + public new const string Name = "Workflow"; + + public const string SourcePrimaryType = "source"; + public const string SourcePrimarySubType = "rdb"; + public const string SourcePrimaryKind = "table"; + public const string SourcePrimaryTable = "workflows"; + + public const string TitleField = "title"; + public const string TitleColumn = "title"; + + public const string IdentityPrimaryType = "identity"; + public const string IdentityPrimarySubType = "primary"; + public const string IdentityPrimaryName = "primary"; + + public static new readonly Dictionary ColumnsByField = new(System.StringComparer.Ordinal) + { + ["createdAt"] = CreatedAtColumn, + ["id"] = IdColumn, + ["title"] = TitleColumn, + }; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Address.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Address.g.cs new file mode 100644 index 0000000000..8243cc6f0d --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Address.g.cs @@ -0,0 +1,26 @@ +// +// Generated by MetaObjects entity-generator (owned value object). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace Acme.Generated; + +public class Address +{ + public enum AddressKind { HOME, WORK } + [JsonPropertyName("street")] + [Required(AllowEmptyStrings = true)] + [MaxLength(120)] + [MinLength(1)] + public string Street { get; set; } = default!; + [JsonPropertyName("kind")] + public AddressKind? Kind { get; set; } + [JsonPropertyName("window")] + public TimeSpan? Window { get; set; } + [JsonPropertyName("hints")] + public Dictionary? Hints { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/AppDbContext.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/AppDbContext.g.cs new file mode 100644 index 0000000000..2fd3c45198 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/AppDbContext.g.cs @@ -0,0 +1,305 @@ +// +// Generated by MetaObjects dbcontext-generator. Do not edit by hand. +#nullable enable +using Microsoft.EntityFrameworkCore; + +namespace Acme.Generated; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Auths { get; set; } = default!; + public DbSet Clients { get; set; } = default!; + public DbSet Invoices { get; set; } = default!; + /// Read-model view for write-through entity Invoice (reads route here; carries the derived fields). + public DbSet InvoiceViews { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(x => x.Home, b => + { + b.Property(p => p.Street).HasColumnName("home_street"); + b.Property(p => p.Kind).HasColumnName("home_kind").HasConversion(); + b.Property(p => p.Window).HasColumnName("home_window"); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + b.Property(p => p.Window).HasColumnType("interval").Metadata.SetTypeMapping(DurationInterval.Mapping); + else + b.Property(p => p.Window).HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + b.Property(p => p.Hints).HasColumnName("home_hints").HasColumnType("jsonb").HasConversion(MapJsonb.Converter(), MapJsonb.Comparer()); + }); + modelBuilder.Entity().Property(x => x.Config).HasColumnName("config").HasColumnType("jsonb").HasConversion(MapJsonb.ObjectConverter(), MapJsonb.ObjectComparer()); + modelBuilder.Entity().Property(x => x.Labels).HasColumnType("jsonb").HasConversion(MapJsonb.Converter(), MapJsonb.Comparer()); + modelBuilder.Entity().Property(x => x.Type).HasConversion(); + modelBuilder.Entity().PrimitiveCollection(x => x.Statuses).ElementType().HasConversion(); + modelBuilder.Entity().PrimitiveCollection(x => x.Slots); + modelBuilder.Entity().PrimitiveCollection(x => x.Tags); + modelBuilder.Entity().Property(x => x.Amount).HasPrecision(10, 2); + modelBuilder.Entity().Property(x => x.CreatedAt).HasColumnType("timestamp with time zone"); + modelBuilder.Entity().Property(x => x.Homepage).HasColumnType("text").HasConversion(v => v!.ToString(), v => new System.Uri(v)); + modelBuilder.Entity().Property(x => x.Addr).HasColumnType("inet"); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().Property(x => x.Ttl).HasColumnType("interval").Metadata.SetTypeMapping(DurationInterval.Mapping); + else + modelBuilder.Entity().Property(x => x.Ttl).HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().PrimitiveCollection(x => x.Slots).Metadata.SetTypeMapping(DurationInterval.ArrayMapping); + else + modelBuilder.Entity().PrimitiveCollection(x => x.Slots).ElementType().HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + modelBuilder.Entity().Property(x => x.Active).HasDefaultValue(true).HasSentinel(true); + modelBuilder.Entity().HasDiscriminator(e => e.Type).HasValue(Auth.AuthType.Bridge); + modelBuilder.Entity().PrimitiveCollection(x => x.Retries); + modelBuilder.Entity().Property(x => x.Fee).HasPrecision(8, 2); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().Property(x => x.Grace).HasColumnType("interval").Metadata.SetTypeMapping(DurationInterval.Mapping); + else + modelBuilder.Entity().Property(x => x.Grace).HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().PrimitiveCollection(x => x.Retries).Metadata.SetTypeMapping(DurationInterval.ArrayMapping); + else + modelBuilder.Entity().PrimitiveCollection(x => x.Retries).ElementType().HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + modelBuilder.Entity().Property(x => x.Quantity).HasDefaultValue(3).HasSentinel(3); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.ParentId).OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity().Property(x => x.BillingAddress).HasColumnName("billing_address").HasColumnType("jsonb").HasConversion(MapJsonb.ObjectConverter(), MapJsonb.ObjectComparer()); + modelBuilder.Entity().PrimitiveCollection(x => x.Reminders); + modelBuilder.Entity().Property(x => x.Total).HasPrecision(12, 2); + modelBuilder.Entity().Property(x => x.DocUrl).HasColumnType("text").HasConversion(v => v!.ToString(), v => new System.Uri(v)); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().Property(x => x.Term).HasColumnType("interval").Metadata.SetTypeMapping(DurationInterval.Mapping); + else + modelBuilder.Entity().Property(x => x.Term).HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().PrimitiveCollection(x => x.Reminders).Metadata.SetTypeMapping(DurationInterval.ArrayMapping); + else + modelBuilder.Entity().PrimitiveCollection(x => x.Reminders).ElementType().HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(e => e.ClientId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity().ToView("v_invoice_with_client"); + modelBuilder.Entity().Property(x => x.BillingAddress).HasColumnName("billing_address").HasColumnType("jsonb").HasConversion(MapJsonb.ObjectConverter(), MapJsonb.ObjectComparer()); + modelBuilder.Entity().PrimitiveCollection(x => x.Reminders); + modelBuilder.Entity().Property(x => x.Total).HasPrecision(12, 2); + modelBuilder.Entity().Property(x => x.DocUrl).HasColumnType("text").HasConversion(v => v!.ToString(), v => new System.Uri(v)); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().Property(x => x.Term).HasColumnType("interval").Metadata.SetTypeMapping(DurationInterval.Mapping); + else + modelBuilder.Entity().Property(x => x.Term).HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + modelBuilder.Entity().PrimitiveCollection(x => x.Reminders).Metadata.SetTypeMapping(DurationInterval.ArrayMapping); + else + modelBuilder.Entity().PrimitiveCollection(x => x.Reminders).ElementType().HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => global::Acme.Generated.DurationWire.ToMicros(v), v => global::Acme.Generated.DurationWire.FromMicros(v))); + } + + /// + /// Shared jsonb (de)serialization for field.map columns. The comparer is required: + /// EF snapshots a value-converted property by reference, so without it an in-place + /// edit to the dictionary is never detected and the row is never updated. + /// + private static class MapJsonb + { + private static readonly System.Text.Json.JsonSerializerOptions Options = new() + { + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + internal static Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter< + System.Collections.Generic.Dictionary, string> RequiredConverter() => new( + v => System.Text.Json.JsonSerializer.Serialize(v, Options), + v => System.Text.Json.JsonSerializer.Deserialize< + System.Collections.Generic.Dictionary>(v, Options)!); + + /// Same conversion as RequiredConverter, over the NULLABLE dictionary a + /// non-required map's property carries — keep the two bodies identical. EF Core's + /// nullability-aware HasConversion checks the converter's model type against the + /// property's own annotation, so one factory cannot serve both arms. + internal static Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter< + System.Collections.Generic.Dictionary?, string> Converter() => new( + v => System.Text.Json.JsonSerializer.Serialize(v, Options), + v => System.Text.Json.JsonSerializer.Deserialize< + System.Collections.Generic.Dictionary>(v, Options)!); + + /// A whole value-object document (or collection of them) as one jsonb value, + /// for a value object that holds a map (EF Core 8 cannot own a dictionary inside JSON). + internal static Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter ObjectConverter() => new( + v => System.Text.Json.JsonSerializer.Serialize(v, Options), + v => System.Text.Json.JsonSerializer.Deserialize(v, Options)!); + + internal static Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer ObjectComparer() => new( + (a, b) => System.Text.Json.JsonSerializer.Serialize(a, Options) == System.Text.Json.JsonSerializer.Serialize(b, Options), + v => System.Text.Json.JsonSerializer.Serialize(v, Options).GetHashCode(), + v => System.Text.Json.JsonSerializer.Deserialize(System.Text.Json.JsonSerializer.Serialize(v, Options), Options)!); + + internal static Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer< + System.Collections.Generic.Dictionary?> Comparer() => new( + (a, b) => Eq(a, b), v => Hash(v), v => Snap(v)); + + /// Order-INDEPENDENT equality: a dictionary rebuilt in a different key + /// order holds the same value, and comparing serialized JSON would call it changed + /// and issue an UPDATE for a row nothing touched. Scalars settle on the default + /// comparer (no serialization at all); a value-object value falls through to a JSON + /// compare, since the generated POCO is a class and compares by reference. + private static bool Eq( + System.Collections.Generic.Dictionary? a, + System.Collections.Generic.Dictionary? b) + { + if (a is null || b is null) return ReferenceEquals(a, b); + if (a.Count != b.Count) return false; + foreach (var kv in a) + { + if (!b.TryGetValue(kv.Key, out var other)) return false; + if (System.Collections.Generic.EqualityComparer.Default.Equals(kv.Value, other)) continue; + if (System.Text.Json.JsonSerializer.Serialize(kv.Value, Options) + != System.Text.Json.JsonSerializer.Serialize(other, Options)) return false; + } + return true; + } + + /// Keys and count only — order-independent (XOR), allocation-free, and + /// stable for a value-object value whose GetHashCode is reference-based. Equal + /// dictionaries always hash equal, which is all EF requires. + private static int Hash(System.Collections.Generic.Dictionary? v) + { + if (v is null) return 0; + var h = v.Count; + foreach (var k in v.Keys) h ^= k.GetHashCode(); + return h; + } + + /// The change-tracking snapshot. A value-object value needs a DEEP copy or + /// an in-place edit to a nested object mutates the snapshot too and goes undetected; + /// an immutable scalar only needs the dictionary copied. + private static System.Collections.Generic.Dictionary? Snap( + System.Collections.Generic.Dictionary? v) => + v is null + ? null + : typeof(TValue).IsValueType || typeof(TValue) == typeof(string) + ? new System.Collections.Generic.Dictionary(v) + : System.Text.Json.JsonSerializer.Deserialize< + System.Collections.Generic.Dictionary>( + System.Text.Json.JsonSerializer.Serialize(v, Options), Options)!; + } +} + +/// +/// field.duration on Npgsql: an interval column read through NpgsqlInterval, so a month +/// or year part, or a day count past what a TimeSpan holds, is refused rather than read wrong. +/// Npgsql is reached by reflection: a project on another provider needs no Npgsql reference. +/// +internal static class DurationInterval +{ + // The Npgsql shape this mapping reads, resolved and checked when the model is built (the + // first touch is SetTypeMapping in OnModelCreating, or the first query on a compiled model), + // so a missing or changed type fails there, naming what is missing, rather than on a row. + internal static readonly System.Type IntervalType = Resolve(); + private static readonly System.Reflection.PropertyInfo Months = IntervalType.GetProperty("Months")!; + private static readonly System.Reflection.PropertyInfo Days = IntervalType.GetProperty("Days")!; + private static readonly System.Reflection.PropertyInfo Time = IntervalType.GetProperty("Time")!; + internal static readonly System.Reflection.MethodInfo GetFieldValue = + typeof(System.Data.Common.DbDataReader).GetMethod(nameof(System.Data.Common.DbDataReader.GetFieldValue))!; + + internal static readonly Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping Mapping = DurationIntervalMapping.Default; + internal static readonly Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping ArrayMapping = DurationIntervalArrayMapping.Default; + + private static System.Type Resolve() + { + var type = System.Type.GetType(IntervalShape.TypeName, throwOnError: false); + if (IntervalShape.Problem(type) is { } problem) throw new System.InvalidOperationException(problem); + return type!; + } + + /// The check of the NpgsqlInterval shape, kept apart from the state above so it can run against any type. + internal static class IntervalShape + { + internal const string TypeName = "NpgsqlTypes.NpgsqlInterval, Npgsql"; + + /// Null when has the shape the mapping reads, else a message naming what is missing. + internal static string? Problem(System.Type? type) + { + const string Lead = "field.duration on Npgsql: the generated DurationInterval mapping reads an interval column as " + + "NpgsqlTypes.NpgsqlInterval (an Int32 Months, an Int32 Days and an Int64 Time in microseconds; " + + "tested with Npgsql 8.0.5), but "; + if (type is null) return Lead + "no type NpgsqlTypes.NpgsqlInterval was found in the loaded Npgsql assembly."; + var missing = new System.Collections.Generic.List(); + foreach (var (name, want) in new[] { ("Months", typeof(int)), ("Days", typeof(int)), ("Time", typeof(long)) }) + { + var property = type.GetProperty(name); + if (property is null || property.PropertyType != want) + missing.Add(want.Name + " " + name); + } + return missing.Count == 0 + ? null + : Lead + type.FullName + " has no " + string.Join(", no ", missing) + " property. Check the Npgsql version the project references."; + } + } + + /// An NpgsqlInterval as a TimeSpan: refused when it has a month or year part, or a span a TimeSpan cannot hold. + public static System.TimeSpan FromInterval(object interval) + { + if ((int)Months.GetValue(interval)! != 0) + throw new System.InvalidCastException("field.duration: the interval has a month or year part, so it is not a fixed-length span"); + var days = (int)Days.GetValue(interval)!; + var micros = (long)Time.GetValue(interval)!; + return System.TimeSpan.FromTicks(checked(days * System.TimeSpan.TicksPerDay + micros * 10)); + } + + /// An interval[] (an NpgsqlInterval array) as the list a duration array property holds. + public static System.Collections.Generic.IList FromIntervals(object intervals) + { + var list = new System.Collections.Generic.List(); + foreach (var interval in (System.Array)intervals) list.Add(FromInterval(interval!)); + return list; + } + + // A span as Postgres interval input: [-]HH:MM:SS[.f], hours unbounded, no day part. The column + // holds microseconds, so sub-microsecond ticks are truncated toward zero. + internal static string Literal(System.TimeSpan value) + { + long micros = value.Ticks / 10; + bool negative = micros < 0; + ulong rest = negative ? (ulong)(-(micros + 1)) + 1UL : (ulong)micros; + ulong frac = rest % 1_000_000UL, s = rest / 1_000_000UL % 60, m = rest / 60_000_000UL % 60, h = rest / 3_600_000_000UL; + var text = h.ToString("00", System.Globalization.CultureInfo.InvariantCulture) + ":" + m.ToString("00", System.Globalization.CultureInfo.InvariantCulture) + + ":" + s.ToString("00", System.Globalization.CultureInfo.InvariantCulture); + if (frac > 0) text += "." + frac.ToString("D6", System.Globalization.CultureInfo.InvariantCulture).TrimEnd('0'); + return "INTERVAL '" + (negative ? "-" : "") + text + "'"; + } +} + +/// The type mapping of a field.duration column on Npgsql (an interval, read through ). +public sealed class DurationIntervalMapping : Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping +{ + public static DurationIntervalMapping Default { get; } = new(); + + public DurationIntervalMapping() : base("interval", typeof(System.TimeSpan)) { } + private DurationIntervalMapping(RelationalTypeMappingParameters parameters) : base(parameters) { } + protected override Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new DurationIntervalMapping(parameters); + public override System.Reflection.MethodInfo GetDataReaderMethod() => DurationInterval.GetFieldValue.MakeGenericMethod(DurationInterval.IntervalType); + public override System.Linq.Expressions.Expression CustomizeDataReaderExpression(System.Linq.Expressions.Expression expression) => + System.Linq.Expressions.Expression.Call(typeof(DurationInterval).GetMethod(nameof(DurationInterval.FromInterval))!, + System.Linq.Expressions.Expression.Convert(expression, typeof(object))); + protected override string GenerateNonNullSqlLiteral(object value) => DurationInterval.Literal((System.TimeSpan)value); +} + +/// The type mapping of an @isArray field.duration column on Npgsql (an interval[]). +public sealed class DurationIntervalArrayMapping : Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping +{ + private static readonly Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer> ListComparer = new( + (a, b) => a == null ? b == null : b != null && System.Linq.Enumerable.SequenceEqual(a, b), + v => System.Linq.Enumerable.Aggregate(v, 0, (h, e) => System.HashCode.Combine(h, e)), + v => System.Linq.Enumerable.ToList(v)); + + // After ListComparer: static initializers run in declaration order. The element mapping is the + // scalar one, which is what lets EF query the column as a primitive collection (Contains, Count, + // Any): a compiled model carries only what the mapping itself names. + public static DurationIntervalArrayMapping Default { get; } = new(); + + public DurationIntervalArrayMapping() : base(new RelationalTypeMappingParameters( + new CoreTypeMappingParameters(typeof(System.Collections.Generic.IList), comparer: ListComparer, keyComparer: ListComparer, + elementMapping: DurationIntervalMapping.Default), "interval[]")) { } + private DurationIntervalArrayMapping(RelationalTypeMappingParameters parameters) : base(parameters) { } + protected override Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new DurationIntervalArrayMapping(parameters); + public override System.Reflection.MethodInfo GetDataReaderMethod() => DurationInterval.GetFieldValue.MakeGenericMethod(DurationInterval.IntervalType.MakeArrayType()); + public override System.Linq.Expressions.Expression CustomizeDataReaderExpression(System.Linq.Expressions.Expression expression) => + System.Linq.Expressions.Expression.Call(typeof(DurationInterval).GetMethod(nameof(DurationInterval.FromIntervals))!, + System.Linq.Expressions.Expression.Convert(expression, typeof(object))); + protected override string GenerateNonNullSqlLiteral(object value) => + "ARRAY[" + string.Join(",", System.Linq.Enumerable.Select((System.Collections.Generic.IEnumerable)value, DurationInterval.Literal)) + "]::interval[]"; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Auth.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Auth.g.cs new file mode 100644 index 0000000000..5bd7921d9c --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Auth.g.cs @@ -0,0 +1,51 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Net; +using System.Text.Json.Serialization; + +namespace Acme.Generated; + +[Table("auths")] +[System.Text.Json.Serialization.JsonDerivedType(typeof(BridgeAuth))] +public abstract class Auth +{ + public enum AuthType { Bridge, Plain } + public enum AuthStatuses { DRAFT, LIVE } + [Key] + [Column("id")] + public long Id { get; set; } + [Column("type")] + public AuthType? Type { get; set; } + [Column("ttl")] + public TimeSpan? Ttl { get; set; } + [Column("slots")] + public IList Slots { get; set; } = new List(); + [Column("amount")] + [DecimalWire.Number(10, 2)] + public decimal? Amount { get; set; } + [Column("created_at")] + public DateTimeOffset? CreatedAt { get; set; } + [Column("homepage")] + [JsonConverter(typeof(MetaNetValidation.AbsoluteUriJsonConverter))] + public Uri? Homepage { get; set; } + [Column("addr")] + [JsonConverter(typeof(MetaNetValidation.IPAddressJsonConverter))] + public IPAddress? Addr { get; set; } + [Column("tags")] + public IList Tags { get; set; } = new List(); + [Column("statuses")] + public IList Statuses { get; set; } = new List(); + public Address? Home { get; set; } + public Address? Config { get; set; } + [Column("labels")] + public Dictionary? Labels { get; set; } + [Column("active")] + public bool? Active { get; set; } = true; + [Column("parent_id")] + public long? ParentId { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/AuthQueries.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/AuthQueries.g.cs new file mode 100644 index 0000000000..2cac0f930f --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/AuthQueries.g.cs @@ -0,0 +1,24 @@ +// +// Generated by MetaObjects entity-generator (reverse FK finders, ADR-0038). Do not edit by hand. +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace Acme.Generated; + +/// Reverse-relationship FK finders for Auth (ADR-0038): explicit, +/// single-query reads — no lazy navigation, no N+1. +public static class AuthQueries +{ + /// The Auth rows whose ParentId equals (one indexed query). + public static async System.Threading.Tasks.Task> FindAuthsByParent(AppDbContext db, long? parentId) => + await db.Auths.AsNoTracking().Where(e => e.ParentId == parentId).ToListAsync(); + + /// The Auth rows whose ParentId is in (one batched IN query; anti-N+1). + public static async System.Threading.Tasks.Task> FindAuthsByParentIn(AppDbContext db, IReadOnlyCollection parentIds) + { + if (parentIds.Count == 0) return new List(); + return await db.Auths.AsNoTracking().Where(e => parentIds.Contains(e.ParentId)).ToListAsync(); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/BridgeAuth.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/BridgeAuth.g.cs new file mode 100644 index 0000000000..caefce0c52 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/BridgeAuth.g.cs @@ -0,0 +1,22 @@ +// +// Generated by MetaObjects entity-generator (TPH subtype). Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Generated; + +public class BridgeAuth : Auth +{ + [Column("quantity")] + public int Quantity { get; set; } = 3; + [Column("grace")] + public TimeSpan? Grace { get; set; } + [Column("retries")] + public IList Retries { get; set; } = new List(); + [Column("fee")] + [DecimalWire.Number(8, 2)] + public decimal? Fee { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Client.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Client.g.cs new file mode 100644 index 0000000000..8937a8116b --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Client.g.cs @@ -0,0 +1,21 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Acme.Generated; + +[Table("clients")] +public class Client +{ + [Key] + [Column("id")] + public long Id { get; set; } + [Column("name")] + [Required(AllowEmptyStrings = true)] + [MinLength(1)] + public string Name { get; set; } = default!; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/DecimalWire.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/DecimalWire.g.cs new file mode 100644 index 0000000000..12ec66bb86 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/DecimalWire.g.cs @@ -0,0 +1,325 @@ +// +// DecimalWire — the wire form of a field.decimal (ADR-0019 Amendments 1 and 2, W1-W4). +// +// Output follows the project's `wire.decimals` setting (Amendment 2). Under `native`, the +// default, a decimal is what System.Text.Json writes for it: a JSON number carrying its scale +// (`12.50`), through the `Number` attribute. Under `string` it is the canonical STRING, +// `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`: plain notation, no exponent, no trailing fractional +// zeros, zero as "0" (the plain converters and `Declared`), the form every port writes alike and +// the only spelling a browser reads exactly. Input is the same under both: a string in the +// JSON-number grammar or a JSON number; anything else is refused. A value `decimal` cannot hold +// (more than 28-29 significant digits) is an error, never a rounded value (W3). +// +// ONE source, two homes. This file is the package's production helper (the filter +// dispatcher parses its operands through it). The entity generator emits the SAME text, +// with only the namespace changed, as `DecimalWire.g.cs` into every namespace that has a +// decimal property, so the `[JsonConverter(typeof(DecimalWire.…))]` on each property +// resolves inside the generated project and the generated entity code imports nothing +// from MetaObjects (CLAUDE.md). DecimalWireTests gates the two texts byte-for-byte. +// Keep this file free of references to anything but the BCL. + +#nullable enable +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Acme.Generated; + +/// The canonical wire form of a decimal: format, parse, and the System.Text.Json converters. +public static class DecimalWire +{ + /// W1. The canonical string of : plain notation, trailing fractional zeros stripped, zero as "0". + public static string Format(decimal value) + { + var s = value.ToString(CultureInfo.InvariantCulture); + if (s.IndexOf('.') >= 0) + { + s = s.TrimEnd('0'); + if (s.EndsWith('.')) s = s.Substring(0, s.Length - 1); + } + return s == "-0" || s.Length == 0 ? "0" : s; + } + + /// + /// W2. Parse text in the JSON-number grammar (-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?, + /// no whitespace, no sign other than -). False for anything else, and for a value + /// decimal cannot hold exactly (W3). + /// + public static bool TryParse(string? text, out decimal value) + { + value = 0m; + if (text is null) return false; + int i = 0, n = text.Length; + bool negative = false; + if (i < n && text[i] == '-') { negative = true; i++; } + if (i >= n) return false; + int intStart = i; + if (text[i] == '0') i++; + else if (text[i] >= '1' && text[i] <= '9') { while (i < n && IsDigit(text[i])) i++; } + else return false; + int intEnd = i; + int fracStart = i, fracEnd = i; + if (i < n && text[i] == '.') + { + i++; + fracStart = i; + while (i < n && IsDigit(text[i])) i++; + if (i == fracStart) return false; + fracEnd = i; + } + long exponent = 0; + if (i < n && (text[i] == 'e' || text[i] == 'E')) + { + i++; + bool expNegative = false; + if (i < n && (text[i] == '+' || text[i] == '-')) { expNegative = text[i] == '-'; i++; } + int expStart = i; + while (i < n && IsDigit(text[i])) + { + if (exponent < 100000) exponent = exponent * 10 + (text[i] - '0'); + i++; + } + if (i == expStart) return false; + if (expNegative) exponent = -exponent; + } + if (i != n) return false; + var digits = text.Substring(intStart, intEnd - intStart) + text.Substring(fracStart, fracEnd - fracStart); + return TryBuild(negative, digits, (intEnd - intStart) + exponent, out value); + } + + /// W2 for an already-parsed JSON value (a PATCH merge reads one property at a time): a string or a number. + public static bool TryParseElement(JsonElement element, out decimal value) + { + value = 0m; + return element.ValueKind switch + { + JsonValueKind.String => TryParse(element.GetString(), out value), + JsonValueKind.Number => TryParse(element.GetRawText(), out value), + _ => false, + }; + } + + /// + /// W4. The value of an IEEE double at 15 significant digits (SQLite's own text precision), + /// canonicalized. A SQLite or D1 NUMERIC column is a double, so this is the most a read of + /// one can promise. Throws beyond decimal's range. + /// + public static decimal FromDouble15(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + throw new OverflowException("a decimal cannot hold " + value.ToString(CultureInfo.InvariantCulture)); + var e = value.ToString("E14", CultureInfo.InvariantCulture); // d.ddddddddddddddE+ddd + bool negative = e[0] == '-'; + int ePos = e.IndexOf('E'); + var mantissa = e.Substring(negative ? 1 : 0, ePos - (negative ? 1 : 0)).Replace(".", ""); + long exponent = long.Parse(e.Substring(ePos + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + if (!TryBuild(negative, mantissa, 1 + exponent, out var result)) + throw new OverflowException("a decimal cannot hold " + e); + return result; + } + + /// W4 as text: of . + public static string FormatDouble15(double value) => Format(FromDouble15(value)); + + private static bool IsDigit(char c) => c >= '0' && c <= '9'; + + // digits is the unsigned digit run; the decimal point sits after pointPos of them. + private static bool TryBuild(bool negative, string digits, long pointPos, out decimal value) + { + value = 0m; + int lead = 0; + while (lead < digits.Length && digits[lead] == '0') lead++; + int end = digits.Length; + while (end > lead && digits[end - 1] == '0') end--; + if (lead == end) return true; // zero, in any spelling + var d = digits.Substring(lead, end - lead); + long point = pointPos - lead; + // decimal holds at most 29 integer digits and 28 fractional places. + if (point > 29 || point < -28) return false; + var sb = new StringBuilder(); + if (negative) sb.Append('-'); + if (point <= 0) sb.Append("0.").Append('0', (int)-point).Append(d); + else if (point >= d.Length) sb.Append(d).Append('0', (int)point - d.Length); + else sb.Append(d, 0, (int)point).Append('.').Append(d, (int)point, d.Length - (int)point); + var plain = sb.ToString(); + if (!decimal.TryParse(plain, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, out value)) return false; + // decimal.Parse ROUNDS an excess digit; a round trip that does not reproduce the + // input means it was rounded (W3: an error, never silent rounding). + return Format(value) == plain; + } + + /// + /// W3 for a declared column: true when fits @precision and + /// @scale without rounding: no more fraction digits than , no + /// more integer digits than minus . + /// Counted on the canonical form, so trailing zeros never count. + /// + public static bool Fits(decimal value, int precision, int scale) + { + var text = Format(value); + if (text.StartsWith('-')) text = text.Substring(1); + var dot = text.IndexOf('.'); + var integerDigits = (dot < 0 ? text : text.Substring(0, dot)).TrimStart('0').Length; + var fractionDigits = dot < 0 ? 0 : text.Length - dot - 1; + return fractionDigits <= scale && integerDigits <= precision - scale; + } + + /// + /// W3 for a declared property: false when of + /// carries (or a with a precision) + /// and does not fit it. True for a property with no declared + /// precision (the PATCH merge asks this per key). + /// + public static bool FitsDeclared(Type owner, string property, decimal value) + { + var attributes = owner.GetProperty(property)?.GetCustomAttributes(true); + if (attributes is null) return true; + foreach (var attribute in attributes) + { + // The attribute is matched by name, not by type identity: a route in another namespace + // than the entity resolves THIS DecimalWire, while the entity carries the copy emitted + // into its own namespace, and the two DeclaredAttribute types are different types. + var type = attribute.GetType(); + if (type.Name is not (nameof(DeclaredAttribute) or nameof(NumberAttribute)) + || type.DeclaringType?.Name != nameof(DecimalWire)) continue; + var precision = type.GetProperty(nameof(DeclaredAttribute.Precision))?.GetValue(attribute); + var scale = type.GetProperty(nameof(DeclaredAttribute.Scale))?.GetValue(attribute); + return precision is int p && scale is int sc ? Fits(value, p, sc) : true; + } + return true; + } + + private static decimal ReadOne(ref Utf8JsonReader reader, int? precision = null, int? scale = null) + { + decimal result; + switch (reader.TokenType) + { + case JsonTokenType.String: + if (!TryParse(reader.GetString(), out result)) + throw new JsonException("expected a decimal as a JSON-number string or a JSON number"); + break; + case JsonTokenType.Number: + var raw = Encoding.UTF8.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray()); + if (!TryParse(raw, out result)) throw new JsonException("decimal out of range"); + break; + default: + throw new JsonException("expected a decimal as a JSON-number string or a JSON number"); + } + if (precision is int p && scale is int sc && !Fits(result, p, sc)) + throw new JsonException("decimal exceeds the declared precision and scale"); + return result; + } + + /// + /// A decimal property with a declared @precision and @scale, under + /// wire.decimals: string: the same wire form as the plain converters, and a value the + /// column cannot hold is refused, never rounded (W3). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class DeclaredAttribute(int precision, int scale) : JsonConverterAttribute + { + public int Precision { get; } = precision; + public int Scale { get; } = scale; + + public bool Accepts(decimal value) => Fits(value, Precision, Scale); + + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (typeToConvert == typeof(decimal)) return new DecimalJsonConverter(Precision, Scale); + if (typeToConvert == typeof(decimal?)) return new NullableDecimalJsonConverter(Precision, Scale); + if (typeToConvert == typeof(IList)) return new DecimalListJsonConverter(Precision, Scale); + return null; + } + } + + /// + /// A decimal property under wire.decimals: native: written as System.Text.Json writes a + /// decimal (a JSON number carrying its scale), read by the same rules as the string form + /// (a string or a number; a declared @precision and @scale refuse a value the + /// column cannot hold, never rounded). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class NumberAttribute : JsonConverterAttribute + { + public NumberAttribute() { } + + public NumberAttribute(int precision, int scale) + { + Precision = precision; + Scale = scale; + } + + public int? Precision { get; } + public int? Scale { get; } + + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (typeToConvert == typeof(decimal)) return new DecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(decimal?)) return new NullableDecimalJsonConverter(Precision, Scale, asString: false); + if (typeToConvert == typeof(IList)) return new DecimalListJsonConverter(Precision, Scale, asString: false); + return null; + } + } + + private static void WriteOne(Utf8JsonWriter writer, decimal value, bool asString) + { + if (asString) writer.WriteStringValue(Format(value)); + else writer.WriteNumberValue(value); + } + + /// A decimal property: written as the canonical string (or, from , a JSON number), read from a string or a number. + public sealed class DecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter + { + public DecimalJsonConverter() : this(null, null) { } + + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ReadOne(ref reader, precision, scale); + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + => WriteOne(writer, value, asString); + } + + /// A nullable decimal property: null is JSON null, with the key present. + public sealed class NullableDecimalJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter + { + public NullableDecimalJsonConverter() : this(null, null) { } + + public override bool HandleNull => true; + + public override decimal? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType == JsonTokenType.Null ? null : ReadOne(ref reader, precision, scale); + + public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) + { + if (value is null) writer.WriteNullValue(); + else WriteOne(writer, value.Value, asString); + } + } + + /// An @isArray decimal property, typed IList<decimal> (the one collection shape EF Core 8 on SQLite can materialize): an array of canonical strings (or JSON numbers). + public sealed class DecimalListJsonConverter(int? precision, int? scale, bool asString = true) : JsonConverter> + { + public DecimalListJsonConverter() : this(null, null) { } + + public override IList? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + if (reader.TokenType != JsonTokenType.StartArray) throw new JsonException("expected an array of decimals"); + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) list.Add(ReadOne(ref reader, precision, scale)); + return list; + } + + public override void Write(Utf8JsonWriter writer, IList value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var d in value) WriteOne(writer, d, asString); + writer.WriteEndArray(); + } + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/DurationWire.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/DurationWire.g.cs new file mode 100644 index 0000000000..3267b11185 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/DurationWire.g.cs @@ -0,0 +1,258 @@ +// +// DurationWire — the wire form of a field.duration: a fixed-length span of time, a TimeSpan in C#. +// +// Under `wire.durations: iso` a duration is an ISO-8601 duration STRING on the wire. Every port writes the canonical form +// `[-]PT[H][M][[.]S]`: hours never folded into days, zero components left out, zero +// as `PT0S`, one leading `-` for a negative span, and as many fractional digits as the port's own +// type holds, trailing zeros dropped: seven here (a TimeSpan tick is 100 ns). Every port reads that +// form plus a leading week count (`nW`, exactly 7 days) and day count (`nD`, exactly 24 hours), an +// optional `+`, and a fraction of any length; the digits past a tick are truncated toward zero, +// never rounded. Years and months are refused: a calendar span has no fixed length. +// Under `wire.durations: native` the property carries no converter and System.Text.Json's own +// TimeSpan form (`01:30:00`) is the wire form instead. A span longer than a TimeSpan +// holds (about 10.7 million days) is refused, never wrapped: that is this port's own limit, not a +// cross-port one. On a database without an interval type (SQLite, MySQL) a duration is stored as a +// 64-bit count of microseconds (ToMicros / FromMicros), the column's own precision. The rule and its +// cross-port vectors are in fixtures/duration-wire/. +// +// ONE source, two homes. This file is the package's production helper (the filter dispatcher +// parses its operands through it). The entity generator emits the SAME text, with only the +// namespace changed, as `DurationWire.g.cs` into every namespace that has a duration property, +// so the `[DurationWire.Iso]` on each property resolves inside the generated project and the +// generated entity code imports nothing from MetaObjects (CLAUDE.md). DurationWireTests gates +// the two texts byte-for-byte. Keep this file free of references to anything but the BCL. + +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Acme.Generated; + +/// The canonical wire form of a duration: format, parse, and the System.Text.Json converters. +public static class DurationWire +{ + /// The accepted form, as a regular expression (the cross-port statement of the rule; this helper parses it by hand). + public const string Pattern = @"^[-+]?P(?!$)([0-9]+W)?([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+(\.[0-9]+)?S)?)?$"; + + /// The fractional-second digits the canonical form carries at most: a TimeSpan tick is 100 ns. + public const int FractionDigits = 7; + + private const ulong TicksPerSecond = (ulong)TimeSpan.TicksPerSecond; + private const ulong TicksPerMinute = (ulong)TimeSpan.TicksPerMinute; + private const ulong TicksPerHour = (ulong)TimeSpan.TicksPerHour; + private const ulong TicksPerDay = (ulong)TimeSpan.TicksPerDay; + private const ulong TicksPerWeek = 7UL * TicksPerDay; + private const long TicksPerMicro = TimeSpan.TicksPerMillisecond / 1000L; + + /// The canonical string of : [-]PT[nH][nM][n[.f]S], up to seven fractional digits. + public static string Format(TimeSpan value) + { + long ticks = value.Ticks; + if (ticks == 0) return "PT0S"; + bool negative = ticks < 0; + // The magnitude as unsigned, so TimeSpan.MinValue (whose negation a long cannot hold) formats too. + ulong rest = negative ? (ulong)(-(ticks + 1)) + 1UL : (ulong)ticks; + ulong h = rest / TicksPerHour; + rest %= TicksPerHour; + ulong m = rest / TicksPerMinute; + rest %= TicksPerMinute; + ulong s = rest / TicksPerSecond; + ulong frac = rest % TicksPerSecond; + var sb = new StringBuilder(negative ? "-PT" : "PT"); + if (h > 0) sb.Append(h.ToString(CultureInfo.InvariantCulture)).Append('H'); + if (m > 0) sb.Append(m.ToString(CultureInfo.InvariantCulture)).Append('M'); + if (s > 0 || frac > 0) + { + sb.Append(s.ToString(CultureInfo.InvariantCulture)); + if (frac > 0) sb.Append('.').Append(frac.ToString("D7", CultureInfo.InvariantCulture).TrimEnd('0')); + sb.Append('S'); + } + return sb.ToString(); + } + + /// + /// Parse an ISO-8601 duration in the accepted form (). False for anything + /// else: years or months, lowercase designators, fractional hours, minutes or weeks, a comma, + /// per-component signs, whitespace, out-of-order components, or a span a TimeSpan cannot hold. + /// Fractional seconds past the seventh digit (a tick) are truncated toward zero. + /// + public static bool TryParse(string? text, out TimeSpan value) + { + value = TimeSpan.Zero; + if (text is null) return false; + int i = 0, n = text.Length; + bool negative = false; + if (i < n && (text[i] == '-' || text[i] == '+')) { negative = text[i] == '-'; i++; } + if (i >= n || text[i] != 'P') return false; + i++; + if (i >= n) return false; // "P" alone has no component + ulong ticks = 0; // the magnitude + try + { + checked + { + // Optional week and day counts, in that order, each at most once. + int dateOrder = 0; // 1 = W, 2 = D + while (i < n && IsDigit(text[i])) + { + ulong count = ReadDigits(text, ref i); + if (i >= n) return false; + int at = text[i] switch { 'W' => 1, 'D' => 2, _ => 0 }; + if (at == 0 || at <= dateOrder) return false; + dateOrder = at; + ticks += count * (at == 1 ? TicksPerWeek : TicksPerDay); + i++; + } + if (i < n) + { + if (text[i] != 'T') return false; + i++; + if (i >= n || !IsDigit(text[i])) return false; // `T` must be followed by a component + int order = 0; // 1 = H, 2 = M, 3 = S: each at most once, in that order + while (i < n) + { + if (!IsDigit(text[i])) return false; + ulong whole = ReadDigits(text, ref i); + if (i >= n) return false; + if (text[i] == '.') + { + i++; + int fracStart = i; + while (i < n && IsDigit(text[i])) i++; + int digits = i - fracStart; + if (digits < 1 || i >= n || text[i] != 'S' || order >= 3) return false; + // The first seven digits are whole ticks; the rest are truncated toward zero. + var seven = text.Substring(fracStart, Math.Min(digits, FractionDigits)).PadRight(FractionDigits, '0'); + ticks += whole * TicksPerSecond + ulong.Parse(seven, NumberStyles.None, CultureInfo.InvariantCulture); + order = 3; + i++; + continue; + } + int at = text[i] switch { 'H' => 1, 'M' => 2, 'S' => 3, _ => 0 }; + if (at == 0 || at <= order) return false; + order = at; + ticks += whole * (at == 1 ? TicksPerHour : at == 2 ? TicksPerMinute : TicksPerSecond); + i++; + } + } + } + } + catch (OverflowException) + { + return false; + } + // A TimeSpan holds one more tick below zero than above it. + if (negative ? ticks > (ulong)long.MaxValue + 1UL : ticks > (ulong)long.MaxValue) return false; + value = TimeSpan.FromTicks(!negative ? (long)ticks : ticks > (ulong)long.MaxValue ? long.MinValue : -(long)ticks); + return true; + } + + /// The stored form on a database without an interval type: whole microseconds, sub-microsecond ticks truncated toward zero. + public static long ToMicros(TimeSpan value) => value.Ticks / TicksPerMicro; + + /// A stored microsecond count as a TimeSpan. Throws for a count a TimeSpan cannot hold: refused, never wrapped. + public static TimeSpan FromMicros(long micros) => TimeSpan.FromTicks(checked(micros * TicksPerMicro)); + + /// Parse the accepted form, or throw . + public static TimeSpan Parse(string text) => + TryParse(text, out var value) + ? value + : throw new FormatException("not an ISO-8601 duration (such as PT1H30M; no years or months): " + text); + + /// The canonical form of an accepted ISO-8601 duration, or null when is not one. + public static string? Canonical(string? text) => TryParse(text, out var value) ? Format(value) : null; + + /// An already-parsed JSON value (a PATCH merge reads one property at a time): a string in the accepted form. + public static bool TryParseElement(JsonElement element, out TimeSpan value) + { + value = TimeSpan.Zero; + return element.ValueKind == JsonValueKind.String && TryParse(element.GetString(), out value); + } + + private static bool IsDigit(char c) => c >= '0' && c <= '9'; + + // A run of ASCII digits at i (at least one; the caller checked), advancing i past it. + private static ulong ReadDigits(string text, ref int i) + { + ulong v = 0; + while (i < text.Length && IsDigit(text[i])) + { + v = checked(v * 10 + (ulong)(text[i] - '0')); + i++; + } + return v; + } + + private static TimeSpan ReadOne(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.String || !TryParse(reader.GetString(), out var value)) + throw new JsonException("field.duration: expected an ISO-8601 duration such as PT1H30M (no years or months)"); + return value; + } + + /// + /// A duration property (TimeSpan, TimeSpan? or IList<TimeSpan>): written as the + /// canonical string, read from a string in the accepted form; anything else is refused. Attached per + /// property, so no host serializer setting can put System.Text.Json's own TimeSpan form on the wire. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class IsoAttribute : JsonConverterAttribute + { + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (typeToConvert == typeof(TimeSpan)) return new DurationJsonConverter(); + if (typeToConvert == typeof(TimeSpan?)) return new NullableDurationJsonConverter(); + if (typeToConvert == typeof(IList)) return new DurationListJsonConverter(); + return null; + } + } + + /// A duration property: the canonical string. + public sealed class DurationJsonConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ReadOne(ref reader); + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + => writer.WriteStringValue(Format(value)); + } + + /// A nullable duration property: null is JSON null, with the key present. + public sealed class NullableDurationJsonConverter : JsonConverter + { + public override bool HandleNull => true; + + public override TimeSpan? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType == JsonTokenType.Null ? null : ReadOne(ref reader); + + public override void Write(Utf8JsonWriter writer, TimeSpan? value, JsonSerializerOptions options) + { + if (value is null) writer.WriteNullValue(); + else writer.WriteStringValue(Format(value.Value)); + } + } + + /// An @isArray duration property, typed IList<TimeSpan>: an array of canonical strings. + public sealed class DurationListJsonConverter : JsonConverter> + { + public override IList? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + if (reader.TokenType != JsonTokenType.StartArray) throw new JsonException("field.duration: expected an array of ISO-8601 durations"); + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) list.Add(ReadOne(ref reader)); + return list; + } + + public override void Write(Utf8JsonWriter writer, IList value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var d in value) writer.WriteStringValue(Format(d)); + writer.WriteEndArray(); + } + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Invoice.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Invoice.g.cs new file mode 100644 index 0000000000..34c7daadbd --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/Invoice.g.cs @@ -0,0 +1,31 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace Acme.Generated; + +[Table("invoices")] +public class Invoice +{ + [Key] + [Column("id")] + public long Id { get; set; } + [Column("client_id")] + public long ClientId { get; set; } + [Column("term")] + public TimeSpan? Term { get; set; } + [Column("reminders")] + public IList Reminders { get; set; } = new List(); + [Column("total")] + [DecimalWire.Number(12, 2)] + public decimal? Total { get; set; } + [Column("doc_url")] + [JsonConverter(typeof(MetaNetValidation.AbsoluteUriJsonConverter))] + public Uri? DocUrl { get; set; } + public Address? BillingAddress { get; set; } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/InvoiceQueries.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/InvoiceQueries.g.cs new file mode 100644 index 0000000000..a770feae94 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/InvoiceQueries.g.cs @@ -0,0 +1,24 @@ +// +// Generated by MetaObjects entity-generator (reverse FK finders, ADR-0038). Do not edit by hand. +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace Acme.Generated; + +/// Reverse-relationship FK finders for Invoice (ADR-0038): explicit, +/// single-query reads — no lazy navigation, no N+1. +public static class InvoiceQueries +{ + /// The Invoice rows whose ClientId equals (one indexed query). + public static async System.Threading.Tasks.Task> FindInvoicesByClient(AppDbContext db, long clientId) => + await db.InvoiceViews.AsNoTracking().Where(e => e.ClientId == clientId).ToListAsync(); + + /// The Invoice rows whose ClientId is in (one batched IN query; anti-N+1). + public static async System.Threading.Tasks.Task> FindInvoicesByClientIn(AppDbContext db, IReadOnlyCollection clientIds) + { + if (clientIds.Count == 0) return new List(); + return await db.InvoiceViews.AsNoTracking().Where(e => clientIds.Contains(e.ClientId)).ToListAsync(); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/InvoiceView.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/InvoiceView.g.cs new file mode 100644 index 0000000000..0edca93790 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/InvoiceView.g.cs @@ -0,0 +1,34 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace Acme.Generated; + +public class InvoiceView +{ + [Key] + [Column("id")] + public long Id { get; set; } + [Column("client_id")] + public long ClientId { get; set; } + [Column("term")] + public TimeSpan? Term { get; set; } + [Column("reminders")] + public IList Reminders { get; set; } = new List(); + [Column("total")] + [DecimalWire.Number(12, 2)] + public decimal? Total { get; set; } + [Column("doc_url")] + [JsonConverter(typeof(MetaNetValidation.AbsoluteUriJsonConverter))] + public Uri? DocUrl { get; set; } + public Address? BillingAddress { get; set; } + [Column("client_name")] + [Required(AllowEmptyStrings = true)] + [MinLength(1)] + public string ClientName { get; set; } = default!; +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/MetaNetValidation.g.cs b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/MetaNetValidation.g.cs new file mode 100644 index 0000000000..8c52ac52b0 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/Golden/pre-hooks/MetaNetValidation.g.cs @@ -0,0 +1,89 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Acme.Generated; + +/// GENERATED (#234) — strict System.Text.Json converters for field.uri / +/// field.inet. Dependency-free; runs without any MetaObjects runtime. +public static class MetaNetValidation +{ + /// field.uri — an ABSOLUTE URI carrying an explicit scheme (leading/trailing + /// whitespace trimmed, WHATWG). A relative reference (example.com, /path) or malformed + /// value is rejected. + public sealed class AbsoluteUriJsonConverter : JsonConverter + { + public override Uri? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // #234 review N2: a non-string JSON token (number/object/array) rejects as a + // JsonException (a clean 400), not an InvalidOperationException from GetString(). + if (reader.TokenType != JsonTokenType.String && reader.TokenType != JsonTokenType.Null) + throw new JsonException("expected a JSON string"); + string? raw = reader.GetString(); + if (raw is null) return null; + string s = raw.Trim(); + // Require an explicit scheme so a bare path ("/x") is not inferred as a file: URI + // on .NET, AND that it parses as an absolute URI. + if (!Regex.IsMatch(s, "^[a-zA-Z][a-zA-Z0-9+.-]*:") + || !Uri.TryCreate(s, UriKind.Absolute, out Uri? uri)) + throw new JsonException("field.uri: must be an absolute URI carrying a scheme: " + raw); + return uri; + } + public override void Write(Utf8JsonWriter writer, Uri value, JsonSerializerOptions options) + => writer.WriteStringValue(value.OriginalString); + } + + /// field.inet — an IPv4 or IPv6 LITERAL only. Never a DNS lookup: a value + /// containing ':' can only be an IPv6 literal, otherwise a strict dotted-quad IPv4. + /// Hostnames, CIDR, padding, out-of-range octets are rejected. + public sealed class IPAddressJsonConverter : JsonConverter + { + public override IPAddress? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // #234 review N2: a non-string JSON token (number/object/array) rejects as a + // JsonException (a clean 400), not an InvalidOperationException from GetString(). + if (reader.TokenType != JsonTokenType.String && reader.TokenType != JsonTokenType.Null) + throw new JsonException("expected a JSON string"); + string? raw = reader.GetString(); + if (raw is null) return null; + // No trim: IP-literal grammar has no whitespace, so padding is rejected. + if (raw.IndexOf(':') >= 0) + { + if (IPAddress.TryParse(raw, out IPAddress? v6)) return v6; // ':' => IPv6 literal, no DNS + throw new JsonException("field.inet: not a valid IPv6 literal: " + raw); + } + return ParseIpv4(raw); + } + public override void Write(Utf8JsonWriter writer, IPAddress value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + + private static IPAddress ParseIpv4(string s) + { + string[] parts = s.Split('.'); + if (parts.Length != 4) + throw new JsonException("field.inet: not a valid IPv4 literal: " + s); + var octets = new byte[4]; + for (int i = 0; i < 4; i++) + { + string part = parts[i]; + // #234 review H1: reject a leading-zero octet (010 / 01) — the + // octal/decimal-parse ambiguity — matching Python ipaddress + the TS regex. + if (part.Length == 0 || part.Length > 3 || (part.Length > 1 && part[0] == '0')) + throw new JsonException("field.inet: not a valid IPv4 literal: " + s); + foreach (char ch in part) + if (!char.IsDigit(ch)) + throw new JsonException("field.inet: not a valid IPv4 literal: " + s); + int v = int.Parse(part); + if (v > 255) + throw new JsonException("field.inet: IPv4 octet out of range: " + s); + octets[i] = (byte)v; + } + return new IPAddress(octets); + } + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/IdentityStartDialectTests.cs b/server/csharp/MetaObjects.Codegen.Tests/IdentityStartDialectTests.cs new file mode 100644 index 0000000000..af48a8ee87 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/IdentityStartDialectTests.cs @@ -0,0 +1,78 @@ +// The no-restart migrations generator subclasses Npgsql's, so it is emitted only for a project +// whose dialect is Postgres (`.metaobjects/config.json` migrate.dialect, the one dialect setting a +// project declares; GenConfig.Dialect). A project on any other dialect, or one that names none, +// declares @sequenceStart without needing the Npgsql package: it gets no generator and no +// identity start in its model, so a migration can never RESTART, and the run says so. + +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public sealed class IdentityStartDialectTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Ticket", "children": [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "@fields": "id", "@generation": "increment", "@sequenceStart": 100 } } + ] } } + ] } } + """; + + private static (List Files, List Warnings) Run(string? dialect, string model = Model) + { + var load = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "m.json")]); + Assert.Empty(load.Errors); + var warnings = new List(); + var ctx = new GenContext + { + Entities = load.Root.Objects(), Root = load.Root, Warn = warnings.Add, + Config = new GenConfig { OutDir = "/unused", Namespace = "Acme", Dialect = dialect }, + }; + return (new DbContextGenerator().Generate(ctx).ToList(), warnings); + } + + [Fact] + public void A_postgres_project_gets_the_generator_and_the_start() + { + var (files, _) = Run("postgres"); + Assert.Contains(files, f => f.Path == "IdentityStartMigrationsSqlGenerator.g.cs"); + var context = files.Single(f => f.Path == "AppDbContext.g.cs").Content; + Assert.Contains("Npgsql:IdentitySequenceOptions", context); + // Wired at registration (UseIdentityStartMigrations), never by the context's OnConfiguring: + // that fails under pooling, collides with a partial context's own, and shadows a base's. + Assert.DoesNotContain("OnConfiguring", context); + Assert.Contains("global::Acme.IdentityStartMigrations.EnsureConfigured(this);", context); + Assert.Contains("public static DbContextOptionsBuilder UseIdentityStartMigrations(this DbContextOptionsBuilder options)", + files.Single(f => f.Path == "IdentityStartMigrationsSqlGenerator.g.cs").Content); + } + + [Fact] + public void A_postgres_project_without_a_start_still_gets_the_rewrite_so_removing_one_cannot_restart() + { + // Removing the last @sequenceStart is a start-only change (Npgsql: RESTART WITH 1), and the + // adopter's UseIdentityStartMigrations() call must keep compiling. + var (files, warnings) = Run("postgres", Model.Replace(", \"@sequenceStart\": 100", "")); + Assert.Contains(files, f => f.Path == "IdentityStartMigrationsSqlGenerator.g.cs"); + var context = files.Single(f => f.Path == "AppDbContext.g.cs").Content; + Assert.DoesNotContain("EnsureConfigured", context); + Assert.DoesNotContain("Npgsql:IdentitySequenceOptions", context); + Assert.Empty(warnings); + } + + [Theory] + [InlineData("sqlite")] + [InlineData("mysql")] + [InlineData(null)] + public void Any_other_project_needs_no_Npgsql_and_cannot_restart(string? dialect) + { + var (files, warnings) = Run(dialect); + var context = Assert.Single(files).Content; + Assert.DoesNotContain("Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure", context); + Assert.DoesNotContain("Npgsql:IdentitySequenceOptions", context); + Assert.Contains(warnings, w => w.Contains("@sequenceStart") && w.Contains("migrate.dialect") && w.Contains("GenConfig.Dialect")); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/IntBackedEnumArrayCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/IntBackedEnumArrayCodegenTests.cs new file mode 100644 index 0000000000..cf6cd3df1d --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/IntBackedEnumArrayCodegenTests.cs @@ -0,0 +1,186 @@ +// An int-backed field.enum ARRAY (@intValueMap + isArray) in the generated AppDbContext. +// +// Each element persists as its declared int (an EF Core 8 primitive collection whose element +// converter is the @intValueMap pair) and reads back as its member, wherever the field is +// mapped: an entity column, a member of a flattened value object, and a projection column. +// The live Postgres round trip is MetaObjects.IntegrationTests/IntBackedEnumArrayPostgresTests. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class IntBackedEnumArrayCodegenTests +{ + private static MetaRoot Load(string model) + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "ivm-array.json")]); + Assert.Empty(r.Errors); + return r.Root; + } + + private static GenContext Ctx(MetaRoot root) => new() + { + Entities = root.Objects(), Root = root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true }, + }; + + private static IReadOnlyList CompileErrors(GenContext ctx) + { + var sources = new EntityGenerator().Generate(ctx) + .Concat(new DbContextGenerator().Generate(ctx)) + .Concat(new NamesGenerator().Generate(ctx)) + .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))); + var comp = CSharpCompilation.Create( + "ivm_array_" + Guid.NewGuid().ToString("N"), sources, DbContextCompileTests.BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + return comp.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToList(); + } + + private static string DbContext(GenContext ctx) => + Assert.Single(new DbContextGenerator().Generate(ctx)).Content; + + // The UnmappedEnumValue helper is emitted only when some converter calls it. A + // flattened value object's ARRAY enum member gets the same element converter as an + // entity-level one, so when that member is the model's ONLY int-backed enum the helper + // must still be emitted, or the generated file does not compile (CS0103). + [Fact] + public void A_flattened_value_objects_int_backed_array_member_alone_still_gets_the_helper() + { + var ctx = Ctx(Load(""" + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "street" } }, + { "field.enum": { "name": "tags", "isArray": true, "@values": ["A", "B"], + "@intValueMap": { "A": 1, "B": 2 } } } + ]}}, + { "object.entity": { "name": "Order", "children": [ + { "source.rdb": { "@table": "orders" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "home", "@objectRef": "Address", "@storage": "flattened" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """)); + + var dbctx = DbContext(ctx); + Assert.Contains("b.PrimitiveCollection(p => p.Tags).HasColumnName(\"home_tags\").ElementType()" + + ".HasConversion(new global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => v == Address.AddressTags.A ? 1 : v == Address.AddressTags.B ? 2 : UnmappedEnumMember(v, \"tags\"), " + + "v => v == 1 ? Address.AddressTags.A : v == 2 ? Address.AddressTags.B : UnmappedEnumValue(v, \"tags\")));", + dbctx); + Assert.Contains("private static T UnmappedEnumValue(int stored, string field)", dbctx); + + var errors = CompileErrors(ctx); + Assert.True(errors.Count == 0, "generated code should compile:\n" + string.Join("\n", errors)); + } + + // A projection (view) column that holds an enum ARRAY needs the same element converter + // the table side gets. Without one, EF maps the List by its ORDINAL default: an + // int-backed array then reads its stored ints as ordinals, silently naming the wrong + // members (5 is not PUBLISHED's ordinal), and a string-backed one reads text[] as int[]. + [Fact] + public void A_projections_enum_array_column_gets_the_element_converter() + { + var ctx = Ctx(Load(""" + { "metadata.root": { "package": "acme", "children": [ + { "field.enum": { "name": "Status", "abstract": true, + "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"], + "@intValueMap": { "DRAFT": 0, "PUBLISHED": 5, "ARCHIVED": 9 } } }, + { "object.projection": { "name": "DocSummary", "children": [ + { "source.rdb": { "@kind": "view", "@table": "v_doc_summary" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "history", "extends": "acme::Status", "isArray": true } }, + { "field.enum": { "name": "labels", "isArray": true, "@values": ["X", "Y"] } } + ]}} + ]}} + """)); + + var dbctx = DbContext(ctx); + Assert.Contains("modelBuilder.Entity().PrimitiveCollection(x => x.History).ElementType()" + + ".HasConversion(new global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => v == Status.DRAFT ? 0 : v == Status.PUBLISHED ? 5 : v == Status.ARCHIVED ? 9 : UnmappedEnumMember(v, \"history\"), " + + "v => v == 0 ? Status.DRAFT : v == 5 ? Status.PUBLISHED : v == 9 ? Status.ARCHIVED : UnmappedEnumValue(v, \"history\")));", + dbctx); + Assert.Contains("modelBuilder.Entity().PrimitiveCollection(x => x.Labels).ElementType().HasConversion();", + dbctx); + Assert.Contains("private static T UnmappedEnumValue(int stored, string field)", dbctx); + + var errors = CompileErrors(ctx); + Assert.True(errors.Count == 0, "generated code should compile:\n" + string.Join("\n", errors)); + } + + // The entity-level path, the shape adopters write: the map inherited from a shared + // abstract enum by both a scalar and an array field. + [Fact] + public void An_entity_array_inheriting_a_shared_map_gets_the_element_converter_and_compiles() + { + var ctx = Ctx(Load(""" + { "metadata.root": { "package": "acme", "children": [ + { "field.enum": { "name": "Status", "abstract": true, + "@values": ["DRAFT", "PUBLISHED"], + "@intValueMap": { "DRAFT": 0, "PUBLISHED": 5 } } }, + { "object.entity": { "name": "Doc", "children": [ + { "source.rdb": { "@table": "docs" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "current", "extends": "acme::Status" } }, + { "field.enum": { "name": "history", "extends": "acme::Status", "isArray": true } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """)); + + var dbctx = DbContext(ctx); + Assert.Contains("modelBuilder.Entity().PrimitiveCollection(x => x.History).ElementType()" + + ".HasConversion(new global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(v => v == Status.DRAFT ? 0 : v == Status.PUBLISHED ? 5 : UnmappedEnumMember(v, \"history\"), " + + "v => v == 0 ? Status.DRAFT : v == 5 ? Status.PUBLISHED : UnmappedEnumValue(v, \"history\")));", + dbctx); + + var errors = CompileErrors(ctx); + Assert.True(errors.Count == 0, "generated code should compile:\n" + string.Join("\n", errors)); + } + + // A non-required int-backed array's column is nullable, and NULL must read back as null + // (not crash materialization), so its property is a nullable IList with no initializer — + // the rule a non-required decimal array already follows. A @required one keeps the + // non-nullable, empty-initialised IList. A flattened value object's member follows the + // same rule. + [Fact] + public void A_non_required_int_backed_array_is_a_nullable_list() + { + var ctx = Ctx(Load(""" + { "metadata.root": { "package": "acme", "children": [ + { "field.enum": { "name": "Status", "abstract": true, + "@values": ["DRAFT", "PUBLISHED"], + "@intValueMap": { "DRAFT": 0, "PUBLISHED": 5 } } }, + { "object.value": { "name": "Address", "children": [ + { "field.enum": { "name": "tags", "extends": "acme::Status", "isArray": true } } + ]}}, + { "object.entity": { "name": "Doc", "children": [ + { "source.rdb": { "@table": "docs" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "history", "extends": "acme::Status", "isArray": true } }, + { "field.enum": { "name": "trail", "extends": "acme::Status", "isArray": true, "@required": true } }, + { "field.object": { "name": "home", "@objectRef": "Address", "@storage": "flattened" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """)); + + var files = new EntityGenerator().Generate(ctx).ToList(); + var doc = files.Single(f => f.Path == "Doc.g.cs").Content; + Assert.Contains("public IList? History { get; set; }\n", doc.Replace("\r\n", "\n")); + Assert.Contains("public IList Trail { get; set; } = new List();", doc); + var address = files.Single(f => f.Path == "Address.g.cs").Content; + Assert.Contains("public IList? Tags { get; set; }", address); + + var errors = CompileErrors(ctx); + Assert.True(errors.Count == 0, "generated code should compile:\n" + string.Join("\n", errors)); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj b/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj index dc8ea823c6..b57be70108 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj +++ b/server/csharp/MetaObjects.Codegen.Tests/MetaObjects.Codegen.Tests.csproj @@ -24,6 +24,12 @@ reason this reference exists — see CodegenDriftTests.cs). --> + + + + + diff --git a/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs index 3beae425ea..8af2743ba5 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs @@ -39,13 +39,13 @@ namespace MetaObjects.Codegen.Tests; public class NamesGeneratorTests { - private static GenContext Ctx(MetaRoot root, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) => new() + private static GenContext Ctx(MetaRoot root, ColumnNamingStrategy? strategy = null) => new() { Entities = root.Objects(), Root = root, // IncludeNames: true -- this is the ON-arm helper (see CtxWithoutNames below for // the OFF arm); GenConfig.IncludeNames defaults to false, so the Task-4 // consumption-site assertions below need it set explicitly. - Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = strategy, IncludeNames = true }, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = strategy ?? ColumnNamingStrategy.SnakeCase, IncludeNames = true }, }; private static MetaRoot Load(string model) @@ -729,12 +729,12 @@ public void A_run_that_names_no_generator_is_a_usage_error_and_writes_nothing() // constant-reference spelling it replaces is GONE. // ------------------------------------------------------------------------- - private static GenContext CtxWithoutNames(MetaRoot root, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) => new() + private static GenContext CtxWithoutNames(MetaRoot root, ColumnNamingStrategy? strategy = null) => new() { Entities = root.Objects(), Root = root, Config = new GenConfig { - OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = strategy, + OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = strategy ?? ColumnNamingStrategy.SnakeCase, // The fact under test: this GenContext models a run where `names` was never // selected (e.g. `--generators entity,db-context`). GenConfig.IncludeNames // now defaults to false too, but this sets it explicitly rather than relying diff --git a/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs index 3755e1179f..135c18e8d9 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs @@ -21,6 +21,35 @@ namespace MetaObjects.Codegen.Tests; public class NamingConformanceTests { + // The corpus's application-registered strategies (ADR-0058 Amendment 1), registered the + // way an application does — through NamingStrategies, before any config is read — each + // delegating to the built-in it names. + static NamingConformanceTests() => RegisterCorpusStrategies(); + + internal static void RegisterCorpusStrategies() + { + using var doc = CasesDoc(); + foreach (var s in doc.RootElement.GetProperty("strategies").EnumerateArray()) + { + var name = s.GetProperty("name").GetString()!; + var delegateName = s.GetProperty("delegate").GetString()!; + var prefix = s.TryGetProperty("prefix", out var p) ? p.GetString()! : ""; + var upper = s.TryGetProperty("upperCase", out var u) + ? u.EnumerateArray().Select(e => e.GetString()!).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + if (s.GetProperty("for").GetString() == "columns") + { + var inner = NamingStrategies.Column(delegateName); + NamingStrategies.RegisterColumns(name, n => upper.Contains(n) ? n.ToUpperInvariant() : prefix + inner.Apply(n)); + } + else + { + var inner = NamingStrategies.Table(delegateName); + NamingStrategies.RegisterTables(name, n => upper.Contains(n) ? n.ToUpperInvariant() : prefix + inner.Apply(n)); + } + } + } + private static string FixtureDir => Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "naming-conformance", "physical-names"); @@ -62,12 +91,15 @@ private static Case LoadValidCase(string name) return new Case(name, naming, tables, columns); } - private static JsonElement LoadInvalidNaming(string name) + private static (JsonElement Naming, string[] MessageIncludes) LoadInvalidNaming(string name) { using var doc = CasesDoc(); var el = doc.RootElement.GetProperty("invalid").EnumerateArray() .Single(c => c.GetProperty("name").GetString() == name); - return el.GetProperty("naming").Clone(); + var includes = el.TryGetProperty("messageIncludes", out var m) + ? m.EnumerateArray().Select(e => e.GetString()!).ToArray() + : []; + return (el.GetProperty("naming").Clone(), includes); } /// @@ -178,7 +210,9 @@ public void Names_artifact_carries_every_column(string name) [MemberData(nameof(InvalidCaseNames))] public void Refuses_invalid_naming_blocks(string name) { - var naming = LoadInvalidNaming(name); - Assert.ThrowsAny(() => ReadNaming(naming)); + var (naming, includes) = LoadInvalidNaming(name); + var ex = Assert.ThrowsAny(() => ReadNaming(naming)); + foreach (var part in includes) Assert.Contains(part, ex.Message); + if (includes.Length > 0) Assert.Contains("C# port", ex.Message); } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs index 3443a969cd..93c885b6b1 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/NoMagicPhysicalNamesTests.cs @@ -225,22 +225,12 @@ private static readonly (string Literal, string ShouldUse, Reach Reach, string W (Proc, "ProcOutNames.SourcePrimaryProc", Reach.Constant, ""), (ProcOutCol, "ProcOutNames.TotalColumn", Reach.Constant, ""), - // --- index names: carried by the artifact, read by no C# generator ---------------- - (SecIndex, "WidgetNames.IdentityZzPhysIdxSecIndex", Reach.Dropped, - "Dropped, not KnownLiteral, and the distinction is the whole point of the row. The 0.25.0 " + - "restructure gave the artifact `identities` and `indexes` members, so the constant EXISTS " + - "here — IdentityZzPhysIdxSecIndex, resolved through the same IndexNaming.ResolveIndexName " + - "door the TS port's codegen and migrate share. Nothing in MetaObjects.Codegen reads it, " + - "because this port emits no index DDL at all (schema is TS-owned, ADR-0015): there is no " + - "HasIndex / IsUnique call anywhere in the assembly. That is CORRECT today and is exactly " + - "why it is pinned rather than left unlisted — the row asserts BOTH halves, so the day C# " + - "emits an index through the constant it fails and demands promotion to Constant, instead " + - "of the wiring landing with nothing to notice it. Its old absence from this ledger asserted " + - "neither half."), - (LkpIndex, "WidgetNames.IndexZzPhysIdxLkpIndex", Reach.Dropped, - "As SecIndex. Kept as its own row rather than folded in: ADR-0040 put uniqueness in the " + - "TYPE, so a secondary identity and a lookup index live in different artifact members and " + - "are reached by different paths. One row cannot fail for both."), + // --- index names: the DbContext's HasIndex(..., name).HasDatabaseName(name) --------- + // Promoted from Dropped by the rows doing what they were pinned to do: the context now + // emits the index vocabulary (identity.secondary, index.lookup) as EF indexes, named + // through these constants, so a model over the migrated database declares its indexes. + (SecIndex, "WidgetNames.IdentityZzPhysIdxSecIndex", Reach.Constant, ""), + (LkpIndex, "WidgetNames.IndexZzPhysIdxLkpIndex", Reach.Constant, ""), // --- a field.enum ON the value object --------------------------------------------- // VoEnumCol has NO row: a VO POCO member carries no [Column(...)] at all — the enum arm diff --git a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs index 9a36fe8c73..5b6c984290 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs @@ -100,11 +100,12 @@ public void Referenced_value_object_is_emitted_as_a_plain_poco() // own columns), and the default prefix follows the project's column rule: the names // `meta migrate` creates for the same model (core naming's ResolveFlattenedColumnPrefix). [Theory] - [InlineData(ColumnNamingStrategy.Literal, "street", "Officestreet", "homeAddress_street")] - [InlineData(ColumnNamingStrategy.UpperFirst, "Street", "OfficeStreet", "HomeAddress_Street")] + [InlineData("literal", "street", "Officestreet", "homeAddress_street")] + [InlineData("pascal", "Street", "OfficeStreet", "HomeAddress_Street")] public void DbContext_flattened_columns_follow_column_prefix_and_the_column_rule( - ColumnNamingStrategy strategy, string plain, string prefixed, string byDefault) + string strategyName, string plain, string prefixed, string byDefault) { + var strategy = NamingStrategies.Column(strategyName); const string model = """ { "metadata.root": { "package": "acme", "children": [ { "object.value": { "name": "Address", "children": [ { "field.string": { "name": "street" } } ]}}, @@ -186,7 +187,7 @@ public void DbContext_configures_owned_types_flattened_and_json() // migration never created — measured against a real Postgres, not inferred. Assert.Contains("b.Property(p => p.Kind).HasColumnName(\"homeAddress_kind\").HasConversion();", dbContext); Assert.Contains( - "b.Property(p => p.Grade).HasColumnName(\"homeAddress_grade\").HasConversion(v => v == Address.AddressGrade.LO ? 1 : 2, " + + "b.Property(p => p.Grade).HasColumnName(\"homeAddress_grade\").HasConversion(v => v == Address.AddressGrade.LO ? 1 : v == Address.AddressGrade.HI ? 2 : UnmappedEnumMember(v, \"grade\"), " + "v => v == 1 ? Address.AddressGrade.LO : v == 2 ? Address.AddressGrade.HI : UnmappedEnumValue(v, \"grade\"));", dbContext); // ...and the fail-fast helper that converter names is declared, even though the ONLY @@ -281,17 +282,12 @@ public void DbContext_names_every_flattened_member_the_migration_creates() // A NESTED value object member: ONE json column at `_` (migrate's // subtypeToSqlType returns json for field.object — a nested VO does not flatten - // recursively), so it needs its own ToJson pinned to that column. The nested builder - // is `nb`: emitting `b.` here would not compile in the GENERATED file. + // recursively). It is one jsonb property converted whole: EF Core 8 refuses a nested + // owned ToJson inside a table-split owned type, which failed model validation. Assert.Contains( - """ - b.OwnsOne(p => p.Badge, nb => - { - nb.ToJson("profile_badge"); - nb.Property(p => p.Tier).HasConversion(); - }); - """, + "b.Property(p => p.Badge).HasColumnName(\"profile_badge\").HasColumnType(\"jsonb\").HasConversion(MapJsonb.ObjectConverter<", dbContext); + Assert.DoesNotContain("nb.ToJson", dbContext); } // A field.map member USED to be the one member kind deliberately not named: the port diff --git a/server/csharp/MetaObjects.Codegen.Tests/PropertyNamingCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/PropertyNamingCodegenTests.cs new file mode 100644 index 0000000000..0f41436c75 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/PropertyNamingCodegenTests.cs @@ -0,0 +1,201 @@ +// One property-name resolver for every generator that names a field's C# property. +// +// EntityGenerator has long had a `PropertyName` hook, but the DbContext's model configuration, +// the reverse finders and the composite [PrimaryKey] spelled the name with CSharpNaming.Pascal +// on their own, so an adopter that renamed a property (an acronym, say) got entities that +// declared one name and a context that configured another: a generated tree that does not +// compile. GenConfig.PropertyNaming is the one resolver; both generators' hooks default to it. + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class PropertyNamingCodegenTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "PostalAddress", "children": [ + { "field.string": { "name": "street" } }, + { "field.string": { "name": "zipCode" } }, + { "field.enum": { "name": "kind", "@values": ["HOME", "WORK"] } } + ]}}, + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "taxId", "@maxLength": 20 } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "OrderLine", "children": [ + { "source.rdb": { "@table": "order_lines" } }, + { "field.long": { "name": "orderNo" } }, + { "field.int": { "name": "lineNo" } }, + { "field.long": { "name": "customerId", "@required": true } }, + { "field.enum": { "name": "status", "@values": ["OPEN", "SHIPPED"] } }, + { "field.timestamp": { "name": "placedAt" } }, + { "field.decimal": { "name": "amount", "@precision": 12, "@scale": 2 } }, + { "field.string": { "name": "tags", "isArray": true } }, + { "field.uri": { "name": "docUrl" } }, + { "field.object": { "name": "shipTo", "@objectRef": "PostalAddress", "@storage": "flattened" } }, + { "field.object": { "name": "billTo", "@objectRef": "PostalAddress" } }, + { "identity.primary": { "@fields": ["orderNo", "lineNo"] } }, + { "identity.reference": { "name": "refCustomer", "@fields": "customerId", "@references": "Customer" } } + ]}}, + { "object.entity": { "name": "Vehicle", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "vehicles" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["Car", "Van"] } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Car", "extends": "Vehicle", "@discriminatorValue": "Car", "children": [ + { "field.int": { "name": "seatCount" } } + ]}}, + { "object.entity": { "name": "Van", "extends": "Vehicle", "@discriminatorValue": "Van", "children": [ + { "field.int": { "name": "payloadKg" } } + ]}} + ]}} + """; + + // An acronym-style spelling for some fields, the Pascal default for the rest. + private static string Acronyms(MetaField f) => f.Name switch + { + "customerId" => "CustomerID", + "lineNo" => "LineNO", + "status" => "STATUS", + "placedAt" => "PlacedAT", + "amount" => "AMOUNT", + "tags" => "TAGS", + "docUrl" => "DocURL", + "zipCode" => "ZIPCode", + "kind" => "KIND", + "taxId" => "TaxID", + "seatCount" => "SeatCOUNT", + _ => CSharpNaming.Pascal(f.Name), + }; + + private static MetaRoot Load() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "property-naming.json")]); + Assert.Empty(r.Errors); + return r.Root; + } + + private static GenContext Ctx(MetaRoot root, Func? naming = null, string contextType = "AppDbContext") => new() + { + Entities = root.Objects(), Root = root, + Config = new GenConfig + { + OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true, + PropertyNaming = naming, ContextTypeName = contextType, + }, + }; + + private static List CompileErrors(IEnumerable files) + { + var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))); + var comp = CSharpCompilation.Create("naming_" + Guid.NewGuid().ToString("N"), trees, + DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + return comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); + } + + private static List Generate(GenContext ctx, EntityGenerator entities, DbContextGenerator dbContext) => + entities.Generate(ctx).Concat(dbContext.Generate(ctx)).Concat(new NamesGenerator().Generate(ctx)).ToList(); + + [Fact] + public void A_configured_resolver_names_every_property_the_entities_and_the_context_reference() + { + var ctx = Ctx(Load(), Acronyms); + var files = Generate(ctx, new EntityGenerator(), new DbContextGenerator()); + + Assert.Empty(CompileErrors(files)); + var line = files.Single(f => f.Path == "OrderLine.g.cs").Content; + Assert.Contains("[PrimaryKey(nameof(OrderNo), nameof(LineNO))]", line); + Assert.Contains("public long CustomerID { get; set; }", line); + var context = files.Single(f => f.Path == "AppDbContext.g.cs").Content; + Assert.Contains("HasForeignKey(e => e.CustomerID)", context); + Assert.Contains("Property(x => x.STATUS)", context); + Assert.Contains("HasDiscriminator(e => e.KIND)", context); + Assert.Contains("b.Property(p => p.ZIPCode)", context); + var finders = files.Single(f => f.Path == "OrderLineQueries.g.cs").Content; + Assert.Contains("e.CustomerID ==", finders); + } + + // The same rename through the generators' own hooks, overridden the way a subclass does it. + private sealed class AcronymEntities : EntityGenerator + { + protected override string PropertyName(MetaField field) => Acronyms(field); + } + + private sealed class AcronymContext : DbContextGenerator + { + protected override string PropertyName(MetaField field) => Acronyms(field); + } + + [Fact] + public void The_overridable_hooks_agree_across_both_generators() + { + var ctx = Ctx(Load()); + var files = Generate(ctx, new AcronymEntities(), new AcronymContext()); + + Assert.Empty(CompileErrors(files)); + Assert.Contains("e.CustomerID ==", files.Single(f => f.Path == "OrderLineQueries.g.cs").Content); + } + + [Fact] + public void Without_a_resolver_the_output_is_the_pascal_default() + { + var plain = Generate(Ctx(Load()), new EntityGenerator(), new DbContextGenerator()); + var pascal = Generate(Ctx(Load(), f => CSharpNaming.Pascal(f.Name)), new EntityGenerator(), new DbContextGenerator()); + Assert.Equal(plain.Select(f => f.Content), pascal.Select(f => f.Content)); + Assert.Empty(CompileErrors(plain)); + } + + [Fact] + public void Reverse_finders_take_the_configured_context_type() + { + var files = new EntityGenerator().Generate(Ctx(Load(), contextType: "ShopContext")).ToList(); + var finders = files.Single(f => f.Path == "OrderLineQueries.g.cs").Content; + Assert.Contains("(ShopContext db, long customerId)", finders); + Assert.DoesNotContain("AppDbContext", finders); + } + + [Fact] + public void Reverse_finders_can_be_turned_off() + { + var files = new EntityGenerator { EmitsReverseFinders = false }.Generate(Ctx(Load())).ToList(); + Assert.DoesNotContain(files, f => f.Path.EndsWith("Queries.g.cs", StringComparison.Ordinal)); + Assert.Contains(files, f => f.Path == "OrderLine.g.cs"); + } + + // E17: a class-level attribute added through the seam, without re-implementing the header. + private sealed class MarkedEntities : EntityGenerator + { + protected override void EmitClassAttributes( + StringBuilder sb, MetaObject entity, string className, ClassDeclarationKind kind, GenContext ctx) + { + if (kind is ClassDeclarationKind.MappedEntity or ClassDeclarationKind.TphSubtype) + sb.AppendLine("[System.Serializable]"); + } + } + + [Fact] + public void A_subclass_adds_a_class_attribute_through_the_seam() + { + var files = new MarkedEntities().Generate(Ctx(Load())).ToList(); + var customer = files.Single(f => f.Path == "Customer.g.cs").Content.Replace("\r\n", "\n"); + Assert.Contains("[System.Serializable]\npublic class Customer", customer); + Assert.True(customer.IndexOf("[Table(", StringComparison.Ordinal) < customer.IndexOf("[System.Serializable]", StringComparison.Ordinal)); + var vehicle = files.Single(f => f.Path == "Vehicle.g.cs").Content.Replace("\r\n", "\n"); + // After the framework's own class attributes, immediately before the declaration. + Assert.Contains("[System.Text.Json.Serialization.JsonDerivedType(typeof(Van))]\n[System.Serializable]\npublic abstract class Vehicle", vehicle); + Assert.Contains("[System.Serializable]\npublic class Car : Vehicle", files.Single(f => f.Path == "Car.g.cs").Content.Replace("\r\n", "\n")); + Assert.DoesNotContain("[System.Serializable]", files.Single(f => f.Path == "PostalAddress.g.cs").Content); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs index 45f293355f..587f90db8a 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs @@ -99,11 +99,11 @@ private static MetaRoot Load(params string[] reports) return result.Root; } - private static GenConfig Config(ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal, bool includeNames = true) => new() + private static GenConfig Config(ColumnNamingStrategy? strategy = null, bool includeNames = true) => new() { OutDir = "/unused", Namespace = Namespace, - ColumnNamingStrategy = strategy, + ColumnNamingStrategy = strategy ?? ColumnNamingStrategy.Literal, IncludeNames = includeNames, }; @@ -717,13 +717,13 @@ public void The_row_and_its_mapping_compile(bool includeNames) public void A_report_whose_DbSet_name_collides_with_an_entitys_is_refused() { // Report `Sales` and entity `Sale` both claim the DbSet property `Sales`. - var root = Load(Report("Sales", "\"@kind\": \"view\", \"@view\": \"v_sales\"")); + var root = Load(Report("Sales", "\"@kind\": \"view\", \"@view\": \"v_sales\"", measures: "\"revenue\"")); var outDir = Path.Combine(Path.GetTempPath(), "report-rows-" + Guid.NewGuid().ToString("N")); try { var ex = Assert.Throws(() => - CodegenRunner.Run(Config() with { OutDir = outDir }, root, [new EntityGenerator(), new DbContextGenerator()])); - Assert.Contains("\"Sale\" and \"Sales\" both pluralize", ex.Message); + CodegenRunner.Run(Config() with { OutDir = outDir }, root, [new DbContextGenerator()])); + Assert.Contains("DbSet property \"Sales\"", ex.Message); } finally { diff --git a/server/csharp/MetaObjects.Codegen.Tests/RequiredNestedValueObjectTests.cs b/server/csharp/MetaObjects.Codegen.Tests/RequiredNestedValueObjectTests.cs new file mode 100644 index 0000000000..8495d510c4 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/RequiredNestedValueObjectTests.cs @@ -0,0 +1,69 @@ +// A @required value-object member of a value object starts as an instance, not `default!`: +// a document that omits the member deserializes with the member present (and its own members +// at their defaults) instead of null behind a non-nullable type. + +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class RequiredNestedValueObjectTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Thresholds", "children": [ + { "field.double": { "name": "low" } } + ]}}, + { "object.value": { "name": "Shape", "abstract": true, "children": [ + { "field.string": { "name": "label" } } + ]}}, + { "object.value": { "name": "ScoringConfig", "children": [ + { "field.object": { "name": "thresholds", "@objectRef": "Thresholds", "@required": true } }, + { "field.object": { "name": "fallback", "@objectRef": "Thresholds" } }, + { "field.object": { "name": "shape", "@objectRef": "Shape", "@required": true } } + ]}} + ]}} + """; + + private static List Generate() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "required-nested.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, + }; + return new EntityGenerator().Generate(ctx).ToList(); + } + + [Fact] + public void A_required_nested_value_object_is_initialized() + { + var src = Generate().Single(f => f.Path == "ScoringConfig.g.cs").Content; + Assert.Contains("public Thresholds Thresholds { get; set; } = new Thresholds();", src); + Assert.Contains("public Thresholds? Fallback { get; set; }", src); + // An abstract value object cannot be constructed, so it keeps the filler. + Assert.Contains("public Shape Shape { get; set; } = default!;", src); + } + + [Fact] + public void A_document_that_omits_the_member_reads_back_with_it_present() + { + var trees = Generate().Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))); + var comp = CSharpCompilation.Create("required_nested_" + Guid.NewGuid().ToString("N"), trees, + DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + var type = Assembly.Load(ms.ToArray()).GetType("Acme.Generated.ScoringConfig")!; + + var config = System.Text.Json.JsonSerializer.Deserialize("{}", type)!; + Assert.NotNull(type.GetProperty("Thresholds")!.GetValue(config)); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/TphDiscriminatorEnumTests.cs b/server/csharp/MetaObjects.Codegen.Tests/TphDiscriminatorEnumTests.cs new file mode 100644 index 0000000000..69c28f3c57 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/TphDiscriminatorEnumTests.cs @@ -0,0 +1,57 @@ +// A TPH discriminator typed by a SHARED or @provided enum (FR-019): HasValue must name the enum +// where it actually lives, not as a type nested in the base entity. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class TphDiscriminatorEnumTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "field.enum": { "name": "VehicleKind", "abstract": true, "@values": ["Car", "Van"], "@provided": true } }, + { "object.entity": { "name": "Vehicle", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "vehicles" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "extends": "acme::VehicleKind" } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Car", "extends": "Vehicle", "@discriminatorValue": "Car", "children": [ + { "field.int": { "name": "seats" } } + ]}}, + { "object.entity": { "name": "Van", "extends": "Vehicle", "@discriminatorValue": "Van", "children": [ + { "field.int": { "name": "payloadKg" } } + ]}} + ]}} + """; + + // The adopter's own enum, which @provided says exists at the configured namespace. + private const string ProvidedEnum = "namespace Acme.Shared { public enum VehicleKind { Car, Van } }"; + + [Fact] + public void A_provided_enum_discriminator_names_the_provided_type_and_compiles() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "tph-provided.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ProvidedEnumNamespace = "Acme.Shared" }, + }; + var files = new EntityGenerator().Generate(ctx).Concat(new DbContextGenerator().Generate(ctx)).ToList(); + var context = files.Single(f => f.Path == "AppDbContext.g.cs").Content; + Assert.Contains(".HasValue(Acme.Shared.VehicleKind.Car).HasValue(Acme.Shared.VehicleKind.Van)", context); + + var trees = files.Select(f => f.Content).Append(ProvidedEnum) + .Select(c => CSharpSyntaxTree.ParseText(c, new CSharpParseOptions(LanguageVersion.CSharp12))); + var errors = CSharpCompilation.Create("tph_provided_" + Guid.NewGuid().ToString("N"), trees, + DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString()).ToList(); + Assert.Empty(errors); + } +} diff --git a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiDocsRenderer.cs b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiDocsRenderer.cs index ae5f0353a7..8eaa63d80d 100644 --- a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiDocsRenderer.cs +++ b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiDocsRenderer.cs @@ -131,20 +131,22 @@ public string RenderIndex(CSharpApiModel model, DocsPaths.Layout layout) if (entities.Count > 0) { sb.Append("\n## Entities\n\n"); - foreach (var u in entities) AppendIndexRow(sb, u, layout); + foreach (var u in entities) AppendIndexRow(sb, u, layout, entities); } if (templates.Count > 0) { sb.Append("\n## Templates\n\n"); - foreach (var u in templates) AppendIndexRow(sb, u, layout); + foreach (var u in templates) AppendIndexRow(sb, u, layout, templates); } return sb.ToString(); } - private void AppendIndexRow(StringBuilder sb, ApiUnit u, DocsPaths.Layout layout) + private void AppendIndexRow(StringBuilder sb, ApiUnit u, DocsPaths.Layout layout, IReadOnlyList siblings) { var href = DocsPaths.SurfaceCrossHref("README.md", DocsPaths.DocPageOutputPath(layout, u.Package, u.Node)); - sb.Append("- [").Append(u.Node).Append("](").Append(href).Append(") — ") + // Readable when two units share a short name: the label carries the package. + var label = DocsPaths.IndexLabel(u.Node, u.Package, siblings.Select(s => s.Node)); + sb.Append("- [").Append(label).Append("](").Append(href).Append(") — ") .Append(Summary(u)).Append(" (").Append(u.Symbols.Count).Append(" symbols)\n"); } diff --git a/server/csharp/MetaObjects.Codegen/ApiDocs/DocsPaths.cs b/server/csharp/MetaObjects.Codegen/ApiDocs/DocsPaths.cs index 48bc7d0478..f6b9ef7beb 100644 --- a/server/csharp/MetaObjects.Codegen/ApiDocs/DocsPaths.cs +++ b/server/csharp/MetaObjects.Codegen/ApiDocs/DocsPaths.cs @@ -40,6 +40,41 @@ public static string DocPageOutputPath(Layout layout, string? pkg, string name) return dir.Length == 0 ? file : dir + "/" + file; } + /// + /// The index label of a page: its short name, or "<name> (<package>)" when + /// another unit in the same list shares that name (case-insensitively), so the index tells the + /// two apart. is the whole list, this unit included. Mirrors the TS + /// docIndexLabel. + /// + public static string IndexLabel(string name, string? pkg, IEnumerable siblings) + { + var shared = siblings.Count(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) > 1; + return shared && !string.IsNullOrEmpty(pkg) ? $"{name} ({pkg})" : name; + } + + /// + /// Refuse two pages that resolve to the same output path, compared case-insensitively: macOS + /// and Windows hold ProductNdc.md and ProductNDC.md as one file, so one page + /// would silently replace the other. Each placement is (path, the node's FQN). Mirrors the TS + /// assertNoDuplicateDocPaths. + /// + public static void AssertNoDuplicateDocPaths(IEnumerable<(string Path, string Fqn)> placements) + { + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var placement in placements) + { + if (seen.TryGetValue(placement.Path, out var prior)) + { + throw new InvalidOperationException(prior.Path == placement.Path + ? $"docs: duplicate output path \"{placement.Path}\" from nodes {prior.Fqn} and {placement.Fqn} — rename one of them." + : $"docs: output paths \"{prior.Path}\" ({prior.Fqn}) and \"{placement.Path}\" ({placement.Fqn}) differ only " + + "in case — one file on a case-insensitive filesystem (macOS, Windows), so one page would replace " + + "the other. Rename one of them."); + } + seen[placement.Path] = placement; + } + } + /// Relative posix href from 's directory to /// (mirrors the TS surfaceCrossHref). public static string SurfaceCrossHref(string fromOutputPath, string toOutputPath) diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index d8e99fa656..839a90be56 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -326,6 +326,15 @@ public static string MapValueType(MetaField field, MetaData? root = null) public static string Pascal(string name) => name.Length == 0 ? name : char.ToUpperInvariant(name[0]) + name[1..]; + /// + /// The C# property name of : the run's + /// resolver when one is set, else + /// of the field name. The one answer every generator uses, so a + /// renamed property is renamed everywhere it is referenced. + /// + public static string PropertyName(MetaField field, GenConfig? config) => + config?.PropertyNaming?.Invoke(field) ?? Pascal(field.Name); + // ---------------------------------------------------------------------------- // Generated-SDK SYMBOL names — the single source of truth for the public C# // identifiers the generators emit. Each generator calls one of these instead of @@ -353,10 +362,29 @@ public static string Pascal(string name) => /// See fixtures/api-contract-conformance/m2m/ for the gating scenario. /// /// - public static string RoutePath(MetaObject entity) => Pluralize(ToSnakeCase(entity.Name)); + public static string RoutePath(MetaObject entity) => PluralizeCrossPort(ToSnakeCase(entity.Name)); + + /// + /// The DbSet property name for an object: its @dbSetName when it declares one, else + /// Pluralize(Pascal(name)). Every site that names the property (the context, the + /// routes, the reverse finders, the api reference) reads it here, so they always agree. + /// + public static string DbSetName(MetaObject entity) + { + // ADR-0039: own — @dbSetName names THIS object's property; an abstract base's value + // inherited by every subtype would hand them all one name. + if (entity.OwnAttr(OBJECT_ATTR_DB_SET_NAME) is not string { Length: > 0 } declared) + return Pluralize(Pascal(entity.Name)); + // Refused here, the one place every generator reads it from, rather than as a compile + // error in the generated context (or a property that hides DbContext.Database). + if (!System.Text.RegularExpressions.Regex.IsMatch(declared, "^[A-Za-z_][A-Za-z0-9_]*$") || ReservedDbSetNames.Contains(declared)) + throw new InvalidOperationException( + $"{entity.ResolutionKey()}: @dbSetName \"{declared}\" cannot be the DbSet property name — it must be a C# identifier " + + "(letters, digits and underscores, not starting with a digit) and not a DbContext member (Database, Model, ChangeTracker, ContextId)."); + return declared; + } - /// The DbSet property name for an entity: Pluralize(Pascal(name)). - public static string DbSetName(MetaObject entity) => Pluralize(Pascal(entity.Name)); + private static readonly HashSet ReservedDbSetNames = new(StringComparer.Ordinal) { "Database", "Model", "ChangeTracker", "ContextId" }; /// /// #214 — the view read-model class name for a write-through entity: <EntityPascal>View. @@ -444,18 +472,12 @@ public static string ReverseFinderInName(MetaObject entity, string fkFieldName) /// field name run through . Shared so the schema /// DDL and the [Column] annotation agree. /// - public static string Column(MetaField field, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) => - field.DbColumn ?? ApplyStrategy(field.Name, strategy); + public static string Column(MetaField field, ColumnNamingStrategy? strategy = null) => + field.DbColumn ?? ApplyStrategy(field.Name, strategy ?? ColumnNamingStrategy.SnakeCase); - /// Apply to a raw field name. - public static string ApplyStrategy(string name, ColumnNamingStrategy strategy) => strategy switch - { - ColumnNamingStrategy.Literal => name, - ColumnNamingStrategy.SnakeCase => ToSnakeCase(name), - ColumnNamingStrategy.KebabCase => ToSnakeCase(name).Replace('_', '-'), - ColumnNamingStrategy.UpperFirst => PhysicalNaming.ToUpperFirstColumn(name), - _ => name, - }; + /// Apply — a built-in or an application-registered + /// strategy (ADR-0058 Amendment 1) — to a raw field name. + public static string ApplyStrategy(string name, ColumnNamingStrategy strategy) => strategy.Apply(name); // Insert _ between lower→upper (camelCase) and at the end of an acronym run // (UPPER followed by Upper+lower, e.g. URLHost → url_host). @@ -506,8 +528,9 @@ public static bool HasPrimarySource(MetaObject obj) => /// the object subtype. /// /// - public static ObjectNames? ResolveObjectNames(MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) + public static ObjectNames? ResolveObjectNames(MetaObject obj, ColumnNamingStrategy? strategy = null) { + strategy ??= ColumnNamingStrategy.SnakeCase; // FR-044 — a report resolves no names artifact, so NamesGenerator emits none and // the report's generated row (ReportRows) spells its view and columns as literals // through the same fallback a sourceless object takes. One gate, here, so the @@ -724,8 +747,9 @@ private static bool DeclaresNamesContent(MetaObject obj) => /// walk and the emit cannot disagree about which ancestors exist. /// public static ObjectNames? ResolveSuperFragmentNames( - MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.SnakeCase) + MetaObject obj, ColumnNamingStrategy? strategy = null) { + strategy ??= ColumnNamingStrategy.SnakeCase; if (!DeclaresNamesContent(obj)) return null; var own = obj.OwnFields(); @@ -894,6 +918,20 @@ public static string ColumnRef(MetaObject owner, MetaField field, ColumnNamingSt : $"\"{Column(field, strategy)}\""; } + /// + /// The database name of an identity.secondary or index.lookup as the + /// <Owner>Names constant (IdentityUqCustEmailIndex) when this run emits + /// the names artifact and it declares the key, else the resolved name as a literal. + /// + public static string IndexRef(MetaObject owner, MetaData key, ColumnNamingStrategy strategy, bool includeNames) + { + var names = includeNames ? ResolveObjectNames(owner, strategy) : null; + var keys = key.Type == TYPE_INDEX ? names?.Indexes : names?.Identities; + return keys is not null && keys.TryGetValue(key.Name, out var k) && k.Index is not null + ? $"{NamesClassName(owner)}.{KeyMemberName(key.Type, key.Name, MEMBER_INDEX)}" + : $"\"{CSharpStringLiteral(IndexNaming.ResolveIndexName(key))}\""; + } + /// /// I1/§A6 — the <Owner>Names.Source<Role><Alias> constant reference for 's physical table/view/proc name, when BOTH C1 ( + /// The schema argument for an EF .ToView(name, schema) call (, "sales", or the + /// names constant), or the empty string when the source in declares + /// no @schema. The view counterpart of : migrate-ts + /// creates a view in its declared schema, so an unqualified mapping reads whatever the + /// connection's search_path resolves to. + /// + public static string ViewSchemaArg( + MetaObject obj, ColumnNamingStrategy strategy, bool includeNames, + string role = SOURCE_ROLE_PRIMARY) + { + var src = ResolveObjectNames(obj, strategy)?.Sources.GetValueOrDefault(role); + if (string.IsNullOrEmpty(src?.Schema)) return ""; + return SourceSchemaMember(obj, strategy, includeNames, role) is { } member + ? $", {member}" + : $", \"{src.Schema}\""; + } + /// /// The <Owner>Names class a consumption site may reference for /// — when both of 's gates hold (C1: the @@ -1081,13 +1137,29 @@ private static string PluralizeLegacySuffixOnly(string name) /// existing adopter database never sees a proposed rename. Adds already-plural /// detection () on top of the historical suffix rule /// (). + /// + /// A Greek -sis noun takes -ses (Diagnosis -> Diagnoses, + /// Analysis -> Analyses) instead of the sibilant +es rule's + /// Diagnosises. Every name this spells is a C# identifier (DbSet properties, reverse + /// finders); the REST collection segment, which IS a cross-port wire contract, keeps + /// until every port adopts the rule together. /// public static string Pluralize(string name) { if (IsAlreadyPlural(name)) return name; + if (name.Length > 3 && name.EndsWith("sis", StringComparison.OrdinalIgnoreCase)) + return name[..^2] + (char.IsUpper(name[^1]) ? "ES" : "es"); return PluralizeLegacySuffixOnly(name); } + /// + /// The pluralization every port shares for the REST collection segment + /// (fixtures/naming-conformance): without the C#-only + /// -sis rule. + /// + private static string PluralizeCrossPort(string name) => + IsAlreadyPlural(name) ? name : PluralizeLegacySuffixOnly(name); + /// /// Whether a field is non-nullable in the generated entity: required (the @required attr /// or a validator.required child), or part of the primary identity. @@ -1153,10 +1225,9 @@ public static string EnumTypeName(MetaObject entity, MetaField field) /// hardening pass over every DbContext reference. The OLDER cross-entity variant of /// this hazard (Pluralize("Address") == "Addresses", a DIFFERENT entity's /// DbSet) predates this fix and is out of scope HERE — ForeignKeyExpression's - /// lambda is what keeps THAT reference safe to compile. - /// now refuses the model outright before any of this runs when two entities share a - /// pluralized name at all (so a model that reaches this method never has that pair - /// in the first place) — the two are complementary, not redundant: this method's + /// lambda is what keeps THAT reference safe to compile. The context's DbSet-name check + /// () refuses two entities of one context that share a + /// DbSet name (so a context that reaches this method never has that pair) — the two are complementary, not redundant: this method's /// job is a bare reference staying correct even for an owner that collides only /// with ITSELF (the self-collision case), which the refusal does not and should not /// forbid. @@ -1165,84 +1236,30 @@ public static string QualifiedOwnerTypeRef(string owner, MetaObject ownerObject, Pluralize(owner) == owner ? $"global::{ValueObjectNames.Namespace(ownerObject, config)}.{owner}" : owner; /// - /// Refuse a generation run in which two DISTINCT entities/projections pluralize to - /// the same API-surface collection name ( / - /// / a generated finder name) — e.g. "Address" and "Addresses" both resolving to - /// "Addresses". Reachable from stock metadata now that no - /// longer doubles an already-plural word: before that fix "Addresses" legacy- - /// pluralized to "Addresseses", so the pair never collided; it is the SAME - /// mechanism as the pre-existing Address/Addresses nameof-vs-DbSet-property hazard - /// (see DbContextForeignKeyConfigTests), but this is the cross-ENTITY - /// collision on the DbSet NAME itself (CS0102 duplicate member), not the - /// bare-identifier-inside-OnModelCreating hazard ForeignKeyExpression guards. - /// - /// Called ONCE, over the full entity set, before any generator runs - /// () — the single choke point every DbSet/route/ - /// finder-emitting generator reads its entity list from. Scoped to every object - /// EXCEPT object.value () — a value object - /// never gets a DbSet, route, or finder name, so it cannot collide on any of them. - /// A TPH subtype IS included: a subtype's own pluralized name feeds subtype-scoped - /// generated names too, so two subtypes (or a subtype and an unrelated top-level - /// entity) sharing a pluralized name collide exactly as two top-level entities - /// would. - /// - /// A PURE function of the entity set, never of traversal order: the reported - /// pair is deterministic (first-seen-by-input-order "owner" of a plural; thrown as - /// soon as a second, different entity claims the same plural). - /// - public static void AssertNoCollectionNameCollisions(IReadOnlyList entities) - { - var ownerOfPlural = new Dictionary(StringComparer.Ordinal); - foreach (var obj in entities) - { - if (obj.IsValue()) continue; - var plural = Pluralize(obj.Name); - if (ownerOfPlural.TryGetValue(plural, out var existing)) - { - if (existing.ResolutionKey() != obj.ResolutionKey()) - throw new InvalidOperationException( - $"\"{existing.Name}\" and \"{obj.Name}\" both pluralize to the API-surface " + - $"collection name \"{plural}\" (DbSet property name, route collection segment, " + - $"generated reverse-finder name) — rename one entity so its pluralized name is " + - $"distinct. Default PHYSICAL table names are unaffected by this rule and are not " + - $"involved in the collision."); - } - else - { - ownerOfPlural[plural] = obj; - } - } - AssertNoRouteSegmentCollisions(entities); - } - - /// - /// The route half. is the entity NAME snake_cased then - /// pluralized, so "ApiKey" and "APIKey" both serve "/api_keys" while their pluralized - /// PascalCase names differ and pass the DbSet-name check above. Counts only objects that - /// actually get a route: not a value object, not abstract, not sourceless, and not a - /// TPH subtype (folded into its base's table and served under the base's route). + /// Refuse two objects that claim one generated name inside ONE scope: the DbSet properties + /// of one context, the route segments of one routes entry, the finders of one class. Each + /// generator calls this over the names it actually emits, so an object that emits nothing + /// (abstract, sourceless) never takes part, and the same short name in two scopes (two + /// packages, two contexts, two route groups) is not a collision. There is deliberately no + /// run-wide check: a name collides only where the artifact that carries it lives. + /// Deterministic: the first object to claim a name owns it, and the error names the + /// first second claimant, by its full name, with the colliding name and the scope. /// - private static void AssertNoRouteSegmentCollisions(IReadOnlyList entities) + public static void AssertUniqueInScope( + IEnumerable<(string Name, string Claimant)> claims, string what, string scope, string remedy) { - var ownerOfSegment = new Dictionary(StringComparer.Ordinal); - foreach (var obj in entities) + var claimantOf = new Dictionary(StringComparer.Ordinal); + foreach (var (name, claimant) in claims) { - if (obj.IsValue() || !InstanceArtifacts.EmitsInstanceArtifacts(obj)) continue; - // ADR-0039: OwnAttr is REQUIRED — a TPH subtype declares @discriminatorValue itself. - if (obj.OwnAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE) is string { Length: > 0 }) continue; - var segment = RoutePath(obj); - if (ownerOfSegment.TryGetValue(segment, out var existing)) - { - if (existing.ResolutionKey() != obj.ResolutionKey()) - throw new InvalidOperationException( - $"\"{existing.Name}\" and \"{obj.Name}\" both resolve " + - $"to the REST collection segment \"{segment}\" — rename one entity so its " + - $"snake_cased, pluralized route segment is distinct."); - } - else + if (!claimantOf.TryGetValue(name, out var existing)) { - ownerOfSegment[segment] = obj; + claimantOf[name] = claimant; + continue; } + if (existing == claimant) continue; + throw new InvalidOperationException( + $"\"{existing}\" and \"{claimant}\" both generate the {what} \"{name}\" in {scope} — " + + $"{remedy}. Default PHYSICAL table names are unaffected by this rule."); } } diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index a393994dcf..0ba051f8e9 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -257,6 +257,7 @@ public static GenOutcome RunGen( // type, in a different assembly, once ejected — still satisfies the gate. IncludeNames = includeNames ?? generators.Any(g => g.Name == "names"), SweepOrphans = sweepOrphans, + Dialect = MetaObjects.Config.NeutralConfig.Read(root, validateNaming: false)?.MigrateDialect, }; CodegenRunner.RunResult result; @@ -478,13 +479,7 @@ private static int Unknown(string cmd) /// The --column-naming spelling of — used only /// to format the ADR-0058 drift message below, never to parse a flag any more. - public static string ColumnNamingFlag(ColumnNamingStrategy strategy) => strategy switch - { - ColumnNamingStrategy.SnakeCase => "snake_case", - ColumnNamingStrategy.KebabCase => "kebab-case", - ColumnNamingStrategy.UpperFirst => "upperFirst", - _ => "literal", - }; + public static string ColumnNamingFlag(ColumnNamingStrategy strategy) => strategy.Name; /// /// ADR-0058 — the 1.1 physical-naming migration guide, named once so every refusal below @@ -810,13 +805,14 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) /// Every naming other than , in the order verify probes them (the /// Python port probes the same order): the used table rule first, so a different column /// rule alone is tried before a different table rule; within each, the used column rule - /// first, then snake_case, literal, kebab-case, upperFirst. + /// first, then snake_case, literal, kebab-case, pascal, then every strategy the application + /// registered, in registration order (ADR-0058 Amendment 1: the probe goes through the + /// registry, so a project on its own strategy is probed against it too). /// private static IEnumerable NamingCandidates(PhysicalNaming used) { - ColumnNamingStrategy[] columns = - [ColumnNamingStrategy.SnakeCase, ColumnNamingStrategy.Literal, ColumnNamingStrategy.KebabCase, ColumnNamingStrategy.UpperFirst]; - TableNamingRule[] tables = [TableNamingRule.SnakeCase, TableNamingRule.Literal]; + var columns = NamingStrategies.Columns; + var tables = NamingStrategies.Tables; foreach (var t in tables.OrderBy(t => t == used.Tables ? 0 : 1)) foreach (var c in columns.OrderBy(c => c == used.Columns ? 0 : 1)) if (new PhysicalNaming(c, t) is var candidate && candidate != used) @@ -825,8 +821,7 @@ private static IEnumerable NamingCandidates(PhysicalNaming used) /// The naming.tables spelling of in /// .metaobjects/config.json, for the ADR-0058 drift message below. - private static string TableNamingSpelling(TableNamingRule rule) => - rule == TableNamingRule.Literal ? "literal" : "snake_case"; + private static string TableNamingSpelling(TableNamingRule rule) => rule.Name; /// A naming as the naming block that declares it. private static string NamingBlock(PhysicalNaming naming) => diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs index 4aa2ffa2f9..73a4623273 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs @@ -70,11 +70,10 @@ internal static RunResult Run( Generators = generators, }; - // Refuse before any generator runs — the one choke point that sees the WHOLE - // entity set every DbSet/route/finder-emitting generator below reads from. - // A view-backed report gets a DbSet too, so it is part of the collision check. - CSharpNaming.AssertNoCollectionNameCollisions( - [.. ctx.Entities, .. root.Objects().Where(o => ReportRows.IsViewBacked(o) && root.InScope(o))]); + // No run-wide name-collision check: each generator refuses a name two objects claim in + // the scope it emits into (DbSet names per context, route segments per routes entry, + // finders per class; CSharpNaming.AssertUniqueInScope). The same short name in two + // packages is two names until both land in one of those scopes. Directory.CreateDirectory(config.OutDir); var results = new List(); diff --git a/server/csharp/MetaObjects.Codegen/Generator.cs b/server/csharp/MetaObjects.Codegen/Generator.cs index a49f2b81e7..457af8c247 100644 --- a/server/csharp/MetaObjects.Codegen/Generator.cs +++ b/server/csharp/MetaObjects.Codegen/Generator.cs @@ -195,6 +195,26 @@ public string Namespace /// public string ContextTypeName { get; init; } = "AppDbContext"; + /// + /// The project's SQL dialect (.metaobjects/config.json migrate.dialect: + /// postgres, sqlite, mysql, d1), or null when it names none. + /// Read only where generated code must bind one provider at compile time: the + /// @sequenceStart migrations generator subclasses Npgsql's, so it is emitted for + /// postgres alone. + /// + public string? Dialect { get; init; } + + /// + /// The C# property name of a field, for every generator that names one: the entity and + /// value-object classes, the composite [PrimaryKey], the DbContext's model + /// configuration, the reverse finders and the routes. null, the default, is the + /// PascalCased field name (). Set it to rename a property + /// (an acronym, say) once, so the class that declares it and every line that references it + /// agree. EntityGenerator.PropertyName and DbContextGenerator.PropertyName read + /// it by default; a subclass that overrides one of those hooks must override both. + /// + public Func? PropertyNaming { get; init; } + /// /// FR-019 — the C# namespace an externally-@provided shared enum is referenced /// from (ADR-0026: the namespace is per-port codegen config, never a metadata attr — diff --git a/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs index 822dbaaab7..e23bf4f5d4 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs @@ -41,6 +41,10 @@ public IEnumerable Generate(GenContext ctx) var model = new CSharpApiModelBuilder(ctx.Config, ctx.Applicability).Build(ctx.Root, ProjectName); var renderer = new CSharpApiDocsRenderer(); const DocsPaths.Layout layout = DocsPaths.Layout.Package; + // Refused before anything is yielded: two pages that are one file must not half-write. + DocsPaths.AssertNoDuplicateDocPaths(model.Units.Select(u => ( + DocsPaths.DocPageOutputPath(layout, u.Package, u.Node), + string.IsNullOrEmpty(u.Package) ? u.Node : $"{u.Package}::{u.Node}"))); foreach (var unit in model.Units) { diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index 06b8fbb41f..ccd96d12a1 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -56,6 +56,16 @@ public class DbContextGenerator : IGenerator /// protected virtual bool EmitsReferenceForeignKeys => true; + /// The C# property name of a field, for every line of model configuration that + /// names one. Default: the run's resolver, else + /// PascalCase of the field name — the same answer EntityGenerator.PropertyName gives + /// by default, so the two agree. A subclass that overrides the entity generator's hook + /// overrides this one the same way (or sets the resolver on the config instead). + protected virtual string PropertyName(MetaField field) => CSharpNaming.PropertyName(field, _config); + + // The run's config, for the PropertyName default. Set when Generate starts. + private GenConfig? _config; + /// /// True iff this object gets a DbSet on the generated AppDbContext: a persisted /// entity/projection (IsEntity() || DbView != null) that emits instance @@ -75,6 +85,7 @@ public static bool AppliesTo(MetaObject obj, MetaRoot root) => public virtual IEnumerable Generate(GenContext ctx) { ctx = ctx.Narrow(Include); + _config = ctx.Config; // FR-017 TPH: a concrete subtype shares the base's single table — it gets NO // DbSet and no per-subtype model config; the hierarchy is reached via the base // DbSet (`.OfType()`). Filter subtypes out of the emitted set entirely. @@ -86,6 +97,17 @@ public virtual IEnumerable Generate(GenContext ctx) .OrderBy(o => o.Name, StringComparer.Ordinal) .ToList(); if (objects.Count == 0) return []; + // One context, one property per name (CS0102 otherwise). Compared on the names this + // context emits, so an object that gets no DbSet here never takes part. + CSharpNaming.AssertUniqueInScope( + objects.SelectMany(o => o.IsWriteThrough() + ? new[] { (DbSetPropertyName(o, ctx), o.ResolutionKey()), (CSharpNaming.ViewDbSetName(o), o.ResolutionKey()) } + : new[] { (DbSetPropertyName(o, ctx), o.ResolutionKey()) }), + "DbSet property", "one generated context", + "rename one, or set @dbSetName on one of them"); + // In one context each relation (or routine) is one object's. Two objects + // that resolve to one (schema, name) would silently read and write one table. + AssertOnePhysicalAddress(objects, ctx); // OnModelCreating body lines (8-space indented), in a stable order. var modelLines = new List(); @@ -132,7 +154,7 @@ public virtual IEnumerable Generate(GenContext ctx) // narrowed --generators selection. Falls back to the bare literal view name // (p.DbView, non-null in this branch) otherwise -- exactly what this line // emitted before Program A added the constant. - modelLines.Add($" modelBuilder.Entity<{name}>(){noKey}.ToView({CSharpNaming.NameRef(p, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames, p.DbView!)});"); + modelLines.Add($" modelBuilder.Entity<{name}>(){noKey}.ToView({CSharpNaming.NameRef(p, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames, p.DbView!)}{CSharpNaming.ViewSchemaArg(p, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames)});"); // Enum-typed projection columns persist as their string symbol in the view // (string-backed enums, CHECK-constrained varchar/text). Without an explicit // string conversion EF defaults to the int-ordinal mapping and reads the text @@ -142,8 +164,20 @@ public virtual IEnumerable Generate(GenContext ctx) // An int-backed enum (@intValueMap) column in the view holds the declared INTEGER, // so it takes the same custom converter pair the table side gets — reading it as // a string would fail materialization exactly as the ordinal default does here. - foreach (var f in p.Fields().Where(f => f.SubType == FIELD_SUBTYPE_ENUM && !f.ResolvedIsArray())) - modelLines.Add($" modelBuilder.Entity<{name}>().Property(x => x.{CSharpNaming.Pascal(f.Name)}).{EnumConversionCall(name, p, f, ctx.Config)};"); + // An enum ARRAY column takes the table side's element converter too: left to EF's + // ordinal default, an int-backed array would read its stored ints as ORDINALS and + // silently name the wrong members, and a string-backed one would read text[] as int[]. + foreach (var f in p.Fields().Where(f => f.SubType == FIELD_SUBTYPE_ENUM)) + { + // An override that returns null for a field leaves it unconverted: no line. + if (EnumConversionCall(name, p, f, ctx) is not { } conversion) + { + continue; + } + modelLines.Add(f.ResolvedIsArray() + ? $" modelBuilder.Entity<{name}>().PrimitiveCollection(x => x.{PropertyName(f)}).ElementType().{conversion};" + : $" modelBuilder.Entity<{name}>().Property(x => x.{PropertyName(f)}).{conversion};"); + } // A field.map column on a view needs its jsonb mapping for the SAME reason: // EntityGenerator emits the Dictionary property for a projection too, so without // this loop the property carries no column type and no converter -- only an @@ -153,6 +187,7 @@ public virtual IEnumerable Generate(GenContext ctx) modelLines.Add(MapJsonbConfig(name, p, f, ctx)); // A duration column on a view reads through the same provider-dependent mapping. AddDurationConfig(name, p, p.Fields(), ctx, modelLines); + EmitShapeInheritedFacets(name, p, p.Fields(), ctx, modelLines); } foreach (var e in objects.Where(o => o.IsEntity() && !o.IsReadOnlyProjection())) { @@ -172,6 +207,19 @@ public virtual IEnumerable Generate(GenContext ctx) // finalization on a field.uri / @dbColumnType:uuid column). jsonbObjectsOnly:false — // the write entity carries flattened VOs too (its per-column spread is mapped here). EmitFieldTypeConfig(owner, e, configFields, ctx, modelLines, jsonbObjectsOnly: false); + EmitShapeInheritedFacets(owner, e, configFields, ctx, modelLines); + EmitDefaultValueConfig(e, configFields, ctx, modelLines); + if (IdentityStartOn(ctx)) EmitIdentityStartConfig(owner, e, modelLines); + EmitCommentConfig(owner, e, configFields, modelLines); + EmitIndexConfig(owner, e, ctx, modelLines); + + // identity.primary @generation: assigned — the caller supplies the key. EF's + // convention makes a single integer or Guid key generated on add, which leaves a key + // equal to the CLR default (0) out of the INSERT altogether, and has EnsureCreated + // build an identity column the model never asked for. + if (AssignedKeyField(e) is { } assignedKey) + modelLines.Add( + $" modelBuilder.Entity<{owner}>().Property(x => x.{PropertyName(assignedKey)}).ValueGeneratedNever();"); // FR-037 R1 — both non-readWrite modes are excluded from UPDATE, which EF // expresses the same way: SetAfterSaveBehavior(Ignore). They differ on INSERT, @@ -183,7 +231,7 @@ public virtual IEnumerable Generate(GenContext ctx) foreach (var f in configFields.Where(f => !f.ResolvedIsArray() && f.Mutability != MUTABILITY_READ_WRITE)) { - var prop = CSharpNaming.Pascal(f.Name); + var prop = PropertyName(f); modelLines.Add( $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);"); } @@ -208,7 +256,10 @@ public virtual IEnumerable Generate(GenContext ctx) // own columns into the base table as nullable. var tph = TphPlanBuilder.For(e, ctx.Root); if (tph is not null) + { modelLines.Add(HasDiscriminatorConfig(owner, e, tph, ctx.Config)); + EmitTphSubtypeFieldConfig(e, tph, ctx, modelLines); + } // ADR-0047 / #294 — explicit 1:N relationship configuration, with the // referential action INLINE on the call that establishes the foreign key. @@ -219,7 +270,11 @@ public virtual IEnumerable Generate(GenContext ctx) // UsingEntityConfig; see the m2mJunctions note above. junctionOwnedFks.TryGetValue(e, out var ownedByUsingEntity); if (EmitsReferenceForeignKeys) - EmitReferenceConfig(owner, e, tph, ctx, modelLines, ownedByUsingEntity); + EmitReferenceConfig(e, tph, ctx, modelLines, ownedByUsingEntity); + + // The adopter's own per-entity lines (indexes, CHECKs, query filters, computed + // columns), last so they see everything the entity was configured with. + modelLines.AddRange(EntityConfigTrailer(e, ctx)); } // #214 (FR-024 §7) — register the write-through read model against its replica view. @@ -243,7 +298,7 @@ public virtual IEnumerable Generate(GenContext ctx) var replicaRole = wt.ReplicaSource!.Role; var viewRef = CSharpNaming.NameRef( wt, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames, wt.ReplicaViewName!, replicaRole); - modelLines.Add($" modelBuilder.Entity<{view}>().ToView({viewRef});"); + modelLines.Add($" modelBuilder.Entity<{view}>().ToView({viewRef}{CSharpNaming.ViewSchemaArg(wt, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames, replicaRole)});"); // #214 [0] — the read model exposes ALL fields (incl. the derived origin.* fields), // so it needs the SAME per-field TYPE converters the write entity gets, or EF Core // fails model finalization (a field.uri / @dbColumnType:uuid / decimal-precision / @@ -262,13 +317,35 @@ public virtual IEnumerable Generate(GenContext ctx) var needsMetadataUsing = objects .Where(o => o.IsEntity() && !o.IsReadOnlyProjection()) .Any(o => o.Fields().Any(f => !f.ResolvedIsArray() - && f.Mutability != MUTABILITY_READ_WRITE)); + && f.Mutability != MUTABILITY_READ_WRITE)) + // A TPH subtype's own read-only field is configured too (EmitTphSubtypeFieldConfig). + || modelLines.Any(l => l.Contains("PropertySaveBehavior.", StringComparison.Ordinal)); + + // The identity-start rewrite. A Postgres context with an increment key gets the + // generated rewrite (and its UseIdentityStartMigrations() options extension) whether or not + // a start is declared today, so removing the last @sequenceStart still goes through it + // and the adopter's registration keeps compiling. A context that DECLARES a start refuses + // to build its model on Npgsql without the rewrite: Npgsql alone writes every start-only + // change as RESTART. + var written = objects.Where(o => o.IsEntity() && !o.IsReadOnlyProjection()).ToList(); + var declaresStart = written.Any(o => IdentityStartOf(o) is not null); + var identityStart = IdentityStartOn(ctx) && written.Any(o => IncrementKeyOf(o) is not null); + if (declaresStart && !IdentityStartOn(ctx)) + ctx.Warn($"{Name}: @sequenceStart is not in the EF model: the project names no Postgres " + + "dialect (.metaobjects/config.json migrate.dialect: \"postgres\"; a self-hosted runner sets GenConfig.Dialect), " + + "and the rewrite that keeps a changed start from restarting the sequence binds Npgsql. meta migrate still creates the start."); + if (declaresStart && identityStart) + modelLines.Insert(0, $" if (Database.ProviderName == \"{NpgsqlProviderName}\") global::{ctx.Config.Namespace}.{IdentityStartExtensionsName}.EnsureConfigured(this);"); var sb = new StringBuilder(); EmitUsings(sb, needsMetadataUsing, ctx); EmitDbSetDeclarations(sb, objects, ctx); EmitOnModelCreatingBody(sb, modelLines, ctx); - if (NeedsUnmappedEnumHelper(objects, ctx.Root)) EmitUnmappedEnumHelper(sb); + // The model lines are complete here, so the emitted text decides too: a TPH subtype's own + // int-backed enum is configured although the subtype is not in `objects`. + if (NeedsUnmappedEnumHelper(objects, ctx.Root) + || modelLines.Any(l => l.Contains(UnmappedEnumMemberHelperName + "(", StringComparison.Ordinal))) + EmitUnmappedEnumHelper(sb); // Gated on the EMITTED text, not on a second derivation of it. Every site that uses the // helper appends to modelLines (entity, read-only projection, and the flattened-VO member // block, which lands here as one OwnedTypeConfig string), and modelLines is complete by @@ -278,14 +355,19 @@ public virtual IEnumerable Generate(GenContext ctx) if (modelLines.Any(l => l.Contains(MapJsonbHelperName))) EmitMapJsonbHelper(sb); sb.AppendLine("}"); if (modelLines.Any(l => l.Contains(DurationIntervalHelperName + "."))) EmitDurationIntervalHelper(sb); - return [new EmittedFile("AppDbContext.g.cs", sb.ToString())]; + return identityStart + ? [new EmittedFile("AppDbContext.g.cs", sb.ToString()), new EmittedFile($"{IdentityStartGeneratorName}.g.cs", IdentityStartMigrationsFile(ctx))] + : [new EmittedFile("AppDbContext.g.cs", sb.ToString())]; } /// /// Extension hook — emits the file header, usings, namespace, and the opening of /// the public class AppDbContext : DbContext declaration through its primary - /// constructor (leaving the class body open for - /// + ; the caller emits the closing }). + /// constructor and any (leaving the class body open for + /// + ; the caller + /// emits the closing }). The shell is assembled from , + /// , , + /// and , so changing one of them needs no copy of this body. /// is true when a @readOnly field /// requires the Microsoft.EntityFrameworkCore.Metadata import. The default /// body reproduces the inline emission, so default output is byte-identical. @@ -296,30 +378,114 @@ protected virtual void EmitUsings(StringBuilder sb, bool needsMetadataUsing, Gen sb.AppendLine("// "); sb.AppendLine("// Generated by MetaObjects dbcontext-generator. Do not edit by hand."); sb.AppendLine("#nullable enable"); - sb.AppendLine("using Microsoft.EntityFrameworkCore;"); + var dbCtxNs = ContextNamespace(ctx); + _usingSite = (needsMetadataUsing, dbCtxNs); + try + { + foreach (var line in ContextUsings(ctx)) + { + sb.AppendLine(line); + } + } + finally + { + _usingSite = null; + } + sb.AppendLine(); + sb.AppendLine($"namespace {dbCtxNs};"); + sb.AppendLine(); + var name = ContextClassName(ctx); + sb.AppendLine($"public {(ContextIsPartial(ctx) ? "partial " : "")}class {name} : {ContextBaseClass(ctx)}"); + sb.AppendLine("{"); + if (EmitContextConstructor(ctx)) + { + sb.AppendLine($" public {name}(DbContextOptions<{name}> options) : base(options) {{ }}"); + sb.AppendLine(); + } + var members = ContextMemberLines(ctx).ToList(); + foreach (var line in members) + { + sb.AppendLine(line); + } + if (members.Count > 0) + { + sb.AppendLine(); + } + } + + // What EmitUsings knows that ContextUsings' signature does not carry: whether a read-only field + // needs the Metadata namespace, and the namespace the context is declared in (asked once, of + // ContextNamespace). Null when ContextUsings is called on its own. + private (bool NeedsMetadataUsing, string ContextNamespace)? _usingSite; + + /// + /// Hook — the using lines of the context file, complete (using X;), in order. + /// Default: Microsoft.EntityFrameworkCore; Microsoft.EntityFrameworkCore.Metadata + /// when a @readOnly field needs PropertySaveBehavior; then every namespace an + /// entity or view resolves to other than , ordinal order (the + /// context names every entity by its short name). Called once per context. Override to add a + /// namespace your lives in, or to drop one whose objects you + /// leave out of the context. + /// + protected virtual IEnumerable ContextUsings(GenContext ctx) + { + var needsMetadataUsing = _usingSite?.NeedsMetadataUsing ?? false; + var dbCtxNs = _usingSite?.ContextNamespace ?? ContextNamespace(ctx); + var lines = new List { "using Microsoft.EntityFrameworkCore;" }; if (needsMetadataUsing) - sb.AppendLine("using Microsoft.EntityFrameworkCore.Metadata;"); + lines.Add("using Microsoft.EntityFrameworkCore.Metadata;"); // Package-binding — pull in every per-package namespace the model uses. The DbContext // references EVERY entity by short name (DbSet, modelBuilder.Entity()), // so it needs a `using` for each distinct namespace the entities resolve to. - var dbCtxNs = ctx.Config.Namespace; var refNamespaces = ReportRows.WithReportRows(ctx) .Where(o => o.IsEntity() || o.DbView is not null) .Select(o => PackageBindingResolver.Resolve(ctx.Config, PackageBindingResolver.EffectivePackage(o), o.Name)) .Where(ns => !string.IsNullOrEmpty(ns) && ns != dbCtxNs) .Distinct(StringComparer.Ordinal) .OrderBy(ns => ns, StringComparer.Ordinal); - foreach (var ns in refNamespaces) - sb.AppendLine($"using {ns};"); - sb.AppendLine(); - sb.AppendLine($"namespace {ctx.Config.Namespace};"); - sb.AppendLine(); - sb.AppendLine("public class AppDbContext : DbContext"); - sb.AppendLine("{"); - sb.AppendLine(" public AppDbContext(DbContextOptions options) : base(options) { }"); - sb.AppendLine(); + lines.AddRange(refNamespaces.Select(ns => $"using {ns};")); + return lines; } + /// + /// Hook — the generated context's class name, used for the declaration and its constructor. + /// Default AppDbContext, always: is the type the + /// callable wrappers and reverse finders TAKE, which an adopter may define itself (often as a + /// subclass of AppDbContext), so it does not name this class. The file stays + /// AppDbContext.g.cs. An override renames only this declaration: the routes generator + /// still names AppDbContext in its handlers, and keeping it consistent (an owned routes + /// generator, or a ContextTypeName to match) is the adopter's. + /// + protected virtual string ContextClassName(GenContext ctx) => "AppDbContext"; + + /// + /// Hook — the namespace the context is declared in. Default: . + /// Every entity namespace other than this one gets a using. + /// + protected virtual string ContextNamespace(GenContext ctx) => ctx.Config.Namespace; + + /// + /// Hook — whether the context declares its (DbContextOptions<TContext> options) + /// constructor. Default true. Return false when a hand-written partial owns the + /// constructors (to inject services the generated one cannot know about). + /// + protected virtual bool EmitContextConstructor(GenContext ctx) => true; + + /// + /// Hook — whether the context class is declared partial. Default false, the + /// declaration it has always had. Return true to pair it with a hand-written partial. + /// + protected virtual bool ContextIsPartial(GenContext ctx) => false; + + /// + /// Hook — extra class members, as complete lines (4-space indent included), placed after the + /// constructor and before the DbSet declarations; a blank line follows them when there + /// are any. Default none. An adopter declares a partial method here + /// (partial void OnModelCreatingExtensions(ModelBuilder modelBuilder);) that its + /// call, and its hand-written partial, implement. + /// + protected virtual IEnumerable ContextMemberLines(GenContext ctx) => []; + /// /// Extension hook — emits one public DbSet<Entity> Plural { get; set; } /// (with its XML doc) per persisted object/projection, in the order computed by @@ -332,7 +498,7 @@ protected virtual void EmitDbSetDeclarations(StringBuilder sb, IReadOnlyList {CSharpNaming.Pluralize(name)} {{ get; set; }} = default!;"); + sb.AppendLine(DbSetLine(o, ctx)); // #214 — a write-through entity additionally exposes a read-model DbSet mapped to // its replica view (reads route here; the derived fields live on the view row). if (o.IsWriteThrough()) @@ -351,16 +517,117 @@ protected virtual void EmitDbSetDeclarations(StringBuilder sb, IReadOnlyList + /// The most configuration entries OnModelCreating holds before they are split into + /// ConfigureModelNN methods, called in order. One method holding every entry of a large model has + /// a stack frame too big for a thread-pool thread in a Debug build (each converter lambda is an + /// expression tree built from locals), so building the model overflows the stack. A model within the + /// limit is emitted as before. + public int ModelConfigChunkSize { get; init; } = 200; + protected virtual void EmitOnModelCreatingBody(StringBuilder sb, IReadOnlyList modelLines, GenContext ctx) { - if (modelLines.Count == 0) return; + // Materialized once: a hook may enumerate lazily, and each is asked exactly once per context. + var preamble = ContextPreambleLines(ctx).ToList(); + var trailer = ContextTrailerLines(ctx).ToList(); + if (modelLines.Count == 0 && preamble.Count == 0 && trailer.Count == 0 && !AlwaysEmitOnModelCreating(ctx)) + { + return; + } sb.AppendLine(); sb.AppendLine(" protected override void OnModelCreating(ModelBuilder modelBuilder)"); sb.AppendLine(" {"); - foreach (var line in modelLines) sb.AppendLine(line); + // Order is part of the contract: preamble first, the model lines (or the ConfigureModelNN + // calls that stand for them) in order, trailer last. + foreach (var line in preamble) + { + sb.AppendLine(line); + } + if (modelLines.Count <= ModelConfigChunkSize) + { + foreach (var line in modelLines) sb.AppendLine(line); + foreach (var line in trailer) + { + sb.AppendLine(line); + } + sb.AppendLine(" }"); + return; + } + var chunks = modelLines.Chunk(Math.Max(1, ModelConfigChunkSize)).ToList(); + for (var i = 0; i < chunks.Count; i++) + { + sb.AppendLine($" ConfigureModel{i + 1:D2}(modelBuilder);"); + } + foreach (var line in trailer) + { + sb.AppendLine(line); + } sb.AppendLine(" }"); + for (var i = 0; i < chunks.Count; i++) + { + sb.AppendLine(); + sb.AppendLine($" private void ConfigureModel{i + 1:D2}(ModelBuilder modelBuilder)"); + sb.AppendLine(" {"); + foreach (var line in chunks[i]) sb.AppendLine(line); + sb.AppendLine(" }"); + } + } + + /// + /// Hook — the base class of the generated context. Default DbContext. An adopter whose + /// context derives from its own (an auditing or tenancy context) returns that type here. + /// + protected virtual string ContextBaseClass(GenContext ctx) => "DbContext"; + + /// + /// Hook — complete lines (8-space indent included, as modelLines are) placed FIRST in + /// OnModelCreating, before any model configuration or ConfigureModelNN call. + /// Default none. An adopter returns base.OnModelCreating(modelBuilder); here. + /// These lines are NOT scanned when the file decides whether it needs the PropertySaveBehavior + /// using or the MapJsonb, UnmappedEnum and DurationInterval helpers: that decision reads the + /// entity model lines only, so a line here that depends on one of them must bring it itself. + /// + protected virtual IEnumerable ContextPreambleLines(GenContext ctx) => []; + + /// + /// Hook — complete lines placed LAST in OnModelCreating, after the model configuration + /// (or after the ConfigureModelNN calls when the model is split). Default none. An + /// adopter returns a partial extension hook call here. + /// Like the preamble, these lines are not scanned for using / helper gating. + /// + protected virtual IEnumerable ContextTrailerLines(GenContext ctx) => []; + + /// + /// Hook — whether OnModelCreating is emitted even when there is nothing to put in it + /// (no model lines, no preamble, no trailer). Default false: an empty method is omitted. + /// + protected virtual bool AlwaysEmitOnModelCreating(GenContext ctx) => false; + + /// + /// Hook — the whole DbSet declaration line for an entity (indent included), so an + /// override can add virtual or change the name. The XML doc above it is emitted by the + /// caller. A write-through entity's read-model DbSet is not routed through here. + /// + protected virtual string DbSetLine(MetaObject entity, GenContext ctx) + { + var name = CSharpNaming.Pascal(entity.Name); + return $" public DbSet<{name}> {DbSetPropertyName(entity, ctx)} {{ get; set; }} = default!;"; } + /// + /// Hook — the DbSet property name for an object: its @dbSetName, else the derived + /// plural (). The context's collision check compares + /// this name, so an override that renames the property is checked as it emits it. + /// + protected virtual string DbSetPropertyName(MetaObject entity, GenContext ctx) => CSharpNaming.DbSetName(entity); + + /// + /// Hook — extra configuration lines appended after everything else an entity is configured + /// with (field types, defaults, M:N, TPH discriminator, references). Default none. For the + /// adopter's own per-entity lines: indexes, CHECK constraints, query filters, computed columns. + /// Called for each table-mapped entity (not a TPH subtype, not a read-only projection). + /// + protected virtual IEnumerable EntityConfigTrailer(MetaObject entity, GenContext ctx) => []; + // R6 Plan 2b — EF mapping for a @dbColumnType physical-override field. The CLR // property type is unchanged (the logical field's native binding); only the DB // column type — and, for uuid, a string↔Guid value converter — is configured so @@ -375,9 +642,9 @@ protected virtual void EmitOnModelCreatingBody(StringBuilder sb, IReadOnlyList().Property(x => x.{prop})"; return f.DbColumnType switch { @@ -401,18 +668,23 @@ protected virtual void EmitOnModelCreatingBody(StringBuilder sb, IReadOnlyList.), which round-trips through the enum's // HasConversion() as the text symbol; otherwise the raw string value. - private static string HasDiscriminatorConfig(string owner, MetaObject baseEntity, TphPlan tph, GenConfig config) + private string HasDiscriminatorConfig(string owner, MetaObject baseEntity, TphPlan tph, GenConfig config) { var discField = baseEntity.FindField(tph.DiscriminatorField); - var discProp = CSharpNaming.Pascal(tph.DiscriminatorField); + var discProp = discField is not null ? PropertyName(discField) : CSharpNaming.Pascal(tph.DiscriminatorField); var isEnum = discField is not null && discField.SubType == FIELD_SUBTYPE_ENUM; // The enum type is nested in the base class, so qualify it (.) // when referenced from the DbContext (a sibling type) — global::-qualified // (CSharpNaming.QualifiedOwnerTypeRef), not bare: a bare can resolve to // this DbContext's own same-named DbSet property instead of the entity type // (CS1061), which an already-plural base entity name now reaches. + // FR-019: a SHARED (root-level abstract) or @provided enum is not nested in the base — + // it is named where it lives, exactly as EnumConversionCall names it. var enumType = discField is not null && isEnum - ? $"{CSharpNaming.QualifiedOwnerTypeRef(owner, baseEntity, config)}.{CSharpNaming.EnumTypeName(baseEntity, discField)}" : null; + ? Fr019SharedEnum.SharedEnumForField(discField) is { } shared + ? Fr019SharedEnum.SharedEnumTypeReference(shared, config) + : $"{CSharpNaming.QualifiedOwnerTypeRef(owner, baseEntity, config)}.{CSharpNaming.EnumTypeName(baseEntity, discField)}" + : null; var sb = new StringBuilder(); sb.Append($" modelBuilder.Entity<{owner}>().HasDiscriminator(e => e.{discProp})"); @@ -453,7 +725,7 @@ private static string HasDiscriminatorConfig(string owner, MetaObject baseEntity /// base+subtype dual declaration that made the adopter's mutation unreliable. /// private void EmitReferenceConfig( - string owner, MetaObject entity, TphPlan? tph, GenContext ctx, List modelLines, + MetaObject entity, TphPlan? tph, GenContext ctx, List modelLines, IReadOnlySet? ownedByUsingEntity) { // Seeded with the junction columns UsingEntityConfig already configures, so those @@ -465,7 +737,7 @@ private void EmitReferenceConfig( foreach (var reference in entity.ReferenceIdentities().Where(r => r.Enforce)) { if (!emitted.Add(FkKey(reference))) continue; - if (ReferenceConfig(owner, entity, reference, ctx) is { } line) modelLines.Add(line); + modelLines.AddRange(ReferenceConfig(entity, reference, ctx)); } if (tph is null) return; @@ -479,12 +751,665 @@ private void EmitReferenceConfig( .OfType().Where(r => r.Enforce)) { if (!emitted.Add(FkKey(reference))) continue; - if (ReferenceConfig(CSharpNaming.Pascal(st.Entity.Name), st.Entity, reference, ctx) is { } line) - modelLines.Add(line); + modelLines.AddRange(ReferenceConfig(st.Entity, reference, ctx)); + } + } + + // The abstract levels between the base and its concrete subtypes, once each, in the order + // the subtypes reach them (nearest first). Their own references were on no type at all: + // the column existed, and EF knew no relationship over it. An intermediate that is a class + // (EmitAbstractShapes) is configured on its own builder, which makes it part of the EF + // hierarchy and the foreign key one for all its leaves; one that is not (its fields are + // folded into each concrete subtype) is configured on each of those subtypes. + var concrete = new HashSet(tph.Subtypes.Select(s => s.Entity)); + var seen = new HashSet(); + foreach (var st in tph.Subtypes) + { + for (var mid = st.Entity.SuperData as MetaObject; + mid is not null && !ReferenceEquals(mid, entity); + mid = mid.SuperData as MetaObject) + { + if (concrete.Contains(mid)) continue; // a concrete subtype: the pass above + if (!seen.Add(mid)) break; // reached before, and so was everything above it + var references = mid.OwnIdentities().OfType().Where(r => r.Enforce).ToList(); + if (references.Count == 0) continue; + var isClass = !InstanceArtifacts.IsAbstract(mid) || ctx.Config.EmitAbstractShapes; + // Folded into its subtypes: the TOPMOST concrete ones only. A concrete subtype under + // another concrete one below `mid` inherits that one's foreign key (EF builds the FK + // on the declaring entity type), so configuring it again would repeat the line. + var leaves = isClass + ? [mid] + : tph.Subtypes.Select(s => s.Entity) + .Where(e => DescendsFrom(e, mid) && !HasConcreteAncestorBelow(e, mid, concrete)) + .ToList(); + foreach (var reference in references) + { + if (!emitted.Add(FkKey(reference))) continue; + foreach (var leaf in leaves) + modelLines.AddRange(ReferenceConfig(leaf, reference, ctx)); + } } } } + // Whether a concrete subtype sits strictly between `obj` and `ancestor` in obj's extends chain. + private static bool HasConcreteAncestorBelow(MetaObject obj, MetaObject ancestor, IReadOnlySet concrete) + { + for (var cursor = obj.SuperData as MetaObject; cursor is not null && !ReferenceEquals(cursor, ancestor); + cursor = cursor.SuperData as MetaObject) + if (concrete.Contains(cursor)) return true; + return false; + } + + private static bool DescendsFrom(MetaObject obj, MetaObject ancestor) + { + for (var cursor = obj.SuperData; cursor is not null; cursor = cursor.SuperData) + if (ReferenceEquals(cursor, ancestor)) return true; + return false; + } + + /// + /// Core @default as the column default EF knows about, so a database EF creates carries + /// it and the model agrees with the schema meta migrate builds: + /// + /// A literal the property's CLR type can hold (bool, string, a number, an enum member) + /// is HasDefaultValue(v).HasSentinel(v). The sentinel is the load-bearing half. EF + /// leaves a property with a database default out of the INSERT when its value equals the + /// sentinel, which is otherwise the CLR default — so a bool defaulting to true would + /// store true when the app set false, and a nullable one its explicit NULL. + /// With the declared default as the sentinel, EF omits only the value the database would + /// write anyway, for nullable and non-nullable properties alike. + /// A SQL expression (now(), anything function-like, current_timestamp; + /// a bare now is now(), as migrate reads it) or a literal of a type with no + /// C# literal here (a timestamp, date, uuid) is HasDefaultValueSql(...), on a + /// REQUIRED property only. Its sentinel stays the CLR default, which on a non-nullable + /// property means "not supplied"; on a nullable one it is NULL, and an app that set NULL on + /// purpose would get the default instead, so those are left out. + /// + /// Not configured: primary-key fields, arrays, value objects, maps and durations. + /// + private void EmitDefaultValueConfig( + MetaObject entity, IEnumerable fields, GenContext ctx, List modelLines) + { + foreach (var f in fields) + { + modelLines.AddRange(DefaultValueConfig(entity, f, ctx)); + } + } + + /// + /// Hook — the column-default configuration lines for one field (see the summary above): + /// HasDefaultValue/HasSentinel, HasDefaultValueSql, or none. Called for EVERY field + /// of a configured entity, so an override can add a default the metadata does not carry; + /// the base returns nothing for a primary-key field, an array, a field with no @default + /// and a @dbColumnType override. The emitted class is the entity's own name. + /// + protected virtual IEnumerable DefaultValueConfig(MetaObject entity, MetaField f, GenContext ctx) + { + var className = CSharpNaming.Pascal(entity.Name); + var pk = entity.PrimaryIdentity()?.Fields ?? []; + if (f.Default is not { } raw || f.ResolvedIsArray() || pk.Contains(f.Name)) + { + return []; + } + if (CSharpNaming.DbColumnTypeClrOverride(f) is not null) + { + return []; + } + var lhs = $" modelBuilder.Entity<{className}>().Property(x => x.{PropertyName(f)})"; + if (DefaultLiteral(className, entity, f, raw, ctx.Config) is { } literal) + { + return [$"{lhs}.HasDefaultValue({literal}).HasSentinel({literal});"]; + } + if (!CSharpNaming.IsRequired(entity, f) || DefaultSql(f, raw) is not { } sql) + { + return []; + } + return [$"{lhs}.HasDefaultValueSql(\"{CSharpNaming.CSharpStringLiteral(sql)}\");"]; + } + + /// + /// Refuse two objects of one context that resolve to one physical address: the same name + /// in the same @schema (no schema is the dialect's default, one address too). Tables, + /// views and materialized views share one namespace, as PostgreSQL's relations do; stored + /// procedures and table functions share another. The same name in two schemas is two + /// addresses. Keyed on every source the object maps (a write-through's table and view), + /// resolved through the names artifact's own resolution, so a default table name counts. + /// An object that extends another and inherits its source is one EF hierarchy over that + /// table, not a second claim on it. + /// + private static void AssertOnePhysicalAddress(IReadOnlyList objects, GenContext ctx) + { + var owners = new Dictionary(StringComparer.Ordinal); + static bool Extends(MetaObject sub, MetaObject super) + { + for (var s = sub.SuperData; s is not null; s = s.SuperData) + if (ReferenceEquals(s, super)) return true; + return false; + } + foreach (var o in objects) + { + if (CSharpNaming.ResolveObjectNames(o, ctx.Config.ColumnNamingStrategy) is not { } names) continue; + foreach (var src in names.Sources.Values) + { + if (src.PhysicalName is not { Length: > 0 } physical) continue; + var routine = src.Kind is MetaObjects.Persistence.Source.SourceConstants.SOURCE_KIND_STORED_PROC + or MetaObjects.Persistence.Source.SourceConstants.SOURCE_KIND_TABLE_FUNCTION; + var address = (src.Schema is { Length: > 0 } schema ? $"\"{schema}\"." : "") + $"\"{physical}\""; + var key = (routine ? "routine " : "relation ") + address; + if (!owners.TryGetValue(key, out var existing)) + { + owners[key] = (o, src.Kind); + continue; + } + if (ReferenceEquals(existing.Claimant, o) || Extends(o, existing.Claimant) || Extends(existing.Claimant, o)) continue; + throw new InvalidOperationException( + $"ERR_DUPLICATE_SQL_NAME: \"{existing.Claimant.ResolutionKey()}\" ({existing.Kind}) and \"{o.ResolutionKey()}\" ({src.Kind}) both map to " + + $"{address} in one generated context — rename one (its source @table / @view / physical name), " + + "or give one its own @schema."); + } + } + } + + /// + /// identity.primary @sequenceStart on a single-field increment key: a fresh + /// database's identity begins there, as meta migrate creates it (GENERATED BY + /// DEFAULT AS IDENTITY (START WITH n)). Set as the annotation Npgsql's + /// HasIdentityOptions writes, inside the provider check, so the context itself needs + /// no Npgsql reference. A start-only change never restarts the sequence: Npgsql alone would + /// write RESTART WITH n (or RESTART WITH 1 when the start is removed), handing + /// out ids live rows hold, so the adopter registers the generated rewrite + /// (), and the context refuses to build its model + /// on Npgsql without it. + /// + private static void EmitIdentityStartConfig(string owner, MetaObject entity, List modelLines) + { + if (IdentityStartOf(entity) is not { } start) return; + modelLines.Add($" if (Database.ProviderName == \"{NpgsqlProviderName}\")"); + modelLines.Add($" modelBuilder.Entity<{owner}>().Property(x => x.{CSharpNaming.Pascal(start.Field.Name)}).HasAnnotation(" + + $"\"{NpgsqlIdentityOptionsAnnotation}\", \"'{start.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}', '1', '', '', 'False', '1'\");"); + } + + private const string NpgsqlIdentityOptionsAnnotation = "Npgsql:IdentitySequenceOptions"; + private const string IdentityStartGeneratorName = "IdentityStartMigrationsSqlGenerator"; + private const string IdentityStartDifferName = "IdentityStartMigrationsModelDiffer"; + private const string IdentityStartExtensionsName = "IdentityStartMigrations"; + + /// + /// Whether this run emits @sequenceStart and the rewrite: the project's dialect is + /// Postgres. The rewrite subclasses Npgsql's generator, so a project on any other dialect, or + /// naming none, gets neither it nor the start in its model, and a migration can never RESTART. + /// + private static bool IdentityStartOn(GenContext ctx) => + string.Equals(ctx.Config.Dialect, "postgres", StringComparison.OrdinalIgnoreCase); + + /// The field of an entity's single-field increment key — the key a start applies to — or null. + private static MetaField? IncrementKeyOf(MetaObject entity) => + entity.PrimaryIdentity() is MetaPrimaryIdentity { Fields.Count: 1, Generation: GENERATION_INCREMENT } pk + ? entity.FindField(pk.Fields[0]) + : null; + + /// The @sequenceStart of an entity's single-field increment key, or null. + private static (MetaField Field, long Value)? IdentityStartOf(MetaObject entity) + { + if (IncrementKeyOf(entity) is not { } field || entity.PrimaryIdentity()!.Attr(DbConstants.IDENTITY_ATTR_SEQUENCE_START) is not { } raw) return null; + return (field, Convert.ToInt64(raw, System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// The identity-start rewrite a Postgres context's adopter registers with + /// UseIdentityStartMigrations(), after UseNpgsql(...). Every start-only identity + /// change — a start added, changed or removed — is + /// ALTER TABLE … ALTER COLUMN … SET START WITH n, which records where a fresh sequence + /// (or a deliberate restart) begins and leaves the current value where live rows put it; a + /// removed start is set back to the identity default. Npgsql alone writes RESTART + /// for all three. It applies at both ends: the differ scaffolds the change as that SQL (so a + /// migration added through it is safe wherever it runs), and the SQL generator rewrites a + /// migration scaffolded without it. + /// Registration, not the context's OnConfiguring: EF refuses an + /// OnConfiguring that replaces services under pooling, a partial context could not + /// declare its own, and a base context's would be shadowed. A context that declares a start + /// therefore checks, as its model is built, that the rewrite is registered, and throws if not. + /// This file references Npgsql at compile time (it subclasses + /// NpgsqlMigrationsSqlGenerator), and it is emitted only for a Postgres project. + /// Reflection would keep the project free of the reference, but a decorator built by + /// reflection over Npgsql's generator is the runtime binding ADR-0001 rules out. The differ + /// wraps EF's own, which DI constructs with whatever that EF version's constructor needs. + /// + private string IdentityStartMigrationsFile(GenContext ctx) + { + var ns = ctx.Config.Namespace; + return $$""" + // + // Generated by MetaObjects db-context. Do not edit by hand. + #nullable enable + #pragma warning disable EF1001 // Npgsql's options interface and EF's differ live in Internal namespaces. + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Metadata; + using Microsoft.EntityFrameworkCore.Migrations; + using Microsoft.EntityFrameworkCore.Migrations.Internal; + using Microsoft.EntityFrameworkCore.Migrations.Operations; + using Microsoft.EntityFrameworkCore.Storage; + using Microsoft.Extensions.DependencyInjection; + using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; + using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; + + namespace {{ns}}; + + /// Registers the identity-start rewrite: no start-only identity change ever + /// restarts the sequence. + public static class {{IdentityStartExtensionsName}} + { + /// Call after UseNpgsql(...), wherever the context is configured: the + /// app's registration (pooled or not), an IDesignTimeDbContextFactory, or the + /// context's own OnConfiguring. Without Npgsql it does nothing. + public static DbContextOptionsBuilder UseIdentityStartMigrations(this DbContextOptionsBuilder options) + { + if (options.Options.FindExtension() is null) return options; + return options + .ReplaceService() + .ReplaceService(); + } + + /// + public static DbContextOptionsBuilder UseIdentityStartMigrations(this DbContextOptionsBuilder options) + where TContext : DbContext => + (DbContextOptionsBuilder)UseIdentityStartMigrations((DbContextOptionsBuilder)options); + + /// Throws unless the rewrite is registered. A context that declares an + /// identity start calls it as its model is built on Npgsql. + public static void EnsureConfigured(DbContext context) + { + var replaced = context.GetService().FindExtension()?.ReplacedServices; + if (replaced is not null && replaced.TryGetValue((typeof(IMigrationsSqlGenerator), null), out var generator) + && generator == typeof({{IdentityStartGeneratorName}})) + return; + throw new InvalidOperationException( + $"{context.GetType().Name} declares an identity start (@sequenceStart), and Npgsql alone writes a changed start as RESTART, " + + "which hands out ids live rows hold. Call UseIdentityStartMigrations() after UseNpgsql(...) where the context is " + + "configured (its registration, an IDesignTimeDbContextFactory, or its OnConfiguring)."); + } + + private const string Options = "{{NpgsqlIdentityOptionsAnnotation}}"; + private const string Strategy = "Npgsql:ValueGenerationStrategy"; + + // The serialized options are "'start', 'increment', 'min', 'max', 'cyclic', 'cache'"; + // a missing annotation is Npgsql's defaults, and an empty start is no start. + private const string Defaults = "'', '1', '', '', 'False', '1'"; + private static string[] Parts(string? options) => (options ?? Defaults).Split(',').Select(p => p.Trim().Trim('\'')).ToArray(); + private static string? Start(string? options) => Parts(options)[0] is { Length: > 0 } start ? start : null; + + /// with the start replaced: what the old column + /// must say for Npgsql to see no start change. + private static string WithStart(string? options, string? start) => + "'" + (start ?? "") + "'" + (options ?? Defaults)[(options ?? Defaults).IndexOf(',')..]; + + /// Where an identity without a start begins: its minimum ascending, its + /// maximum descending (PostgreSQL's default). + private static string DefaultStart(string? options) + { + var p = Parts(options); + var descending = long.TryParse(p[1], out var increment) && increment < 0; + return descending ? (p[3] is { Length: > 0 } max ? max : "-1") : (p[2] is { Length: > 0 } min ? min : "1"); + } + + private static bool IsIdentity(object? strategy) => strategy?.ToString()?.StartsWith("Identity", StringComparison.Ordinal) == true; + + /// The start-only change an alter carries: the start it moves to, or null + /// when it moves none. + internal static (string Sql, string OldOptions)? StartChange(AlterColumnOperation operation, ISqlGenerationHelper sql) + { + if (!IsIdentity(operation[Strategy]) || !IsIdentity(operation.OldColumn[Strategy])) return null; + var to = operation[Options] as string; + var from = operation.OldColumn[Options] as string; + if (Start(to) == Start(from)) return null; + var statement = "ALTER TABLE " + sql.DelimitIdentifier(operation.Table, operation.Schema) + + " ALTER COLUMN " + sql.DelimitIdentifier(operation.Name) + + " SET START WITH " + (Start(to) ?? DefaultStart(to)) + sql.StatementTerminator; + return (statement, WithStart(from, Start(to))); + } + + /// Whether the alter changes nothing but its identity start. + internal static bool OnlyTheStart(AlterColumnOperation operation, string oldOptions) + { + var o = operation.OldColumn; + if (operation.ClrType != o.ClrType || operation.ColumnType != o.ColumnType || operation.IsNullable != o.IsNullable + || operation.IsUnicode != o.IsUnicode || operation.IsFixedLength != o.IsFixedLength || operation.MaxLength != o.MaxLength + || operation.Precision != o.Precision || operation.Scale != o.Scale || operation.IsRowVersion != o.IsRowVersion + || !Equals(operation.DefaultValue, o.DefaultValue) || operation.DefaultValueSql != o.DefaultValueSql + || operation.ComputedColumnSql != o.ComputedColumnSql || operation.IsStored != o.IsStored + || operation.Comment != o.Comment || operation.Collation != o.Collation) + return false; + var names = operation.GetAnnotations().Select(a => a.Name).Union(o.GetAnnotations().Select(a => a.Name)); + return names.All(n => n == Options + ? Parts(operation[n] as string).SequenceEqual(Parts(oldOptions)) + : Equals(operation[n], o[n])); + } + } + + /// Npgsql's migrations SQL, except that a start-only identity change is + /// SET START WITH only: the current value never moves (no RESTART), because + /// live rows already hold the ids below it. + public class {{IdentityStartGeneratorName}}( + MigrationsSqlGeneratorDependencies dependencies, INpgsqlSingletonOptions npgsqlOptions) + : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlOptions) + { + private const string Options = "{{NpgsqlIdentityOptionsAnnotation}}"; + + protected override void Generate(AlterColumnOperation operation, IModel? model, MigrationCommandListBuilder builder) + { + if ({{IdentityStartExtensionsName}}.StartChange(operation, Dependencies.SqlGenerationHelper) is not { } change) + { + base.Generate(operation, model, builder); + return; + } + builder.AppendLine(change.Sql); + builder.EndCommand(); + // The rest of the column change, with the old start made the new one, so Npgsql + // sees no start change; restored after, as a migration's operations are reused. + var old = operation.OldColumn[Options]; + operation.OldColumn[Options] = change.OldOptions; + try + { + base.Generate(operation, model, builder); + } + finally + { + operation.OldColumn[Options] = old; + } + } + } + + /// EF's model differ, except that a start-only identity change is scaffolded + /// as SET START WITH SQL, so the migration never restarts the sequence even where + /// it runs without this rewrite. + public sealed class {{IdentityStartDifferName}}(IServiceProvider services, ISqlGenerationHelper sql) : IMigrationsModelDiffer + { + private const string Options = "{{NpgsqlIdentityOptionsAnnotation}}"; + private readonly IMigrationsModelDiffer _inner = ActivatorUtilities.CreateInstance(services); + + public bool HasDifferences(IRelationalModel? source, IRelationalModel? target) => _inner.HasDifferences(source, target); + + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + { + var result = new List(); + foreach (var operation in _inner.GetDifferences(source, target)) + { + if (operation is not AlterColumnOperation alter || {{IdentityStartExtensionsName}}.StartChange(alter, sql) is not { } change) + { + result.Add(operation); + continue; + } + result.Add(new SqlOperation { Sql = change.Sql }); + if ({{IdentityStartExtensionsName}}.OnlyTheStart(alter, change.OldOptions)) continue; + alter.OldColumn[Options] = change.OldOptions; + result.Add(alter); + } + return result; + } + } + """ + "\n"; + } + + /// + /// Core @description as the database comment, as meta migrate writes it + /// (COMMENT ON TABLE / COMMENT ON COLUMN): an entity's on its table, a field's + /// on its column, read resolving. Set through the metadata API, which reaches every property + /// kind; a flattened value object is not a column (its members are), so it gets none. + /// + private void EmitCommentConfig(string owner, MetaObject entity, IEnumerable fields, List modelLines, + bool tableComment = true) + { + if (tableComment && entity.Attr(MetaObjects.Core.Documentation.DocumentationConstants.DOC_ATTR_DESCRIPTION) is string { Length: > 0 } table) + modelLines.Add($" modelBuilder.Entity<{owner}>().Metadata.SetComment(\"{CSharpNaming.CSharpStringLiteral(table)}\");"); + foreach (var f in fields) + { + if (f.Attr(MetaObjects.Core.Documentation.DocumentationConstants.DOC_ATTR_DESCRIPTION) is not string { Length: > 0 } text) continue; + if (f.SubType == FIELD_SUBTYPE_OBJECT || f.SubType == FIELD_SUBTYPE_MAP) continue; + modelLines.Add($" modelBuilder.Entity<{owner}>().Metadata.FindProperty(\"{PropertyName(f)}\")?.SetComment(\"{CSharpNaming.CSharpStringLiteral(text)}\");"); + } + } + + /// + /// The index vocabulary as EF indexes, named as meta migrate names them, so a model + /// over the migrated database declares the indexes it has (an EF migration would otherwise + /// drop them): a field's @unique (<table>_<column>_unique), an + /// identity.secondary (unique) and an index.lookup, each with @where + /// (HasFilter), @using (HasMethod) and @orders + /// (IsDescending), under its resolved name. An @expr index is not one EF can + /// model; meta migrate creates it. + /// + private void EmitIndexConfig(string owner, MetaObject entity, GenContext ctx, List modelLines, + IEnumerable? ownFields = null, Func? declares = null) + { + var table = CSharpNaming.Table(entity); + // A TPH subtype passes the fields and index nodes it adds beyond its ancestors in the model. + foreach (var f in ownFields ?? entity.Fields()) + { + // ADR-0039: resolving — @unique may be inherited via extends. + if (f.Attr(FIELD_ATTR_UNIQUE) is not true || f.ResolvedIsArray()) continue; + var column = CSharpNaming.Column(f, ctx.Config.ColumnNamingStrategy); + modelLines.Add($" modelBuilder.Entity<{owner}>().HasIndex(x => x.{PropertyName(f)}).IsUnique()" + + $".HasDatabaseName(\"{CSharpNaming.CSharpStringLiteral($"{table}_{column}_unique")}\");"); + } + var nodes = entity.SecondaryIdentities().Select(i => ((MetaData)i, i.Fields, true)) + .Concat(entity.LookupIndexes().Select(i => ((MetaData)i, i.Fields, false))) + .Where(n => declares is null || declares(n.Item1)) + .ToList(); + foreach (var (node, fields, unique) in nodes) + { + if (node.Attr(DbConstants.IDENTITY_ATTR_EXPR) is string { Length: > 0 } || fields.Count == 0) continue; + var props = new List(); + foreach (var name in fields) + { + if (entity.FindField(name) is not { } mf) { props.Clear(); break; } + props.Add(PropertyName(mf)); + } + if (props.Count == 0) continue; + var indexName = CSharpNaming.IndexRef(entity, node, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames); + var selector = props.Count == 1 ? $"x => x.{props[0]}" : $"x => new {{ {string.Join(", ", props.Select(p => "x." + p))} }}"; + // The NAMED overload: two indexes over one column list (a partial one beside a plain + // one) are two EF indexes only when each carries its own name. + var line = new StringBuilder($" modelBuilder.Entity<{owner}>().HasIndex({selector}, {indexName})"); + if (unique) line.Append(".IsUnique()"); + if (node.Attr(DbConstants.IDENTITY_ATTR_WHERE) is string where && where.Trim().Length > 0) + line.Append($".HasFilter(\"{CSharpNaming.CSharpStringLiteral(where.Trim())}\")"); + if (node.Attr(DbConstants.IDENTITY_ATTR_ORDERS) is System.Collections.IEnumerable orderList and not string) + { + var desc = orderList.Cast().Select(o => string.Equals(o?.ToString(), "desc", StringComparison.OrdinalIgnoreCase)).ToList(); + if (desc.Any(d => d)) + line.Append(".IsDescending(").Append(string.Join(", ", props.Select((_, i) => i < desc.Count && desc[i] ? "true" : "false"))).Append(')'); + } + line.Append($".HasDatabaseName({indexName});"); + modelLines.Add(line.ToString()); + // @using: the access method, an Npgsql annotation (btree is the default and is left out). + if (node.Attr(DbConstants.IDENTITY_ATTR_USING) is string method && method.Trim().Length > 0 && method.Trim() != "btree") + { + modelLines.Add($" if (Database.ProviderName == \"{NpgsqlProviderName}\")"); + modelLines.Add($" modelBuilder.Entity<{owner}>().HasIndex({selector}, {indexName})" + + $".HasAnnotation(\"Npgsql:IndexMethod\", \"{CSharpNaming.CSharpStringLiteral(method.Trim())}\");"); + } + } + } + + // The SQL expressions migrate treats as an expression default (expected-schema.ts + // EXPR_DEFAULT_PATTERNS): the rest of a string default is a literal. + private static readonly System.Text.RegularExpressions.Regex ExprDefault = new( + @"^(current_timestamp|now\(\)|current_date|current_time)$|\(\)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// A C# literal of the property's own CLR type for a @default, or null + /// when the default is a SQL expression or its type has no literal form here. + private static string? DefaultLiteral(string className, MetaObject entity, MetaField f, object raw, GenConfig config) + { + var text = Convert.ToString(raw, System.Globalization.CultureInfo.InvariantCulture) ?? ""; + if (f.SubType == FIELD_SUBTYPE_ENUM) + { + if (f.EffectiveEnumValues is not { } members || !members.Contains(text)) return null; + var type = Fr019SharedEnum.SharedEnumForField(f) is { } shared + ? Fr019SharedEnum.SharedEnumTypeReference(shared, config) + : $"{CSharpNaming.QualifiedOwnerTypeRef(className, entity, config)}.{CSharpNaming.EnumTypeName(entity, f)}"; + return $"{type}.{text}"; + } + var clr = CSharpNaming.ScalarForField(f); + if (clr == "string") + return text.Length > 0 && (ExprDefault.IsMatch(text) || text.Equals("now", StringComparison.OrdinalIgnoreCase)) + ? null + : $"\"{CSharpNaming.CSharpStringLiteral(text)}\""; + if (clr == "bool") + return raw is bool b ? (b ? "true" : "false") + : bool.TryParse(text, out var pb) ? (pb ? "true" : "false") : null; + var suffix = clr switch + { + "int" => "", "long" => "L", "double" => "d", "float" => "f", "decimal" => "m", + "short" => "", "byte" => "", _ => null, + }; + if (suffix is null + || !decimal.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _)) + return null; + // An integral literal for a short/byte property needs the cast: EF requires the default's + // type to be the property's own. + return clr is "short" or "byte" ? $"({clr}){text}" : text + suffix; + } + + /// The SQL a @default that has no C# literal stands for: the expression as + /// declared (a bare now as now()), or a quoted SQL literal. + private static string? DefaultSql(MetaField f, object raw) + { + if (raw is not string text || f.SubType is FIELD_SUBTYPE_DURATION or FIELD_SUBTYPE_MAP or FIELD_SUBTYPE_OBJECT) + return null; + if (text.Equals("now", StringComparison.OrdinalIgnoreCase)) return "now()"; + if (text.Length > 0 && ExprDefault.IsMatch(text)) return text; + return "'" + text.Replace("'", "''") + "'"; + } + + /// + /// FR-017 TPH — the per-field configuration of the fields a concrete SUBTYPE declares beyond + /// its TPH ancestors. A subtype gets no DbSet and is not in the emitted set, so the root's + /// pass (over the root's own fields) never reaches them: an owned value object was left for + /// EF to discover as an entity ("requires a primary key"), and a timestamp, enum, decimal, + /// array or map column lost the mapping the same field gets on the root. Configured on the + /// subtype's own builder, which EF applies to the shared table. A subtype that extends another + /// concrete subtype configures only what it adds, since the parent is in the model already. + /// + private void EmitTphSubtypeFieldConfig(MetaObject root, TphPlan tph, GenContext ctx, List modelLines) + { + foreach (var st in tph.Subtypes) + modelLines.AddRange(TphSubtypeFieldConfig(root, st.Entity, ctx)); + } + + /// + /// Hook — the per-field configuration of one concrete TPH SUBTYPE (see the summary above): + /// the field-type lines, the column defaults, the SetAfterSaveBehavior lines, the + /// column comments and the indexes (@unique, identity.secondary, + /// index.lookup) for what it declares beyond its TPH ancestors, on the subtype's own builder. Called once per + /// subtype of each discriminator ; empty for a subtype that adds no field. + /// + protected virtual IEnumerable TphSubtypeFieldConfig(MetaObject root, MetaObject subtype, GenContext ctx) + { + var tph = TphPlanBuilder.For(root, ctx.Root); + if (tph is null) + { + return []; + } + var concrete = new HashSet(tph.Subtypes.Select(st => st.Entity)); + // The nearest ancestor already in the EF model: a concrete subtype, else the root. + var cursor = subtype.SuperData as MetaObject; + while (cursor is not null && cursor != root && !concrete.Contains(cursor)) + { + cursor = cursor.SuperData as MetaObject; + } + var ancestor = cursor ?? root; + var inherited = new HashSet(ancestor.Fields().Select(f => f.Name), StringComparer.Ordinal); + var own = subtype.Fields().Where(f => !inherited.Contains(f.Name)).ToList(); + // Resolving on both sides: an index node an abstract level between them declares is the + // subtype's to configure, one the ancestor already resolves is configured there. + var ancestorIndexes = new HashSet( + ancestor.SecondaryIdentities().Select(i => i.Name).Concat(ancestor.LookupIndexes().Select(i => i.Name)), StringComparer.Ordinal); + var ownIndexes = subtype.SecondaryIdentities().Select(i => i.Name).Concat(subtype.LookupIndexes().Select(i => i.Name)) + .Where(n => !ancestorIndexes.Contains(n)).ToHashSet(StringComparer.Ordinal); + if (own.Count == 0 && ownIndexes.Count == 0) + { + return []; + } + + var lines = new List(); + var cls = CSharpNaming.Pascal(subtype.Name); + EmitFieldTypeConfig(cls, subtype, own, ctx, lines, jsonbObjectsOnly: false); + EmitDefaultValueConfig(subtype, own, ctx, lines); + foreach (var f in own.Where(f => !f.ResolvedIsArray() && f.Mutability != MUTABILITY_READ_WRITE)) + { + lines.Add( + $" modelBuilder.Entity<{cls}>().Property(x => x.{PropertyName(f)}).Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);"); + } + // The shared table's comment is the root's; a subtype's own columns carry theirs. + EmitCommentConfig(cls, subtype, own, lines, tableComment: false); + EmitIndexConfig(cls, subtype, ctx, lines, own, n => ownIndexes.Contains(n.Name)); + return lines; + } + + /// + /// Under , a concrete class extends the abstract + /// shape its entity extends, and the fields that shape declares live on the shape class — + /// which carries no EF attributes by design. So the [Key], [Column] and + /// [MaxLength] those fields would have carried on the concrete class are configured + /// here instead, or the model keys the entity by convention, names the columns after the + /// properties, and drops the declared length. Nothing when the knob is off (the concrete + /// class then declares every field itself, attributes included). + /// + private void EmitShapeInheritedFacets( + string className, MetaObject entity, IEnumerable fields, GenContext ctx, List modelLines) + { + if (!ctx.Config.EmitAbstractShapes) return; + if (entity.SuperData is not MetaObject parent || !parent.IsEntity() || !InstanceArtifacts.IsAbstract(parent) + || TphPlanBuilder.IsTphMember(parent, ctx.Root)) + return; + var shapeDeclared = new HashSet(parent.Fields().Select(f => f.Name), StringComparer.Ordinal); + var lhs = $" modelBuilder.Entity<{className}>()"; + + if (entity.PrimaryIdentity()?.Fields is [var keyName] && shapeDeclared.Contains(keyName) + && entity.FindField(keyName) is { } keyField) + modelLines.Add($"{lhs}.HasKey(x => x.{PropertyName(keyField)});"); + + foreach (var f in fields.Where(f => shapeDeclared.Contains(f.Name))) + { + var col = CSharpNaming.ColumnRef(entity, f, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames); + var prop = PropertyName(f); + var isScalar = CSharpNaming.ScalarFor(f.SubType) is not null; + if (f.ResolvedIsArray() && (isScalar || f.SubType == FIELD_SUBTYPE_ENUM)) + modelLines.Add($"{lhs}.PrimitiveCollection(x => x.{prop}).HasColumnName({col});"); + else if (isScalar || f.SubType is FIELD_SUBTYPE_ENUM or FIELD_SUBTYPE_MAP) + modelLines.Add($"{lhs}.Property(x => x.{prop}).HasColumnName({col})" + + (f.SubType == FIELD_SUBTYPE_STRING && EffectiveMaxLength(f) is { } max ? $".HasMaxLength({max})" : "") + + ";"); + // An object field's column is already named by its owned-type config. + } + } + + /// A string field's effective maximum length: the strictest of @maxLength and + /// a validator.length @max — the same value EntityGenerator's [MaxLength] carries. + private static long? EffectiveMaxLength(MetaField f) + { + long? max = f.MaxLength; + foreach (var v in f.Validators()) + if (v.SubType == MetaObjects.Core.Validator.ValidatorConstants.VALIDATOR_SUBTYPE_LENGTH && v.Max is long lmax) + max = max is long cur ? Math.Min(cur, lmax) : lmax; + return max; + } + + /// The key field of a single-column primary identity declared + /// @generation: assigned, or null. A composite key is never generated by EF's + /// convention, so it needs nothing. + private static MetaField? AssignedKeyField(MetaObject entity) => + entity.PrimaryIdentity() is MetaPrimaryIdentity { Generation: GENERATION_ASSIGNED } pk + && pk.Fields is [var only] + ? entity.FindField(only) + : null; + /// /// Identity of the physical foreign key a reference defines: its ordered FK field /// names. A TPH subtype re-declaring its base's reference (to add an inverse, or just @@ -494,22 +1419,30 @@ private static string FkKey(MetaReferenceIdentity reference) => string.Join(",", ReferentialActions.ReadIdentityFields(reference)); /// - /// The relationship-configuration line for one reference, or null when it - /// cannot be expressed: an unresolvable / non-persisted target, or an FK field the - /// emitted class does not declare. Silent by design — a dangling @references + /// Hook — the relationship-configuration lines for one reference: one line, or none when it + /// cannot be expressed (an unresolvable / non-persisted target, or an FK field the + /// emitted class does not declare). Silent by design — a dangling @references /// is already a load error, and the other cases are shapes this generator - /// deliberately does not map. + /// deliberately does not map. is the declaring entity (a TPH + /// subtype for a reference it declares itself); the emitted class is its own name. /// - private string? ReferenceConfig( - string owner, MetaObject entity, MetaReferenceIdentity reference, GenContext ctx) + protected virtual IEnumerable ReferenceConfig( + MetaObject entity, MetaReferenceIdentity reference, GenContext ctx) { - if (reference.TargetEntity is null) return null; + var owner = CSharpNaming.Pascal(entity.Name); + if (reference.TargetEntity is null) + { + return []; + } var target = NamingRefs.RefTarget(reference, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject; if (target is null || !target.IsEntity() || !InstanceArtifacts.EmitsInstanceArtifacts(target)) - return null; + return []; var fkFields = ReferentialActions.ReadIdentityFields(reference); - if (fkFields.Count == 0) return null; + if (fkFields.Count == 0) + { + return []; + } var props = new List(fkFields.Count); var isWriteThrough = entity.IsWriteThrough(); @@ -518,17 +1451,56 @@ private static string FkKey(MetaReferenceIdentity reference) => // #214 — a write-through entity's WRITE class omits the derived (origin.*) // fields, so naming one here would reference a property the class does not // declare (a compile error in the generated file). - if (entity.FindField(name) is not { } field) return null; - if (isWriteThrough && field.IsDerived()) return null; - props.Add(CSharpNaming.Pascal(name)); + if (entity.FindField(name) is not { } field) + { + return []; + } + if (isWriteThrough && field.IsDerived()) + { + return []; + } + props.Add(PropertyName(field)); } var actions = ReferentialActions.Resolve(entity, reference); - return $" modelBuilder.Entity<{owner}>().HasOne<{CSharpNaming.Pascal(target.Name)}>()" + return [$" modelBuilder.Entity<{owner}>().HasOne<{CSharpNaming.Pascal(target.Name)}>()" + $".WithMany().HasForeignKey({ForeignKeyExpression("e", props)})" - + $"{OnDeleteCall(entity, fkFields, actions.OnDelete, ctx)};"; + + PrincipalKeyCall(reference, target) + + $"{OnDeleteCall(entity, fkFields, actions.OnDelete, ctx)}" + + ConstraintNameCall(reference) + ";"]; + } + + /// + /// .HasPrincipalKey(...) when @references names target fields that are not the + /// target's primary key ("Warehouse.regionCode,siteNo"), else "". Without it EF + /// pairs the foreign key with the primary key: a composite FK then fails model validation + /// ("cannot target the primary key ... because it is not compatible"), and a single column of + /// the PK's type is silently joined to the wrong column. EF makes the named fields an + /// alternate key, which is the unique constraint such a reference needs anyway. + /// + private string PrincipalKeyCall(MetaReferenceIdentity reference, MetaObject target) + { + var targetFields = reference.TargetFields; + if (targetFields.Count == 0) return string.Empty; + if (target.PrimaryIdentity()?.Fields is { } pk && pk.SequenceEqual(targetFields, StringComparer.Ordinal)) + return string.Empty; + var props = new List(targetFields.Count); + foreach (var name in targetFields) + { + if (target.FindField(name) is not { } field) return string.Empty; + props.Add(PropertyName(field)); + } + return $".HasPrincipalKey({ForeignKeyExpression("p", props)})"; } + /// .HasConstraintName("...") from the reference's @constraintName, so the + /// model (and a database EF creates from it) names the FK the way the schema does; else "". + private static string ConstraintNameCall(MetaReferenceIdentity reference) => + // ADR-0039: resolving — an inherited reference keeps its declared name. + reference.Attr(DbConstants.IDENTITY_ATTR_CONSTRAINT_NAME) is string name && name.Length > 0 + ? $".HasConstraintName(\"{CSharpNaming.CSharpStringLiteral(name)}\")" + : string.Empty; + /// /// The HasForeignKey argument naming on the dependent: /// e => e.Prop for one column, e => new { e.A, e.B } for a composite. @@ -619,10 +1591,15 @@ private string JunctionSideOnDelete(MetaObject junction, string fkField, GenCont // delete, a behaviour change for every existing M:N adopter that has nothing to do // with #294. The 1:N path defaults precisely because those relationships are NEW // here: there is no prior behaviour to preserve, only EF's convention to prevent. - if (resolved is null) return string.Empty; - return OnDeleteCall(junction, [fkField], resolved, ctx); + var constraint = ConstraintNameCall(reference); + if (resolved is null) return constraint; + return OnDeleteCall(junction, [fkField], resolved, ctx) + constraint; } + // A junction FK column's C# property, through the same resolver its entity class used. + private string JunctionFkProperty(MetaObject junction, string fkField) => + junction.FindField(fkField) is { } f ? PropertyName(f) : CSharpNaming.Pascal(fkField); + // FR-018 — EF skip-navigation config for a hetero M:N navigation through its // explicit junction entity. The junction's FK PROPERTIES are the PascalCased // junction FK field names (the EntityGenerator emits them as scalar properties on @@ -645,8 +1622,8 @@ private string UsingEntityConfig(M2MNavigation nav, GenContext ctx) var target = CSharpNaming.Pascal(nav.Target.Name); var through = CSharpNaming.Pascal(nav.Junction.Name); var navProp = CSharpNaming.Pascal(nav.Name); - var sourceFkProp = CSharpNaming.Pascal(nav.SourceField); - var targetFkProp = CSharpNaming.Pascal(nav.TargetField); + var sourceFkProp = JunctionFkProperty(nav.Junction, nav.SourceField); + var targetFkProp = JunctionFkProperty(nav.Junction, nav.TargetField); var targetOnDelete = JunctionSideOnDelete(nav.Junction, nav.TargetField, ctx); var sourceOnDelete = JunctionSideOnDelete(nav.Junction, nav.SourceField, ctx); // Typed lambdas for the same reason the 1:N path uses them (see ForeignKeyExpression): @@ -676,6 +1653,9 @@ private string UsingEntityConfig(M2MNavigation nav, GenContext ctx) /// Name of the generated fail-fast helper the read converters end in. private const string UnmappedEnumHelperName = "UnmappedEnumValue"; + /// Name of the generated fail-fast helper the write converters end in. + private const string UnmappedEnumMemberHelperName = "UnmappedEnumMember"; + /// /// Emits the fail-fast helper every int-backed enum's provider→model converter ends /// in. Emitted only when at least one such converter was generated, so a model with @@ -696,6 +1676,16 @@ private static void EmitUnmappedEnumHelper(StringBuilder sb) sb.AppendLine(" throw new System.InvalidOperationException("); sb.AppendLine(" $\"field.enum '{field}' read stored value {stored} with no member in \" +"); sb.AppendLine(" \"@intValueMap — the database holds a value the model does not describe.\");"); + sb.AppendLine(); + sb.AppendLine(" /// "); + sb.AppendLine(" /// An int-backed field.enum property held a value outside @values (a cast integer, or"); + sb.AppendLine(" /// a member the C# enum declares that the metadata does not): it has no declared"); + sb.AppendLine(" /// integer, and writing some other member's instead would corrupt the row silently."); + sb.AppendLine(" /// "); + sb.AppendLine($" private static int {UnmappedEnumMemberHelperName}(object? value, string field) =>"); + sb.AppendLine(" throw new System.InvalidOperationException("); + sb.AppendLine(" $\"field.enum '{field}' cannot write {value}: it has no integer in \" +"); + sb.AppendLine(" \"@intValueMap — the value is not one of the members the model declares.\");"); } /// @@ -704,8 +1694,8 @@ private static void EmitUnmappedEnumHelper(StringBuilder sb) /// () and the read-only-projection path, which both emit /// a Dictionary<string, V> property and so both need the mapping. /// - private static string MapJsonbConfig(string className, MetaObject owner, MetaField f, GenContext ctx) => - $" modelBuilder.Entity<{className}>().Property(x => x.{CSharpNaming.Pascal(f.Name)})" + private string MapJsonbConfig(string className, MetaObject owner, MetaField f, GenContext ctx) => + $" modelBuilder.Entity<{className}>().Property(x => x.{PropertyName(f)})" + MapJsonbSuffix(owner, f, ctx) + ";"; /// @@ -905,7 +1895,7 @@ private static bool NeedsUnmappedEnumHelper(IEnumerable objects, Met || (f.SubType == FIELD_SUBTYPE_OBJECT && f.Storage == STORAGE_FLATTENED && !f.ResolvedIsArray() && ValueObjectNames.ResolveFieldRef(f, root) is { } vo && vo.Fields().Any(nf => - nf.SubType == FIELD_SUBTYPE_ENUM && !nf.ResolvedIsArray() && IntValueMapOf(nf) is not null)))); + nf.SubType == FIELD_SUBTYPE_ENUM && IntValueMapOf(nf) is not null)))); /// /// The complete HasConversion call for an enum property: the generic @@ -922,12 +1912,15 @@ private static bool NeedsUnmappedEnumHelper(IEnumerable objects, Met /// with no member THROWS rather than silently materializing as the last member — /// matching all four sibling ports. CS8188 bans a throw-EXPRESSION inside an /// expression tree, but a method CALL is legal there and the throw itself happens in - /// the helper's ordinary body. Only the read side needs this: the model→provider - /// chain is exhaustive over the enum by construction, since @intValueMap's - /// keys are loader-validated to match @values exactly. + /// the helper's ordinary body. The model→provider chain does the same, ending in + /// UnmappedEnumMember: @intValueMap's keys match @values + /// exactly, but the C# enum can still hold a value outside them (a cast integer, or a + /// @provided enum's extra member), which must not be stored as another member's + /// integer. /// - private static string EnumConversionCall(string owner, MetaObject entity, MetaField f, GenConfig config) + protected virtual string? EnumConversionCall(string owner, MetaObject entity, MetaField f, GenContext ctx) { + var config = ctx.Config; var intMap = IntValueMapOf(f); // A string-backed enum's column length (EnumLength.ResolveMaxLength: its @maxLength, else // the project's enums.maxLength) is the VARCHAR(n) `meta migrate` creates. An array enum @@ -970,15 +1963,28 @@ private static string EnumConversionCall(string owner, MetaObject entity, MetaFi var toProvider = new System.Text.StringBuilder("v => "); var fromProvider = new System.Text.StringBuilder("v => "); - for (var i = 0; i < members.Count - 1; i++) + // Every member gets its own branch on the WRITE side too, ending in the throwing helper: + // a value outside @values (a cast like (E)99, or a member a @provided C# enum carries + // that the metadata does not) would otherwise fall into the else branch and be stored + // as the LAST member's int, silently. + for (var i = 0; i < members.Count; i++) toProvider.Append($"v == {type}.{members[i]} ? {ints[i]} : "); - toProvider.Append(ints[^1]); + // Typed object: the lambda's parameter is the property's type, nullable or not. + toProvider.Append($"{UnmappedEnumMemberHelperName}(v, \"{f.Name}\")"); // Every member gets its own branch here (no last-member fallthrough) so the // final else can reject an int the model does not describe. for (var i = 0; i < members.Count; i++) fromProvider.Append($"v == {ints[i]} ? {type}.{members[i]} : "); fromProvider.Append($"{UnmappedEnumHelperName}<{type}>(v, \"{f.Name}\")"); + // An ARRAY's conversion goes on the primitive collection's ElementTypeBuilder, which + // is not generic: it has no HasConversion(lambda, lambda) overload to type the two + // lambdas against (CS1660), so the pair is wrapped in an explicit ValueConverter. + // The element converter is the scalar pair unchanged, so each element is stored as + // its declared int and an unmapped stored element throws exactly as a scalar does. + if (f.ResolvedIsArray()) + return "HasConversion(new global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<" + + $"{type}, int>({toProvider}, {fromProvider}))"; return $"HasConversion({toProvider}, {fromProvider})"; } @@ -1008,7 +2014,153 @@ private void EmitFieldTypeConfig( foreach (var f in fieldList.Where(f => f.SubType == FIELD_SUBTYPE_OBJECT && (!jsonbObjectsOnly || f.Storage != STORAGE_FLATTENED))) - if (OwnedTypeConfig(className, entity, f, ctx) is { } cfg) modelLines.Add(cfg); + { + if (OwnedTypeConfig(className, entity, f, ctx) is { } cfg) + { + modelLines.Add(cfg); + } + } + + // Every other kind of field, in FieldTypeRank order: all maps, then all enums, then primitive + // collections, decimals, timestamps, uri, inet, durations. The order is the one this method + // has always emitted in (OrderBy is stable, so fields keep their declared order within a + // kind); FieldTypeConfig is asked about every field, once, in that order. An ARRAY of + // durations belongs to two kinds, a primitive collection and a duration, and the old passes + // wrote its two halves apart: its duration half is set aside by the hook (see + // FieldTypeConfig) and written here at its declared place among the durations. + var items = new List<(int Rank, MetaField Field, bool DurationHalf)>(); + foreach (var f in fieldList) + { + items.Add((FieldTypeRank(f), f, false)); + if (IsDurationArray(f)) + { + items.Add((FieldTypeRankDuration, f, true)); + } + } + var outerDeferred = _deferredDurationLines; + var deferred = new Dictionary>(); + _deferredDurationLines = deferred; + try + { + foreach (var (_, f, durationHalf) in items.OrderBy(i => i.Rank)) + { + if (durationHalf) + { + if (deferred.TryGetValue(f, out var half)) + { + modelLines.AddRange(half); + } + continue; + } + if (f.SubType == FIELD_SUBTYPE_ENUM) + { + // An override that returns null leaves the field unconverted: no line. + if (EnumConversionCall(className, entity, f, ctx) is { } conversion) + { + var prop = PropertyName(f); + // An int-backed enum (@intValueMap) persists the declared INTEGER instead of the + // member symbol, so it needs a custom converter pair rather than HasConversion(). + // The generated C# `enum` declaration is byte-identical either way — int-backing is a + // persistence concern, invisible in the entity's API. + // ADR-0039: resolving — array-ness inheritable via extends. Array-of-enum uses the + // EF Core 8 primitive collection with a per-element conversion so members persist as + // symbols (["DRAFT"]) — or as their declared ints — not as int ordinals ([0]). + if (f.ResolvedIsArray()) + { + modelLines.Add($" modelBuilder.Entity<{className}>().PrimitiveCollection(x => x.{prop}).ElementType().{conversion};"); + } + else + { + modelLines.Add($" modelBuilder.Entity<{className}>().Property(x => x.{prop}).{conversion};"); + } + } + } + modelLines.AddRange(FieldTypeConfig(className, entity, f, ctx)); + } + } + finally + { + _deferredDurationLines = outerDeferred; + } + + foreach (var f in fieldList.Where(f => !f.ResolvedIsArray() && f.DbColumnType is not null)) + { + // An override that returns null leaves the field with no column-type line. + if (DbColumnTypeConfig(className, f) is { } cfg) + { + modelLines.Add(cfg); + } + } + } + + // The duration half of an ARRAY-of-durations field's lines, set aside by the default + // FieldTypeConfig while EmitFieldTypeConfig is running, so the driver can write it where the + // duration pass always wrote it. Null outside that run, when the hook returns everything at once. + private Dictionary>? _deferredDurationLines; + + private const int FieldTypeRankDuration = 8; + + private static bool IsDurationArray(MetaField f) => + f.ResolvedIsArray() && f.SubType == FIELD_SUBTYPE_DURATION && f.DbColumnType is null; + + // The place of a field's type configuration among the others, so a model's lines keep the + // order they have always had: maps, enums, primitive collections, decimals, timestamps, uri, + // inet, durations. A field matching more than one (an array of durations is both a primitive + // collection and a duration) sits at the first. Everything else — scalars needing nothing, + // owned value objects (configured by OwnedTypeConfig above) — sorts last and emits nothing here. + private static int FieldTypeRank(MetaField f) + { + var array = f.ResolvedIsArray(); + if (f.SubType == FIELD_SUBTYPE_MAP) + { + return 1; + } + if (f.SubType == FIELD_SUBTYPE_ENUM) + { + return 2; + } + if (array && CSharpNaming.ScalarFor(f.SubType) is not null) + { + return 3; + } + if (!array && f.SubType == FIELD_SUBTYPE_DECIMAL && f.Precision is not null) + { + return 4; + } + if (!array && f.SubType == FIELD_SUBTYPE_TIMESTAMP) + { + return 5; + } + if (!array && f.SubType == FIELD_SUBTYPE_URI && !CSharpNaming.IsLenientNet(f)) + { + return 6; + } + if (!array && f.SubType == FIELD_SUBTYPE_INET && !CSharpNaming.IsLenientNet(f)) + { + return 7; + } + if (f.SubType == FIELD_SUBTYPE_DURATION && f.DbColumnType is null) + { + return 8; + } + return 99; + } + + /// + /// Hook — the type configuration lines of ONE field, on (the emitted + /// class: the entity, a TPH subtype, or a write-through entity's <Entity>View read + /// model): a field.map's jsonb column, a scalar array's primitive collection, a decimal's + /// precision, a timestamp's column type, field.uri/field.inet storage and a + /// duration's provider-dependent mapping. Called for EVERY field being configured, so an + /// override can add a line for any of them; the base returns nothing for a field none of those + /// apply to. Enum conversions come from , owned value objects + /// from and @dbColumnType from . + /// + protected virtual IEnumerable FieldTypeConfig(string owner, MetaObject entity, MetaField f, GenContext ctx) + { + var lines = new List(); + var array = f.ResolvedIsArray(); + var prop = PropertyName(f); // field.map -> a single jsonb column holding the JSON object. Without this the property // gets NO storage mapping at all: nothing tells EF the column type, and nothing tells it @@ -1024,70 +2176,47 @@ private void EmitFieldTypeConfig( // explicitly, so the mapping does not depend on any host-side serializer configuration // the generated code cannot make on a consumer's behalf. The COMPARER is not optional -- // see EmitMapJsonbHelper. - foreach (var f in fieldList.Where(f => f.SubType == FIELD_SUBTYPE_MAP)) - modelLines.Add(MapJsonbConfig(className, entity, f, ctx)); - - foreach (var f in fieldList.Where(f => f.SubType == FIELD_SUBTYPE_ENUM)) - { - var prop = CSharpNaming.Pascal(f.Name); - // An int-backed enum (@intValueMap) persists the declared INTEGER instead of the - // member symbol, so it needs a custom converter pair rather than HasConversion(). - // The generated C# `enum` declaration is byte-identical either way — int-backing is a - // persistence concern, invisible in the entity's API. - var conversion = EnumConversionCall(className, entity, f, ctx.Config); - // ADR-0039: resolving — array-ness inheritable via extends. Array-of-enum uses the - // EF Core 8 primitive collection with a per-element conversion so members persist as - // symbols (["DRAFT"]) — or as their declared ints — not as int ordinals ([0]). - if (f.ResolvedIsArray()) - modelLines.Add($" modelBuilder.Entity<{className}>().PrimitiveCollection(x => x.{prop}).ElementType().{conversion};"); - else - modelLines.Add($" modelBuilder.Entity<{className}>().Property(x => x.{prop}).{conversion};"); + if (f.SubType == FIELD_SUBTYPE_MAP) + { + lines.Add(MapJsonbConfig(owner, entity, f, ctx)); } - foreach (var f in fieldList.Where(f => f.ResolvedIsArray() && CSharpNaming.ScalarFor(f.SubType) is not null)) + if (array && CSharpNaming.ScalarFor(f.SubType) is not null) { - var prop = CSharpNaming.Pascal(f.Name); - modelLines.Add($" modelBuilder.Entity<{className}>().PrimitiveCollection(x => x.{prop});"); + lines.Add($" modelBuilder.Entity<{owner}>().PrimitiveCollection(x => x.{prop});"); } - foreach (var f in fieldList.Where(f => - !f.ResolvedIsArray() && f.SubType == FIELD_SUBTYPE_DECIMAL && f.Precision is not null)) + if (!array && f.SubType == FIELD_SUBTYPE_DECIMAL && f.Precision is not null) { - var prop = CSharpNaming.Pascal(f.Name); - modelLines.Add(f.Scale is long sc - ? $" modelBuilder.Entity<{className}>().Property(x => x.{prop}).HasPrecision({f.Precision}, {sc});" - : $" modelBuilder.Entity<{className}>().Property(x => x.{prop}).HasPrecision({f.Precision});"); + lines.Add(f.Scale is long sc + ? $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasPrecision({f.Precision}, {sc});" + : $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasPrecision({f.Precision});"); } - foreach (var f in fieldList.Where(f => !f.ResolvedIsArray() && f.SubType == FIELD_SUBTYPE_TIMESTAMP)) + if (!array && f.SubType == FIELD_SUBTYPE_TIMESTAMP) { - var prop = CSharpNaming.Pascal(f.Name); var colType = CSharpNaming.IsLocalTime(f) ? "timestamp without time zone" : "timestamp with time zone"; - modelLines.Add($" modelBuilder.Entity<{className}>().Property(x => x.{prop}).HasColumnType(\"{colType}\");"); + lines.Add($" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasColumnType(\"{colType}\");"); } // #234: a @lenient field.uri / field.inet is a plain string mapped to a plain `text` // column (EF's string default) — no System.Uri HasConversion, no native `inet` type. - foreach (var f in fieldList.Where(f => !f.ResolvedIsArray() - && f.SubType == FIELD_SUBTYPE_URI && !CSharpNaming.IsLenientNet(f))) + if (!array && f.SubType == FIELD_SUBTYPE_URI && !CSharpNaming.IsLenientNet(f)) { - var prop = CSharpNaming.Pascal(f.Name); // System.Uri is FULLY QUALIFIED: the emitted AppDbContext carries no `using System;` // (unlike the entity files), so an unqualified `new Uri(v)` in OnModelCreating fails // to compile except under a host's ImplicitUsings — the generated context must be // self-contained (this is also on the #214 write-through read-model path now). - modelLines.Add( - $" modelBuilder.Entity<{className}>().Property(x => x.{prop}).HasColumnType(\"text\").HasConversion(v => v!.ToString(), v => new System.Uri(v));"); + lines.Add( + $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasColumnType(\"text\").HasConversion(v => v!.ToString(), v => new System.Uri(v));"); } - foreach (var f in fieldList.Where(f => !f.ResolvedIsArray() - && f.SubType == FIELD_SUBTYPE_INET && !CSharpNaming.IsLenientNet(f))) + if (!array && f.SubType == FIELD_SUBTYPE_INET && !CSharpNaming.IsLenientNet(f)) { - var prop = CSharpNaming.Pascal(f.Name); - modelLines.Add( - $" modelBuilder.Entity<{className}>().Property(x => x.{prop}).HasColumnType(\"inet\");"); + lines.Add( + $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasColumnType(\"inet\");"); } // field.duration — a System.TimeSpan. Npgsql maps it to the native `interval` (and an array @@ -1095,10 +2224,23 @@ private void EmitFieldTypeConfig( // provider stores the canonical ISO-8601 string in a text column (the TS-owned SQLite / MySQL // schemas), through the entity namespace's DurationWire; EF's own TimeSpan text ("01:30:00") // would not be the cross-port form, and MySQL's `time` cannot hold more than 838 hours. - AddDurationConfig(className, entity, fieldList, ctx, modelLines); + if (f.SubType == FIELD_SUBTYPE_DURATION && f.DbColumnType is null) + { + if (_deferredDurationLines is { } deferred && IsDurationArray(f)) + { + // Under EmitFieldTypeConfig the duration half of an array waits for its pass; the + // lines are still this hook's to produce, the driver only moves them. + var half = new List(); + AddDurationConfig(owner, entity, [f], ctx, half); + deferred[f] = half; + } + else + { + AddDurationConfig(owner, entity, [f], ctx, lines); + } + } - foreach (var f in fieldList.Where(f => !f.ResolvedIsArray() && f.DbColumnType is not null)) - if (DbColumnTypeConfig(className, f) is { } cfg) modelLines.Add(cfg); + return lines; } // field.duration — a System.TimeSpan. Npgsql maps it to the native `interval` (and an array @@ -1106,12 +2248,12 @@ private void EmitFieldTypeConfig( // provider stores a 64-bit count of microseconds in an integer column (the TS-owned SQLite / // MySQL schemas), through the entity namespace's DurationWire; EF's own TimeSpan text // ("01:30:00") does not order as the span, and MySQL's `time` cannot hold more than 838 hours. - private static void AddDurationConfig( + private void AddDurationConfig( string className, MetaObject entity, IEnumerable fields, GenContext ctx, List modelLines) { foreach (var f in fields.Where(f => f.SubType == FIELD_SUBTYPE_DURATION && f.DbColumnType is null)) modelLines.Add(string.Join("\n", - DurationColumnLines($"modelBuilder.Entity<{className}>()", "x", CSharpNaming.Pascal(f.Name), f.ResolvedIsArray(), entity, ctx, " "))); + DurationColumnLines($"modelBuilder.Entity<{className}>()", "x", PropertyName(f), f.ResolvedIsArray(), entity, ctx, " "))); } // The provider-dependent mapping of one duration column (a top-level property, or a member of a @@ -1328,7 +2470,7 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s // required, so EF models it nullable — a gen↔schema nullability mismatch. Deferred // until the exact EF Core required-owned-JSON mapping can be validated against a // live provider. - private string? OwnedTypeConfig(string owner, MetaObject entity, MetaField field, GenContext ctx) + protected virtual string? OwnedTypeConfig(string owner, MetaObject entity, MetaField field, GenContext ctx) { if (ValueObjectNames.ResolveFieldRef(field, ctx.Root) is not { } vo) { @@ -1336,7 +2478,7 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s return null; } var strategy = ctx.Config.ColumnNamingStrategy; - var nav = CSharpNaming.Pascal(field.Name); + var nav = PropertyName(field); // A6 -- the field itself belongs to `entity` (the JSON column's own physical // name, not the nested value object's), so it hits entity's own names artifact // unconditionally in practice — a lookup, not an assumption, per ColumnRef. @@ -1387,7 +2529,7 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s // else `_`, then the member's own column — the same function // migrate-ts names these columns with, and TS owns the schema (ADR-0015). It is NOT EF's // own owned-type default, `