diff --git a/server/typescript/packages/codegen-ts/test/column-mapper.test.ts b/server/typescript/packages/codegen-ts/test/column-mapper.test.ts index 2b79aade1..9cec7c8e0 100644 --- a/server/typescript/packages/codegen-ts/test/column-mapper.test.ts +++ b/server/typescript/packages/codegen-ts/test/column-mapper.test.ts @@ -222,6 +222,28 @@ describe("mapColumnType — field.uuid (R6 Plan 2a)", () => { const spec = mapColumnType(metaField(FIELD_SUBTYPE_UUID, "id"), "sqlite"); expect(spec.fnName).toBe("text"); }); + + // Native arrays are DERIVED from isArray (dbColumnType slim-and-derive Phase 1): + // no @dbColumnType:uuid_array / text_array escape hatch — the array-ness lives on + // the field's isArray axis. + test("Postgres: field.uuid isArray → uuid() + .array() (= uuid[])", () => { + const f = metaField(FIELD_SUBTYPE_UUID, "memberIds"); + f.setIsArray(true); + const spec = mapColumnType(f, "postgres"); + expect(spec.fnName).toBe("uuid"); + expect(spec.modifiers).toContain(".array()"); + // Drizzle's native .array() is already element-typed — no $type needed. + expect(spec.dollarTypeRef).toBeUndefined(); + }); + + test("Postgres: field.string isArray → text() + .array() (= text[])", () => { + const f = metaField(FIELD_SUBTYPE_STRING, "tags"); + f.setIsArray(true); + const spec = mapColumnType(f, "postgres"); + expect(spec.fnName).toBe("text"); + expect(spec.modifiers).toContain(".array()"); + expect(spec.dollarTypeRef).toBeUndefined(); + }); }); describe("mapColumnType — @dbColumnType physical override (R6 Plan 2b)", () => { diff --git a/server/typescript/packages/metadata/src/persistence/db/db-constants.ts b/server/typescript/packages/metadata/src/persistence/db/db-constants.ts index 49f539cc9..0da328eb9 100644 --- a/server/typescript/packages/metadata/src/persistence/db/db-constants.ts +++ b/server/typescript/packages/metadata/src/persistence/db/db-constants.ts @@ -40,18 +40,30 @@ export const DB_COLUMN_TYPE_UUID = "uuid"; export const DB_COLUMN_TYPE_JSONB = "jsonb"; /** `@dbColumnType: timestamp_with_tz` — `timestamp with time zone` (legal on field.timestamp). */ export const DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ = "timestamp_with_tz"; -/** `@dbColumnType: uuid_array` — native Postgres `uuid[]` column (legal on field.string). */ +/** + * `@dbColumnType: uuid_array` — RETIRED (dbColumnType slim-and-derive Phase 1). + * Native `uuid[]` is now DERIVED from `field.uuid` + `isArray`, not declared via an + * escape hatch. The constant is retained (so any remaining reference still resolves) + * but is no longer a legal value — the loader rejects it (ERR_BAD_ATTR_VALUE). + */ export const DB_COLUMN_TYPE_UUID_ARRAY = "uuid_array"; -/** `@dbColumnType: text_array` — native Postgres `text[]` column (legal on field.string). */ +/** + * `@dbColumnType: text_array` — RETIRED (dbColumnType slim-and-derive Phase 1). + * Native `text[]` is now DERIVED from `field.string` + `isArray`. Retained constant, + * no longer a legal value (loader rejects → ERR_BAD_ATTR_VALUE). + */ export const DB_COLUMN_TYPE_TEXT_ARRAY = "text_array"; -/** The closed set of legal `@dbColumnType` values (raw-dialect passthrough deferred). */ +/** + * The closed set of legal `@dbColumnType` values (raw-dialect passthrough deferred). + * dbColumnType slim-and-derive Phase 1: `uuid_array`/`text_array` are removed — native + * arrays are derived from `isArray`. `timestamp_with_tz` stays in Phase 1 (its default + * flip is Phase 2). + */ export const DB_COLUMN_TYPE_VALUES = [ DB_COLUMN_TYPE_UUID, DB_COLUMN_TYPE_JSONB, DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ, - DB_COLUMN_TYPE_UUID_ARRAY, - DB_COLUMN_TYPE_TEXT_ARRAY, ] as const; export type DbColumnTypeValue = (typeof DB_COLUMN_TYPE_VALUES)[number]; @@ -65,6 +77,4 @@ export const DB_COLUMN_TYPE_LEGAL_SUBTYPES: Readonly { + const { errors } = await load( + entityWith({ "field.string": { name: "members", "@dbColumnType": removed } }), + ); + const bad = errors.filter((e) => (e as { code?: string }).code === "ERR_BAD_ATTR_VALUE"); + expect(bad).toHaveLength(1); + expect(bad[0]!.message).toContain(removed); + }, + ); + it("does NOT re-flag a concrete field that inherits @dbColumnType from an abstract super (own-only check)", async () => { // The (subtype × value) pairing check is own-only (reads node.ownAttrs()): // the attr is validated on the declaring (abstract) node, so a concrete field diff --git a/server/typescript/packages/migrate-ts/src/expected-schema.ts b/server/typescript/packages/migrate-ts/src/expected-schema.ts index 26696da13..fd5548988 100644 --- a/server/typescript/packages/migrate-ts/src/expected-schema.ts +++ b/server/typescript/packages/migrate-ts/src/expected-schema.ts @@ -48,8 +48,6 @@ import { DB_COLUMN_TYPE_UUID, DB_COLUMN_TYPE_JSONB, DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ, - DB_COLUMN_TYPE_UUID_ARRAY, - DB_COLUMN_TYPE_TEXT_ARRAY, STORAGE_FLATTENED, DOC_ATTR_DESCRIPTION, applyColumnNamingStrategy, DEFAULT_COLUMN_NAMING_STRATEGY, @@ -708,21 +706,49 @@ function buildColumn( return col; } +/** + * The native Postgres array ELEMENT SqlType for an `isArray` scalar field, or + * undefined when the subtype has no native-array form (object/map → single jsonb + * column; everything else falls through to the scalar subtype default). + * + * dbColumnType slim-and-derive Phase 1 wires the two derived cases the design calls + * out: `field.string` → `text[]`, `field.uuid` → `uuid[]`. This mirrors codegen-ts's + * column-mapper, which emits native `.array()` for the same scalar subtypes. + */ +function arrayElementSqlType(field: MetaData): SqlType | undefined { + switch (field.subType) { + case FIELD_SUBTYPE_STRING: return { kind: "text" }; + case FIELD_SUBTYPE_UUID: return { kind: "uuid" }; + default: return undefined; + } +} + function subtypeToSqlType(field: MetaData): SqlType { // R6 Plan 2b: a physical @dbColumnType override selects the DB column type // instead of the subtype default (the loader has already validated the // (subtype × value) pairing, so an unrecognized value never reaches here). + // dbColumnType slim-and-derive Phase 1: the array overrides (uuid_array / + // text_array) are RETIRED — native text[]/uuid[] are derived from `isArray` + // below, not declared here. const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE); if (typeof dbColumnType === "string") { switch (dbColumnType) { case DB_COLUMN_TYPE_UUID: return { kind: "uuid" }; case DB_COLUMN_TYPE_JSONB: return { kind: "json" }; case DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ: return { kind: "timestamp", withTimezone: true }; - case DB_COLUMN_TYPE_UUID_ARRAY: return { kind: "array", element: { kind: "uuid" } }; - case DB_COLUMN_TYPE_TEXT_ARRAY: return { kind: "array", element: { kind: "text" } }; } } + // Native array columns are DERIVED from `isArray` (dbColumnType slim-and-derive + // Phase 1). Only scalar subtypes with a stable element SqlType get a native + // Postgres array (e.g. field.string → text[], field.uuid → uuid[]). field.object + // / field.map carry their array-ness inside a single jsonb column (no native + // array — handled by the subtype switch returning { kind: "json" }). + if (field.isArray) { + const element = arrayElementSqlType(field); + if (element !== undefined) return { kind: "array", element }; + } + const subType = field.subType; switch (subType) { case FIELD_SUBTYPE_STRING: { diff --git a/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts b/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts index 5a77b5696..2b015a602 100644 --- a/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts +++ b/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts @@ -148,19 +148,20 @@ describe("buildExpectedSchema — @autoSet timestamp default", () => { }); // --------------------------------------------------------------------------- -// @dbColumnType uuid_array / text_array → native SQL array columns -// (metaobjects can't model Postgres arrays via field subtypes; the physical -// @dbColumnType escape hatch carries them, emitting uuid[] / text[].) +// Native SQL array columns are DERIVED from `isArray` (dbColumnType slim-and-derive +// Phase 1): the array-ness already lives on the field's `isArray` axis, so the DDL +// derives `text[]` / `uuid[]` directly — no `@dbColumnType` escape hatch. The +// removed `uuid_array` / `text_array` values now fail the loader (ERR_BAD_ATTR_VALUE). // --------------------------------------------------------------------------- -describe("buildExpectedSchema — native array columns (@dbColumnType *_array)", () => { +describe("buildExpectedSchema — native array columns (derived from isArray)", () => { async function arrayCols() { const doc = { "metadata.root": { package: "acme", children: [ { "object.entity": { name: "Bag", children: [ { "field.long": { name: "id" } }, { "identity.primary": { "name": "id", "@fields": "id" } }, - { "field.string": { name: "memberIds", "@dbColumnType": "uuid_array" } }, - { "field.string": { name: "tags", "@dbColumnType": "text_array" } }, + { "field.uuid": { name: "memberIds", isArray: true } }, + { "field.string": { name: "tags", isArray: true } }, ] } }, ] } }; const { root, errors } = await new MetaDataLoader().load([ @@ -170,11 +171,45 @@ describe("buildExpectedSchema — native array columns (@dbColumnType *_array)", return new Map((buildExpectedSchema(root).tables[0]?.columns ?? []).map((c) => [c.name, c])); } - test("uuid_array → array of uuid; text_array → array of text", async () => { + test("field.uuid isArray → uuid[]; field.string isArray → text[]", async () => { const byName = await arrayCols(); expect(byName.get("member_ids")?.sqlType).toEqual({ kind: "array", element: { kind: "uuid" } }); expect(byName.get("tags")?.sqlType).toEqual({ kind: "array", element: { kind: "text" } }); }); + + test("field.uuid scalar → uuid; field.string scalar (no maxLength) → text", async () => { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Scalars", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "field.uuid": { name: "ownerId" } }, + { "field.string": { name: "label" } }, + ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(doc)), + ]); + expect(errors).toHaveLength(0); + const byName = new Map((buildExpectedSchema(root).tables[0]?.columns ?? []).map((c) => [c.name, c])); + expect(byName.get("owner_id")?.sqlType).toEqual({ kind: "uuid" }); + expect(byName.get("label")?.sqlType).toEqual({ kind: "text" }); + }); + + test("removed @dbColumnType:uuid_array / text_array now fail the loader (ERR_BAD_ATTR_VALUE)", async () => { + for (const removed of ["uuid_array", "text_array"]) { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Bag", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "field.string": { name: "x", "@dbColumnType": removed } }, + ] } }, + ] } }; + const { errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(doc)), + ]); + expect(errors.some((e) => (e as { code?: string }).code === "ERR_BAD_ATTR_VALUE")).toBe(true); + } + }); }); describe("buildExpectedSchema — indexes + FKs", () => {