Skip to content
Merged
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
32 changes: 32 additions & 0 deletions server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ public void Emits_payload_record_with_scalar_and_nested_array_fields()
Assert.Contains("public required string title { get; init; }", src);
}

// Same shape as Model, but the nested @objectRef is authored FULLY-QUALIFIED
// (acme::ai::PostBrief) — the form a cross-package reference takes. The payload
// record type + the nested-record lookup must resolve to the BARE short name.
private const string FqnRefModel = """
{
"metadata.root": {
"package": "acme::ai",
"children": [
{ "object.value": { "name": "PostBrief", "children": [
{ "field.string": { "name": "title" } }
]}},
{ "object.value": { "name": "AuthorBrief", "children": [
{ "field.string": { "name": "displayName" } },
{ "field.object": { "name": "posts", "isArray": true, "@objectRef": "acme::ai::PostBrief",
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
]}}
]
}
}
""";

[Fact]
public void Fully_qualified_objectRef_strips_to_bare_record_type_and_resolves_nested()
{
var root = new MetaDataLoader().Load([new InMemoryStringSource(FqnRefModel, id: "fqn.json")]).Root;
var src = PayloadCodegen.GeneratePayloadRecords(root, "AuthorBrief");
// Regression: the FQN must NOT leak into the generated C# type or record name.
Assert.Contains("public required IReadOnlyList<PostBrief> posts { get; init; }", src);
Assert.Contains("public sealed record PostBrief", src); // nested record DID resolve (FindObject matched the bare name)
Assert.DoesNotContain("acme::ai::", src);
}

