Compare commits

..

3 Commits

Author SHA1 Message Date
hypercross e0317946d5 feat(csv-loader): expand custom type references in schemas
Allow custom type names to be expanded before parsing the schema
string. This enables using declared types within complex structures
like tuples or arrays. The original schema string is preserved for
type generation to ensure the output uses the named type rather than
the expanded inline definition.
2026-04-22 17:58:36 +08:00
hypercross 46504a53dd feat(csv-loader): preserve original schema strings in type generation
Store the original schema string during CSV parsing to prevent
unnecessary expansion of type name references in the generated
TypeScript definitions. This ensures that declared types reference
each other by name rather than inlining their full definitions.
2026-04-22 17:08:06 +08:00
hypercross ea362d4229 test(csv-loader): add tests for custom type expansion in tuples and
arrays
2026-04-22 16:43:42 +08:00
4 changed files with 125 additions and 12 deletions

View File

@ -160,7 +160,11 @@ export function parseCsv(
} }
// Parse type declarations with expansion of type name references // Parse type declarations with expansion of type name references
const typeDeclarationsParsed: { name: string; schema: Schema }[] = []; const typeDeclarationsParsed: {
name: string;
schema: Schema;
schemaString: string;
}[] = [];
for (const decl of typeDeclarationsRaw) { for (const decl of typeDeclarationsRaw) {
// Expand any type name references before parsing // Expand any type name references before parsing
const expandedSchema = expandSchemaString( const expandedSchema = expandSchemaString(
@ -168,7 +172,11 @@ export function parseCsv(
declaredSchemaStrings, declaredSchemaStrings,
); );
const schema = parseSchema(expandedSchema.trim()); const schema = parseSchema(expandedSchema.trim());
typeDeclarationsParsed.push({ name: decl.typeName, schema }); typeDeclarationsParsed.push({
name: decl.typeName,
schema,
schemaString: decl.schemaString,
});
} }
// Build declared types map // Build declared types map
@ -181,7 +189,11 @@ export function parseCsv(
const typeDeclarations: TypeDeclaration[] = []; const typeDeclarations: TypeDeclaration[] = [];
for (const decl of typeDeclarationsParsed) { for (const decl of typeDeclarationsParsed) {
const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes); const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes);
typeDeclarations.push({ name: decl.name, schema: resolvedSchema }); typeDeclarations.push({
name: decl.name,
schema: resolvedSchema,
schemaString: decl.schemaString,
});
} }
// Update declaredTypes with resolved schemas for column schema lookup // Update declaredTypes with resolved schemas for column schema lookup
@ -197,11 +209,21 @@ export function parseCsv(
// Check if schema string matches a declared type name // Check if schema string matches a declared type name
let schema: Schema; let schema: Schema;
let declaredTypeName: string | undefined; let declaredTypeName: string | undefined;
let columnSchemaString: string | undefined;
if (declaredTypes.has(schemaString)) { if (declaredTypes.has(schemaString)) {
schema = declaredTypes.get(schemaString)!; schema = declaredTypes.get(schemaString)!;
declaredTypeName = schemaString; declaredTypeName = schemaString;
} else { } else {
schema = parseSchema(schemaString); // Expand any custom type name references before parsing
const expandedSchema = expandSchemaString(
schemaString,
declaredSchemaStrings,
);
schema = parseSchema(expandedSchema.trim());
// Only preserve the original schema string if expansion actually changed it
if (expandedSchema !== schemaString) {
columnSchemaString = schemaString;
}
} }
const config: PropertyConfig = { const config: PropertyConfig = {
@ -210,6 +232,7 @@ export function parseCsv(
validator: createValidator(schema), validator: createValidator(schema),
parser: (valueString: string) => parseValue(schema, valueString), parser: (valueString: string) => parseValue(schema, valueString),
declaredTypeName, declaredTypeName,
schemaString: columnSchemaString,
}; };
if (schema.type === "reference") { if (schema.type === "reference") {

View File

@ -56,9 +56,9 @@ describe("parseCsv - type declarations", () => {
currentFilePath: path.join(fixturesDir, "card.csv"), currentFilePath: path.join(fixturesDir, "card.csv"),
}); });
expect(result.typeDefinition).toContain("type Trigger ="); expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain( expect(result.typeDefinition).toContain(
'"onPlay" | "onDraw" | "onDiscard"', "'onPlay' | 'onDraw' | 'onDiscard'",
); );
}); });
@ -93,8 +93,8 @@ describe("parseCsv - type declarations", () => {
currentFilePath: path.join(fixturesDir, "card.csv"), currentFilePath: path.join(fixturesDir, "card.csv"),
}); });
expect(result.typeDefinition).toContain("type Trigger ="); expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("type Status ="); expect(result.typeDefinition).toContain("export type Status =");
}); });
it("should ignore comment lines that are not type declarations", () => { it("should ignore comment lines that are not type declarations", () => {
@ -196,11 +196,96 @@ describe("parseCsv - type declarations", () => {
}); });
// Both type declarations should appear // Both type declarations should appear
expect(result.typeDefinition).toContain("type Trigger ="); expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("type Effect ="); expect(result.typeDefinition).toContain("export type Effect =");
// Column should use the declared type name, not expanded union // Column should use the declared type name, not expanded union
expect(result.typeDefinition).toContain("readonly trigger: Trigger;"); expect(result.typeDefinition).toContain("readonly trigger: Trigger;");
}); });
it("should expand custom type names inside tuples and arrays", () => {
const csv = [
"# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'",
"# type IntentEffect = [IntentEffectTarget; string; int]",
"# type IntentEffects = IntentEffect[]",
"id,effects",
"string,IntentEffects",
"boost,[user;spike;1];[user;defend;4]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(3);
const intentEffect = result.typeDeclarations.find(
(d) => d.name === "IntentEffect",
)!;
expect(intentEffect.schema.type).toBe("tuple");
// First element should be resolved to union, not a string "IntentEffectTarget"
const firstEl = (
intentEffect.schema as { elements: { schema: { type: string } }[] }
).elements[0].schema;
expect(firstEl.type).toBe("union");
});
it("should use declared type names in generated type definition for tuple arrays", () => {
const csv = [
"# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'",
"# type IntentEffect = [IntentEffectTarget; string; int]",
"# type IntentEffects = IntentEffect[]",
"id,effects",
"string,IntentEffects",
"boost,[user;spike;1];[user;defend;4]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "intent",
currentFilePath: path.join(fixturesDir, "intent.csv"),
});
expect(result.typeDefinition).toContain("export type IntentEffectTarget =");
expect(result.typeDefinition).toContain("export type IntentEffect =");
expect(result.typeDefinition).toContain("export type IntentEffects =");
// IntentEffect should reference IntentEffectTarget, not expand it
expect(result.typeDefinition).toContain(
"[IntentEffectTarget; string; int]",
);
// IntentEffects should reference IntentEffect, not expand it
expect(result.typeDefinition).toContain("IntentEffect[]");
// Column should reference IntentEffects, not inline expansion
expect(result.typeDefinition).toContain("readonly effects: IntentEffects;");
});
it("should handle custom type inside a tuple used as an array element", () => {
const csv = [
'# type Type = "apple" | "orange"',
"id,items",
"string,[Type; int][]",
"001,[apple;2];[orange;3]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
id: "001",
items: [
["apple", 2],
["orange", 3],
],
});
const typeResult = parseCsv(csv, {
emitTypes: true,
resourceName: "item",
currentFilePath: path.join(fixturesDir, "item.csv"),
});
expect(typeResult.typeDefinition).toContain("export type Type =");
expect(typeResult.typeDefinition).toContain("[Type; int][]");
expect(typeResult.typeDefinition).toContain(
"readonly items: [Type; int][];",
);
});
}); });
describe("parseCsv - type declarations with resolveReferences: false", () => { describe("parseCsv - type declarations with resolveReferences: false", () => {

View File

@ -51,7 +51,7 @@ export function generateTypeDefinition(
? typeDeclarations ? typeDeclarations
.map( .map(
(decl) => (decl) =>
`export type ${decl.name} = ${schemaToTypeString(decl.schema, resourceNames)};`, `export type ${decl.name} = ${decl.schemaString ?? schemaToTypeString(decl.schema, resourceNames)};`,
) )
.join("\n") + "\n\n" .join("\n") + "\n\n"
: ""; : "";
@ -60,7 +60,8 @@ export function generateTypeDefinition(
.map((config) => { .map((config) => {
const typeStr = config.declaredTypeName const typeStr = config.declaredTypeName
? config.declaredTypeName ? config.declaredTypeName
: schemaToTypeString(config.schema, resourceNames); : (config.schemaString ??
schemaToTypeString(config.schema, resourceNames));
return ` readonly ${config.name}: ${typeStr};`; return ` readonly ${config.name}: ${typeStr};`;
}) })
.join("\n"); .join("\n");

View File

@ -78,6 +78,8 @@ export interface PropertyConfig {
reverseReferenceForeignKey?: string; reverseReferenceForeignKey?: string;
/** When a column uses a declared type name, this stores that name */ /** When a column uses a declared type name, this stores that name */
declaredTypeName?: string; declaredTypeName?: string;
/** The original schema string for the column (preserves type name references for output) */
schemaString?: string;
} }
/** Parsed reverse reference declaration from a comment line */ /** Parsed reverse reference declaration from a comment line */
@ -98,6 +100,8 @@ export interface ReverseReferenceDeclaration {
export interface TypeDeclaration { export interface TypeDeclaration {
/** Name of the type being defined */ /** Name of the type being defined */
name: string; name: string;
/** The original schema string (preserves type name references for output) */
schemaString: string;
/** The parsed schema for this type */ /** The parsed schema for this type */
schema: Schema; schema: Schema;
} }