Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions server/typescript/packages/codegen-ts/test/column-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -65,6 +77,4 @@ export const DB_COLUMN_TYPE_LEGAL_SUBTYPES: Readonly<Record<DbColumnTypeValue, r
[DB_COLUMN_TYPE_UUID]: [FIELD_SUBTYPE_STRING],
[DB_COLUMN_TYPE_JSONB]: [FIELD_SUBTYPE_STRING],
[DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ]: [FIELD_SUBTYPE_TIMESTAMP],
[DB_COLUMN_TYPE_UUID_ARRAY]: [FIELD_SUBTYPE_STRING],
[DB_COLUMN_TYPE_TEXT_ARRAY]: [FIELD_SUBTYPE_STRING],
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,21 @@ describe("attr-schema validation — @dbColumnType (subtype × value) pairing (R
expect(bad[0]!.message).toContain("tsvector");
});

// dbColumnType slim-and-derive Phase 1: the array values are REMOVED — native
// text[]/uuid[] are derived from `isArray` on field.string/field.uuid, never
// declared via an escape hatch. Both now fail the loader.
it.each(["uuid_array", "text_array"])(
"rejects removed @dbColumnType:%s → ERR_BAD_ATTR_VALUE (derive from isArray instead)",
async (removed) => {
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
Expand Down
34 changes: 30 additions & 4 deletions server/typescript/packages/migrate-ts/src/expected-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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", () => {
Expand Down
Loading