[Fact]
public void Emits_render_handle_binding_textRef_and_format()
{
Expand Down
9 changes: 6 additions & 3 deletions server/csharp/MetaObjects.Codegen/PayloadCodegen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ private static (string Type, string? RefVo) FieldType(MetaData owner, MetaData f
if (field.SubType == FIELD_SUBTYPE_OBJECT)
{
var refAttr = field.OwnAttr(FIELD_ATTR_OBJECT_REF);
string refName = refAttr is string s ? s : "object";
string? refVo = refAttr is string r ? r : null;
// @objectRef may be authored fully-qualified (acme::sales::Brief) or bare;
// the generated record type + the nested-record lookup key are the BARE
// short name (FindObject matches bare names — every other caller strips too).
string refName = refAttr is string s ? CSharpNaming.StripPkg(s) : "object";
string? refVo = refAttr is string r ? CSharpNaming.StripPkg(r) : null;
return (IsArrayField(field) ? $"IReadOnlyList<{refName}>" : refName, refVo);
}
// Enum payload field -> the generated nested C# enum type (same scheme entity codegen uses
Expand Down Expand Up @@ -155,7 +158,7 @@ private static IReadOnlyList<PayloadField> BuildTree(MetaData root, string voNam
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
{
if (f.SubType == FIELD_SUBTYPE_OBJECT && f.OwnAttr(FIELD_ATTR_OBJECT_REF) is string refName)
fields.Add(new PayloadField(f.Name, BuildTree(root, refName, visiting)));
fields.Add(new PayloadField(f.Name, BuildTree(root, CSharpNaming.StripPkg(refName), visiting)));
else
fields.Add(new PayloadField(f.Name));
}
Expand Down
7 changes: 5 additions & 2 deletions server/typescript/packages/codegen-ts/src/column-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
} from "@metaobjectsdev/metadata";
import { columnNameFromField } from "./naming.js";
import { enumValues } from "./enum-meta.js";
import { DEFAULT_COLUMN_NAMING_STRATEGY } from "@metaobjectsdev/metadata";
import { DEFAULT_COLUMN_NAMING_STRATEGY, stripPackage } from "@metaobjectsdev/metadata";
import type { Dialect, ColumnNamingStrategy } from "./metaobjects-config.js";

export type { Dialect };
Expand Down Expand Up @@ -428,7 +428,10 @@ export function mapColumnType(
if (subType === FIELD_SUBTYPE_OBJECT) {
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
dollarTypeRef = { kind: "objectRef", name: ref, module: `./${ref}.js` };
// @objectRef may be authored fully-qualified or bare — the $type<E[]>()
// target interface + its sibling module use the BARE short name.
const base = stripPackage(ref);
dollarTypeRef = { kind: "objectRef", name: base, module: `./${base}.js` };
}
} else {
const scalar = sqliteJsonArrayElementTsType(subType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
if (field.subType === FIELD_SUBTYPE_OBJECT) {
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
const refImp = imp(`${ref}@./${ref}.js`);
// @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; the
// referenced interface + its sibling module are named by the BARE short name.
const base = stripPackage(ref);
const refImp = imp(`${base}@./${base}.js`);
return field.isArray ? code`${refImp}[]` : code`${refImp}`;
}
return field.isArray ? code`unknown[]` : code`unknown`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// downstream (e.g. to LLM tool_use input_schema) lost the nested object shape.

import { code, joinCode, imp, type Code } from "ts-poet";
import { MetaObject, MetaField } from "@metaobjectsdev/metadata";
import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata";
import {
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
Expand Down Expand Up @@ -328,7 +328,10 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
if (field.subType === FIELD_SUBTYPE_OBJECT) {
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
const refImp = imp(`${ref}InsertSchema@./${ref}.js`);
// @objectRef may be authored fully-qualified or bare — the referenced
// <Ref>InsertSchema + its sibling module use the BARE short name.
const refBase = stripPackage(ref);
const refImp = imp(`${refBase}InsertSchema@./${refBase}.js`);
let base: Code = code`${refImp}`;
if (field.isArray) base = code`z.array(${base})`;
return appendValidatorChain(base, field);
Expand Down
11 changes: 11 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 @@ -88,6 +88,17 @@ describe("mapColumnType — SQLite", () => {
expect(spec.dollarTypeRef).toEqual({ kind: "objectRef", name: "SourceLens", module: "./SourceLens.js" });
});

test("@isArray field.object with a FULLY-QUALIFIED @objectRef strips to the bare name", () => {
// Regression: a cross-package @objectRef (acme::ai::SourceLens) must resolve to
// the BARE short name for both the $type<E[]>() target and its sibling module —
// emitting the raw FQN produces an invalid identifier + a colon-laden import path.
const f = metaField(FIELD_SUBTYPE_OBJECT, "citations");
f.setAttr("objectRef", "acme::ai::SourceLens");
f.setIsArray(true);
const spec = mapColumnType(f, "sqlite");
expect(spec.dollarTypeRef).toEqual({ kind: "objectRef", name: "SourceLens", module: "./SourceLens.js" });
});

test("@isArray on field.object without objectRef leaves dollarTypeRef unset", () => {
const f = metaField(FIELD_SUBTYPE_OBJECT, "stuff");
f.setIsArray(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ describe("renderValueObjectFile", () => {
expect(out).not.toContain("UpdateSchema");
});

test("field.object with a FULLY-QUALIFIED @objectRef strips to the bare type + sibling module", () => {
// Regression: a cross-package @objectRef (acme::ai::SourceLens) must resolve to
// the BARE short name in BOTH the emitted interface field type and the
// <Ref>InsertSchema reference — the raw FQN produces an invalid TS identifier
// (`acme::ai::SourceLens`) and a colon-laden import path.
const wo = metaObject(OBJECT_SUBTYPE_VALUE, "Brief");
const citations = metaField(FIELD_SUBTYPE_OBJECT, "citations");
citations.setAttr("objectRef", "acme::ai::SourceLens");
citations.isArray = true;
wo.addChild(citations);

const out = renderValueObjectFile(wo);
expect(out).toMatch(/citations\?:\s*SourceLens\[\];/);
expect(out).toContain("SourceLensInsertSchema");
expect(out).not.toContain("acme::ai::");
});

test("required-field-only value object emits no question marks", () => {
const sl = metaObject(OBJECT_SUBTYPE_VALUE, "SourceLens");
for (const name of ["title", "url", "snippet"]) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, test, expect } from "bun:test";
import { TypeId, TYPE_IDENTITY, TYPE_VALIDATOR,
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_ENUM,
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT,
IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY,
VALIDATOR_SUBTYPE_REGEX,
MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
Expand Down Expand Up @@ -51,6 +51,26 @@ describe("renderZodValidators", () => {
expect(out).not.toContain("z.string()"); // no fallback string type for enum field
});

test("field.object with a FULLY-QUALIFIED @objectRef references <Bare>InsertSchema, not the FQN", () => {
// Regression: a cross-package @objectRef (acme::ai::SourceLens) must resolve to
// the BARE short name for the <Ref>InsertSchema symbol + its sibling module —
// the raw FQN emits an invalid `acme::ai::SourceLensInsertSchema` identifier.
const brief = metaObject(OBJECT_SUBTYPE_ENTITY, "Brief");
const id = metaField(FIELD_SUBTYPE_LONG, "id");
brief.addChild(id);
const lens = metaField(FIELD_SUBTYPE_OBJECT, "lens");
lens.setAttr("objectRef", "acme::ai::SourceLens");
brief.addChild(lens);
const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary");
primary.setAttr("fields", ["id"]);
primary.setAttr("generation", "increment");
brief.addChild(primary);

const out = renderZodValidators(brief).toString();
expect(out).toContain("SourceLensInsertSchema");
expect(out).not.toContain("acme::ai::");
});

test("validator.regex emits .regex(new RegExp(pattern)) — Zod expects a RegExp object, not a string", () => {
const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post");
const slug = metaField(FIELD_SUBTYPE_STRING, "slug");
Expand Down
Loading