diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f71c20e7..30afa5e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -333,6 +333,12 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every ### Fixed +- **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 + update schemas, the filter and sort allowlists, the OpenAPI bodies and the form, so a write dropped + them silently. The flattened member expansion now skips a derived parent, so every generated + surface agrees with the table. - **TypeScript: Drizzle now binds a `field.object` with `@storage: flattened` to its member columns (#524).** The generator used to emit one `jsonb` column named for the field, while `meta migrate` creates one column per member of the value object and none for the field, so every generated diff --git a/docs/features/field-types.md b/docs/features/field-types.md index d04de99e8..f267c7b2c 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -365,8 +365,9 @@ function `meta migrate` uses. The Row and Insert types, the Zod schemas, the fil allowlists, the OpenAPI parameters and the form and grid generators all expose those flat keys; a client reads and writes `addressStreet`, not a nested `address` object. A flattened field declared on a TPH subtype keeps one column named for the field, as `meta migrate` creates it. -A flattened field on a write-through entity is refused at `meta gen` -([source-kinds.md](source-kinds.md)). +A flattened field that carries an `origin.*` is derived: it owns no columns, so it exposes no flat +keys in any of those surfaces, and a write cannot set them. A flattened field on a write-through +entity is refused at `meta gen` ([source-kinds.md](source-kinds.md)). ```ts // generated/acme/blog/Author.ts diff --git a/server/typescript/packages/codegen-ts/src/flattened.ts b/server/typescript/packages/codegen-ts/src/flattened.ts index 9c6ebe03b..d4313158d 100644 --- a/server/typescript/packages/codegen-ts/src/flattened.ts +++ b/server/typescript/packages/codegen-ts/src/flattened.ts @@ -51,6 +51,9 @@ export function isFlattenedObjectField(field: MetaField): boolean { /** The members of a flattened field, or undefined when it is not flattened (or names no value object). */ export function flattenedMembersOf(field: MetaField, strategy: ColumnNamingStrategy): FlattenedMember[] | undefined { if (!isFlattenedObjectField(field)) return undefined; + // A derived (origin-bearing) field is materialized on the read side and owns no column, so its + // members are no columns either; drizzle-schema and migrate's expected schema skip it too. + if (field.isDerived()) return undefined; const valueObject = refTarget(field, FIELD_ATTR_OBJECT_REF); if (valueObject === undefined || !isMetaObject(valueObject)) return undefined; // ADR-0039: effective fields — members inherited via extends are columns too (as migrate does). diff --git a/server/typescript/packages/codegen-ts/test/flattened-storage-e2e.test.ts b/server/typescript/packages/codegen-ts/test/flattened-storage-e2e.test.ts index 408125458..5262d941e 100644 --- a/server/typescript/packages/codegen-ts/test/flattened-storage-e2e.test.ts +++ b/server/typescript/packages/codegen-ts/test/flattened-storage-e2e.test.ts @@ -196,6 +196,51 @@ describe("flattened storage: a derived value-object member keeps its column", () }); }); +describe("flattened storage: a derived flattened parent has no member keys or columns", () => { + const DERIVED_PARENT = JSON.stringify({ + "metadata.root": { package: "acme", children: [ + { "object.value": { name: "Address", children: [ + { "field.string": { name: "street" } }, + { "field.string": { name: "city", "@filterable": true } }, + ] } }, + { "object.entity": { name: "Owner", children: [ + { "source.rdb": { "@table": "owners" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "home", "@objectRef": "Address", "@storage": "flattened" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Parcel", children: [ + { "source.rdb": { "@table": "parcels", "@role": "primary" } }, + { "source.rdb": { "@kind": "view", "@view": "v_parcels", "@role": "replica" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "ref" } }, + { "field.long": { name: "ownerId" } }, + { "field.object": { name: "shipTo", "@objectRef": "Address", "@storage": "flattened", children: [ + { "origin.passthrough": { "@from": "Owner.home", "@via": "Parcel.owner" } }, + ] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "identity.reference": { name: "ownerRef", "@fields": "ownerId", "@references": "Owner" } }, + { "relationship.association": { name: "owner", "@cardinality": "one", "@objectRef": "Owner" } }, + ] } }, + ] }, + }); + + test("no flat keys on the surface, in the schemas or allowlists, and no Drizzle member columns", async () => { + const { root, errors } = await new MetaDataLoader({ strict: true }).load([new InMemoryStringSource(DERIVED_PARENT, { id: "dp.json" })]); + expect(errors).toEqual([]); + const parcel = root.findObject("Parcel")!; + expect(flatSurfaceFields(parcel).map((f) => f.name)).toEqual(["id", "ref", "ownerId", "shipTo"]); + + const outDir = await generate(root, "postgres", [entityFile(), queriesFile()], tmpdir()); + const entity = readFileSync(allTs(outDir).find((f) => f.endsWith("Parcel.ts"))!, "utf8"); + const table = entity.slice(entity.indexOf("Table(\"parcels\""), entity.indexOf("});", entity.indexOf("Table(\"parcels\""))); + expect([...table.matchAll(/^\s+\w+: \w+\("([^"]+)"/gm)].map((m) => m[1]!).sort()).toEqual(["id", "owner_id", "ref"]); + for (const file of allTs(outDir).filter((f) => /Parcel[.\w]*\.ts$/.test(f))) { + expect(readFileSync(file, "utf8")).not.toMatch(/shipTo(Street|City)|ship_to_/); + } + }); +}); + describe("flattened storage: agent/ui.md reports the flat keys with their flags", () => { test("a filterable member's row is marked filterable and sortable", async () => { const page = renderAgentUiPage(await load());