Skip to content

Commit b707b81

Browse files
authored
fix(#19, #20): support complex unions and literal unions (#25)
1 parent e7f1535 commit b707b81

File tree

4 files changed

+173
-6
lines changed

4 files changed

+173
-6
lines changed

src/emitter.ts

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
RecordModelType,
88
Scalar,
99
Type,
10+
Union,
1011
} from "@typespec/compiler";
1112
import {
1213
type EmitContext,
@@ -18,7 +19,7 @@ import type { Attribute, CustomAttribute, Schema } from "electrodb";
1819
import * as ts from "typescript";
1920

2021
import { StateKeys } from "./lib.js";
21-
import { stringifyObject } from "./stringify.js";
22+
import { RawCode, stringifyObject } from "./stringify.js";
2223

2324
function emitIntrinsincScalar(type: Scalar) {
2425
switch (type.name) {
@@ -109,6 +110,105 @@ function emitEnumModel(type: Enum): Attribute {
109110
};
110111
}
111112

113+
function emitTypeToTypeScript(type: Type): string {
114+
switch (type.kind) {
115+
case "Scalar": {
116+
let baseType = type;
117+
while (baseType.baseScalar) {
118+
baseType = baseType.baseScalar;
119+
}
120+
switch (baseType.name) {
121+
case "boolean":
122+
return "boolean";
123+
case "numeric":
124+
case "integer":
125+
case "float":
126+
case "int64":
127+
case "int32":
128+
case "int16":
129+
case "int8":
130+
case "uint64":
131+
case "uint32":
132+
case "uint16":
133+
case "uint8":
134+
case "safeint":
135+
case "float32":
136+
case "float64":
137+
case "decimal":
138+
case "decimal128":
139+
return "number";
140+
default:
141+
return "string";
142+
}
143+
}
144+
case "Model": {
145+
if (type.name === "Array") {
146+
const arrayType = type as ArrayModelType;
147+
return `${emitTypeToTypeScript(arrayType.indexer.value)}[]`;
148+
}
149+
const properties: string[] = [];
150+
for (const prop of walkPropertiesInherited(type as RecordModelType)) {
151+
const optional = prop.optional ? "?" : "";
152+
properties.push(
153+
`${prop.name}${optional}: ${emitTypeToTypeScript(prop.type)}`,
154+
);
155+
}
156+
return `{ ${properties.join("; ")} }`;
157+
}
158+
case "Enum": {
159+
const values = Array.from(type.members)
160+
.map(([key, member]) => `"${member.value ?? key}"`)
161+
.join(" | ");
162+
return values;
163+
}
164+
case "Union": {
165+
const variants = Array.from(type.variants.values())
166+
.map((variant) => emitTypeToTypeScript(variant.type))
167+
.join(" | ");
168+
return variants;
169+
}
170+
default:
171+
return "any";
172+
}
173+
}
174+
175+
function isLiteralUnion(type: Union): string[] | null {
176+
const literals: string[] = [];
177+
178+
for (const variant of type.variants.values()) {
179+
// Check if this variant is a string or number literal
180+
if (variant.type.kind === "String") {
181+
literals.push(variant.type.value);
182+
} else if (variant.type.kind === "Number") {
183+
literals.push(String(variant.type.value));
184+
} else {
185+
// Not a literal union, return null
186+
return null;
187+
}
188+
}
189+
190+
return literals;
191+
}
192+
193+
function emitUnion(type: Union): Attribute {
194+
// Check if this is a simple literal union (e.g., "home" | "work" | "other")
195+
const literals = isLiteralUnion(type);
196+
if (literals) {
197+
// Emit as enum-like array, similar to how named enums are handled
198+
return {
199+
type: literals,
200+
};
201+
}
202+
203+
// Complex union - use CustomAttributeType
204+
const tsType = emitTypeToTypeScript(type);
205+
// RawCode is used to emit the CustomAttributeType function call as-is
206+
return {
207+
// @ts-expect-error - RawCode is handled by stringifyObject at code generation time
208+
type: new RawCode(`CustomAttributeType<${tsType}>("any")`),
209+
};
210+
}
211+
112212
function emitType(type: Type): Attribute {
113213
switch (type.kind) {
114214
case "Scalar":
@@ -118,7 +218,7 @@ function emitType(type: Type): Attribute {
118218
case "Enum":
119219
return emitEnumModel(type);
120220
case "Union":
121-
return { type: "string" };
221+
return emitUnion(type);
122222
default:
123223
throw new Error(`Type kind ${type.kind} is currently not supported!`);
124224
}
@@ -219,13 +319,23 @@ export async function $onEmit(context: EmitContext) {
219319
};
220320
}
221321

222-
const typescriptSource = Object.entries(entities)
322+
const entityDefinitions = Object.entries(entities)
223323
.map(
224324
([name, schema]) =>
225325
`export const ${name} = ${stringifyObject(schema as unknown as Record<string, unknown>)} as const`,
226326
)
227327
.join("\n");
228328

329+
// Add CustomAttributeType import if any union types are used
330+
const hasCustomAttributeType = entityDefinitions.includes(
331+
"CustomAttributeType",
332+
);
333+
const imports = hasCustomAttributeType
334+
? 'import { CustomAttributeType } from "electrodb";\n\n'
335+
: "";
336+
337+
const typescriptSource = imports + entityDefinitions;
338+
229339
const declarations = await ts.transpileDeclaration(typescriptSource, {});
230340
const javascript = await ts.transpileModule(typescriptSource, {});
231341

src/stringify.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { generate } from "@babel/generator";
33
import { parseExpression } from "@babel/parser";
44
import type { Expression, ObjectExpression } from "@babel/types";
55

6+
/**
7+
* Marker class for raw code expressions that should be emitted as-is
8+
*/
9+
export class RawCode {
10+
constructor(public readonly code: string) {}
11+
}
12+
613
function isObjectExpression(target: unknown): target is ObjectExpression {
714
return (target as Expression | undefined)?.type === "ObjectExpression";
815
}
@@ -26,6 +33,10 @@ const stringifyValue = (value: unknown) => {
2633
case "boolean":
2734
return value.toString();
2835
case "object":
36+
if (value instanceof RawCode) {
37+
return value.code;
38+
}
39+
2940
if (Array.isArray(value)) {
3041
return stringifyArray(value);
3142
}
@@ -45,7 +56,9 @@ const stringifyArray = (value: unknown[]): string => {
4556
};
4657

4758
const stringifyKeyValue = (key: string, value: unknown) => {
48-
return parseExpression(`{ ${key}: ${stringifyValue(value)} }`);
59+
return parseExpression(`{ ${key}: ${stringifyValue(value)} }`, {
60+
plugins: ["typescript"],
61+
});
4962
};
5063

5164
/**

test/entities.test.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ suite("Person Entity", () => {
169169
});
170170
});
171171

172-
test("address.type property (union literal) is string", () => {
172+
test("address.type property (literal union) has enum-like values", () => {
173173
assert.deepEqual(Person.attributes.address.properties.type, {
174-
type: "string",
174+
type: ["home", "work", "other"],
175175
required: true,
176176
});
177177
});
@@ -228,6 +228,34 @@ suite("Person Entity", () => {
228228
});
229229
});
230230

231+
suite("Union type (Info[] with BooleanValue | Int64Value)", () => {
232+
test("additionalInfo is a list type with required: true", () => {
233+
assert.equal(Person.attributes.additionalInfo.type, "list");
234+
assert.equal(Person.attributes.additionalInfo.required, true);
235+
});
236+
237+
test("additionalInfo items are map type", () => {
238+
assert.equal(Person.attributes.additionalInfo.items.type, "map");
239+
});
240+
241+
test("additionalInfo item has name property as string", () => {
242+
assert.deepEqual(
243+
Person.attributes.additionalInfo.items.properties.name,
244+
{
245+
type: "string",
246+
required: true,
247+
},
248+
);
249+
});
250+
251+
test("additionalInfo item value property uses CustomAttributeType for union", () => {
252+
const valueAttr = Person.attributes.additionalInfo.items.properties.value;
253+
// CustomAttributeType("any") returns "any" at runtime
254+
assert.equal(valueAttr.type, "any");
255+
assert.equal(valueAttr.required, true);
256+
});
257+
});
258+
231259
suite("Index configurations", () => {
232260
test("persons index (primary, pk only)", () => {
233261
const personsIndex = Person.indexes.persons;

test/main.tsp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ enum CountryCode {
2424
US,
2525
DE,
2626
}
27+
model Int64Value {
28+
value: int64;
29+
type: "int64";
30+
}
31+
32+
model BooleanValue {
33+
value: boolean;
34+
type: "boolean";
35+
}
36+
37+
model Info {
38+
name: string;
39+
value: BooleanValue | Int64Value;
40+
}
2741

2842
model Address {
2943
street: String64;
@@ -106,5 +120,7 @@ model Person {
106120

107121
status: PersonStatus;
108122

123+
additionalInfo: Info[];
124+
109125
coffeePreferences: CoffeePreferences[];
110126
}

0 commit comments

Comments
 (0)