Skip to content

Commit af411f9

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): read field attributes via effective attr(), not ownAttr()
Field-level `extends` (abstract fields — a concrete field extending a package-level abstract field to inherit `required` / `maxLength` / `default` / etc.) was silently dropping the inherited attributes: the generators read them with `ownAttr()` (own declarations only), so an inherited `@required` produced a NULLABLE column, an inherited `@maxLength` was ignored, etc. Output was under-constrained — e.g. a field `{ extends: Name }` of an abstract `Name {required, maxLength:200}` generated `text("name")` instead of `varchar("name",{length:200}).notNull()`. This surfaced after the collect-all rework (#52) made inherited attrs resolve as effective-but-not-own; the generators' long-standing `ownAttr()` usage then missed them. Switched every FIELD_ATTR_* read (57 sites across column-mapper, drizzle-schema, zod-validators, inferred-types, entity-constants, filters, payload/extract, docs, …) to the effective `attr()`. For a field with no `extends`, `attr() === ownAttr()`, so this is a no-op for all existing output (codegen-ts 803/0, byte-identical goldens); it only restores the inherited case. Identity/object/source/validator reads stay `ownAttr()` (correctly own-only). Also: buildFkMapForEntity now strips the package from the FK target before the lookup (`findObject(stripPackage(targetName))`) — the YAML front-end resolves `@references` to an FQN (`pkg::Entity`), and the un-stripped lookup dropped every FK under YAML. Mirrors the relation-resolver, which already strips. Tests: a YAML fixture exercising both (abstract-field required+maxLength inheritance, and a package-qualified FK target). codegen-ts 803/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1ca86e4 commit af411f9

23 files changed

Lines changed: 143 additions & 67 deletions

server/typescript/packages/codegen-ts/src/column-mapper.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ function pgColumnTypeOverride(
170170
field: MetaField,
171171
timestampMode: "date" | "string" = "string",
172172
): { fnName: string; fnOptions?: Record<string, unknown> } | undefined {
173-
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
173+
const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
174174
if (typeof dbColumnType !== "string") return undefined;
175175
switch (dbColumnType) {
176176
case DB_COLUMN_TYPE_UUID:
@@ -191,7 +191,7 @@ function pgColumnTypeOverride(
191191
/** Resolve max length from validator.length child or @maxLength attr.
192192
* Uses field.validators() (effective) so inherited validators are seen. */
193193
function getMaxLength(field: MetaField): number | undefined {
194-
const lenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
194+
const lenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
195195
if (typeof lenAttr === "number") return lenAttr;
196196
for (const child of field.validators()) {
197197
if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
@@ -205,15 +205,15 @@ function getMaxLength(field: MetaField): number | undefined {
205205
/** Check for validator.required child OR @required attr.
206206
* Uses field.validators() (effective) so inherited validators are seen. */
207207
function isRequired(field: MetaField): boolean {
208-
if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
208+
if (field.attr(FIELD_ATTR_REQUIRED) === true) return true;
209209
return field.validators().some((child) => child.subType === VALIDATOR_SUBTYPE_REQUIRED);
210210
}
211211

212212
/** The bare (package-stripped) @objectRef name on a field.object, or undefined
213213
* when unset. Used as the `.$type<VO>()` target + its sibling-module import.
214214
* A fully-qualified ref (acme::ai::SourceLens) strips to the short name. */
215215
function objectRefBaseName(field: MetaField): string | undefined {
216-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
216+
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
217217
if (typeof ref === "string" && ref.length > 0) return stripPackage(ref);
218218
return undefined;
219219
}
@@ -339,8 +339,8 @@ export function mapColumnType(
339339
// declared @precision/@scale (mirroring migrate-ts/expected-schema so
340340
// codegen and the DDL agree), falling back to a sane default.
341341
fnName = "numeric";
342-
const precision = field.ownAttr(FIELD_ATTR_PRECISION);
343-
const scale = field.ownAttr(FIELD_ATTR_SCALE);
342+
const precision = field.attr(FIELD_ATTR_PRECISION);
343+
const scale = field.attr(FIELD_ATTR_SCALE);
344344
if (typeof precision === "number" && typeof scale === "number") {
345345
fnOptions = { precision, scale };
346346
} else if (typeof precision === "number") {
@@ -414,12 +414,12 @@ export function mapColumnType(
414414
modifiers.push(".notNull()");
415415
}
416416

417-
if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) {
417+
if (field.attr(FIELD_ATTR_UNIQUE) === true) {
418418
modifiers.push(".unique()");
419419
}
420420

421421
let defaultExpr: DefaultExpr | undefined;
422-
const defaultAttr = field.ownAttr(FIELD_ATTR_DEFAULT);
422+
const defaultAttr = field.attr(FIELD_ATTR_DEFAULT);
423423
if (defaultAttr !== undefined) {
424424
// SQL-expression detection runs on the raw string value — a string like
425425
// "CURRENT_TIMESTAMP" or "now" must be emitted as sql`...`, not a literal.

server/typescript/packages/codegen-ts/src/enum-shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function sharedEnumForField(field: MetaField): SharedEnum | undefined {
6262
return {
6363
name: toPascalCase(decl.name),
6464
values,
65-
provided: decl.ownAttr(FIELD_ATTR_PROVIDED) === true,
65+
provided: decl.attr(FIELD_ATTR_PROVIDED) === true,
6666
};
6767
}
6868

server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export interface BuildDocDataOpts {
8282
* the documented model-field optionality. Exported so the field-shape builder
8383
* reuses the EXACT same rule rather than re-deriving it. */
8484
export function isFieldRequired(field: MetaField): boolean {
85-
if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
85+
if (field.attr(FIELD_ATTR_REQUIRED) === true) return true;
8686
return field.validators().some((v) => v.subType === VALIDATOR_SUBTYPE_REQUIRED);
8787
}
8888

@@ -104,7 +104,7 @@ interface ValidatorParts {
104104
}
105105

106106
function collectValidatorParts(field: MetaField): ValidatorParts {
107-
const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
107+
const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
108108
const regexParts: string[] = [];
109109
const lengthParts: string[] = [];
110110
const numericParts: string[] = [];
@@ -143,7 +143,7 @@ function collectValidatorParts(field: MetaField): ValidatorParts {
143143
export function neutralTypeStr(field: MetaField): string {
144144
let base: string;
145145
if (field.subType === FIELD_SUBTYPE_OBJECT) {
146-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
146+
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
147147
base = typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : "object";
148148
} else {
149149
base = field.subType;
@@ -165,7 +165,7 @@ function neutralTypeCell(field: MetaField): string {
165165
* uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the
166166
* migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */
167167
function storageTypeCell(field: MetaField): string {
168-
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
168+
const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
169169
if (typeof dbColumnType === "string" && dbColumnType.length > 0) {
170170
return `\`${dbColumnType.toUpperCase()}\``;
171171
}
@@ -190,7 +190,7 @@ function buildConstraintRow(
190190
const rules: string[] = [];
191191

192192
if (isPk) rules.push("primary key");
193-
if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
193+
if (field.attr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
194194

195195
if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
196196
const values = enumValues(field);
@@ -212,7 +212,7 @@ function buildConstraintRow(
212212
rules.push(`references \`${fk.targetEntity}.${fk.targetField}\``);
213213
}
214214

215-
const def = field.ownAttr(FIELD_ATTR_DEFAULT);
215+
const def = field.attr(FIELD_ATTR_DEFAULT);
216216
if (def !== undefined) rules.push(`default: \`${String(def)}\``);
217217

218218
const sup = field.resolveSuper();
@@ -271,7 +271,7 @@ function buildFieldRow(
271271
// Otherwise empty. Keeps the column noise-free for the 90% case where
272272
// field name and column name agree.
273273
const columnName = field.column;
274-
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
274+
const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
275275
const columnDiffers = typeof columnName === "string" && columnName !== field.name;
276276
const hasPhysicalOverride = typeof dbColumnType === "string" && dbColumnType.length > 0;
277277
let storageCell = "";
@@ -287,7 +287,7 @@ function buildFieldRow(
287287
// column, plus the maxLength/length/numeric limits that used to live in
288288
// the separate Limits cell (collapsed in to keep the table to 5 columns).
289289
const rules: string[] = [];
290-
if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
290+
if (field.attr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
291291

292292
if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
293293
const values = enumValues(field);
@@ -303,7 +303,7 @@ function buildFieldRow(
303303
rules.push(...lengthParts, ...numericParts);
304304

305305
// The FK reference is already encoded in typeCell — don't repeat it in rules.
306-
const def = field.ownAttr(FIELD_ATTR_DEFAULT);
306+
const def = field.attr(FIELD_ATTR_DEFAULT);
307307
if (def !== undefined) rules.push(`default: \`${String(def)}\``);
308308

309309
const sup = field.resolveSuper();
@@ -340,10 +340,10 @@ function buildFieldDetail(
340340
const hasSummary = typeof summary === "string" && summary.length > 0;
341341
const sup = field.resolveSuper();
342342
const fk = fkMap.get(field.name);
343-
const def = field.ownAttr(FIELD_ATTR_DEFAULT);
343+
const def = field.attr(FIELD_ATTR_DEFAULT);
344344
const columnName = field.column;
345-
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
346-
const isUnique = field.ownAttr(FIELD_ATTR_UNIQUE) === true;
345+
const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
346+
const isUnique = field.attr(FIELD_ATTR_UNIQUE) === true;
347347
const isEnum = field.subType === FIELD_SUBTYPE_ENUM && !field.isArray;
348348
const enumVals = isEnum ? enumValues(field) : undefined;
349349
const validators = field.validators();
@@ -352,7 +352,7 @@ function buildFieldDetail(
352352
|| v.subType === VALIDATOR_SUBTYPE_REGEX
353353
|| v.subType === VALIDATOR_SUBTYPE_NUMERIC,
354354
);
355-
const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
355+
const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
356356

357357
// "Interesting enough to render a detail block" predicate. Plain typed
358358
// fields with no authored annotations get skipped — the at-a-glance Fields

server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function buildEnrichedPayloadTree(
5858
required: isFieldRequired(f),
5959
};
6060
if (f.subType === FIELD_SUBTYPE_OBJECT) {
61-
const ref = f.ownAttr(FIELD_ATTR_OBJECT_REF);
61+
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
6262
if (typeof ref === "string" && ref.length > 0) {
6363
// Owner switches to the nested VO for its fields (the recursion below
6464
// re-derives `owner` from the resolved VO's own name).

server/typescript/packages/codegen-ts/src/payload-codegen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function fieldTsType(
6565
ownerName: string,
6666
): { type: string; refVo?: string; enumAlias?: { name: string; decl: string } } {
6767
if (field.subType === FIELD_SUBTYPE_OBJECT) {
68-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
68+
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
6969
const refName = typeof ref === "string" ? ref : "unknown";
7070
// isArray is a structural property on MetaData, not an attr.
7171
const isArray = field.isArray;
@@ -96,7 +96,7 @@ function fieldTsType(
9696

9797
/** True iff the field's @required is explicitly set to true. */
9898
function isFieldRequired(field: MetaData): boolean {
99-
return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
99+
return field.attr(FIELD_ATTR_REQUIRED) === true;
100100
}
101101

102102
function emitInterface(

server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ function sourceColumnNameFor(
135135
entityField: MetaData,
136136
ctx: ExtractContext,
137137
): string {
138-
const col = entityField.ownAttr(FIELD_ATTR_COLUMN);
138+
const col = entityField.attr(FIELD_ATTR_COLUMN);
139139
if (typeof col === "string" && col !== "") return col;
140140
return columnNameFromField(entityField.name, ctx.columnNamingStrategy);
141141
}

server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// plus the relations() block auto-emitted at the end.
44

55
import { code, imp, joinCode, type Code } from "ts-poet";
6-
import { MetaObject, MetaField } from "@metaobjectsdev/metadata";
6+
import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata";
77
import {
88
IDENTITY_SUBTYPE_SECONDARY, FIELD_SUBTYPE_LONG,
99
IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_UNIQUE,
@@ -184,7 +184,11 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, F
184184
const fkField = fkFieldNames[0]!;
185185
const targetName = ref.targetEntity;
186186
if (!targetName) continue;
187-
const targetObj = ctx.loadedRoot.findObject(targetName);
187+
// @references may be authored bare OR package-qualified, and the loader can
188+
// resolve it to an FQN (e.g. the YAML front-end qualifies it). Strip the
189+
// package so the lookup matches the object's bare name — mirrors the
190+
// relation-resolver, which already does this.
191+
const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName));
188192
if (!targetObj) continue;
189193
const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id";
190194
result.set(fkField, {
@@ -345,7 +349,7 @@ function renderColumn(
345349
// @autoSet fields: emit .$defaultFn(() => new Date().toISOString()) so Drizzle
346350
// inserts stamp the server-side timestamp automatically. This means callers don't
347351
// need to supply createdAt / updatedAt in INSERT calls — Drizzle fills them in.
348-
const autoSet = field.ownAttr(FIELD_ATTR_AUTO_SET);
352+
const autoSet = field.attr(FIELD_ATTR_AUTO_SET);
349353
const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate")
350354
? `.$defaultFn(() => new Date().toISOString())`
351355
: "";

server/typescript/packages/codegen-ts/src/templates/entity-constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,12 @@ function renderFieldRules(field: MetaField): string | undefined {
160160
}
161161

162162
// Field-level @required attr (if not already covered by validator).
163-
if (!hasRequired && field.ownAttr(FIELD_ATTR_REQUIRED) === true) {
163+
if (!hasRequired && field.attr(FIELD_ATTR_REQUIRED) === true) {
164164
ruleParts.push(`required: ${JSON.stringify(`${humanize(field.name)} is required`)}`);
165165
}
166166

167167
// Field-level @maxLength attr (if not already covered).
168-
const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
168+
const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
169169
if (!hasMaxLength && typeof maxLenAttr === "number") {
170170
ruleParts.push(
171171
`maxLength: { value: ${maxLenAttr}, message: ${JSON.stringify(`Must be ${maxLenAttr} characters or fewer`)} }`,

server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function findObject(root: MetaData, name: string): MetaData | undefined {
3737

3838
/** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
3939
function refVo(field: MetaData, root: MetaData): MetaData | undefined {
40-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
40+
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
4141
if (typeof ref !== "string") return undefined;
4242
const direct = findObject(root, ref);
4343
if (direct !== undefined) return direct;

server/typescript/packages/codegen-ts/src/templates/extractor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function findTemplate(root: MetaData, name: string): MetaData | undefined {
5050

5151
/** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
5252
function refVo(field: MetaData, root: MetaData): MetaData | undefined {
53-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
53+
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
5454
if (typeof ref !== "string") return undefined;
5555
const direct = findObject(root, ref);
5656
if (direct !== undefined) return direct;
@@ -85,7 +85,7 @@ function enumAlias(field: MetaData, ownerName: string): string | undefined {
8585
* required test (boolean `true` only) so the two never skew.
8686
*/
8787
function isFieldRequired(field: MetaData): boolean {
88-
return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
88+
return field.attr(FIELD_ATTR_REQUIRED) === true;
8989
}
9090

9191
/** The mirror→strict mapper name for a value-object (`toStrict<Name>`). */

0 commit comments

Comments
 (0)