From 56b4374edd002adb778ac7f943bf75d184b89b44 Mon Sep 17 00:00:00 2001 From: "Thomas V." <2619415+tvillaren@users.noreply.github.com> Date: Sun, 24 Nov 2024 15:22:19 +0100 Subject: [PATCH] fix(Imports): support for named imports in generated files (#287) * fix: update generated integration file * fix: add support for named imports * test: add one more * chore: disable codecov --- example/heros.zod.ts | 7 +- src/core/generate.test.ts | 128 +++++++++++++++++++++++++++++ src/core/generate.ts | 33 +++++--- src/core/validateGeneratedTypes.ts | 2 +- src/utils/importHandling.test.ts | 34 +++++--- src/utils/importHandling.ts | 42 ++++++++-- 6 files changed, 216 insertions(+), 30 deletions(-) diff --git a/example/heros.zod.ts b/example/heros.zod.ts index 33d65f37..0de82d80 100644 --- a/example/heros.zod.ts +++ b/example/heros.zod.ts @@ -1,6 +1,11 @@ // Generated by ts-to-zod import { z } from "zod"; -import { EnemyPower, Villain, EvilPlan, EvilPlanDetails } from "./heros"; +import { + type Villain, + type EvilPlan, + type EvilPlanDetails, + EnemyPower, +} from "./heros"; import { personSchema } from "./person.zod"; diff --git a/src/core/generate.test.ts b/src/core/generate.test.ts index ca9dd569..fee40f89 100644 --- a/src/core/generate.test.ts +++ b/src/core/generate.test.ts @@ -738,6 +738,62 @@ describe("generate", () => { }); }); + describe("named import", () => { + const sourceText = ` + import { Villain as Nemesis } from "@project/villain-module"; + + export interface Superman { + nemesis: Nemesis; + id: number + } + `; + + const { getZodSchemasFile, getIntegrationTestFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./source")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + const nemesisSchema = z.any(); + + export const supermanSchema = z.object({ + nemesis: nemesisSchema, + id: z.number() + }); + " + `); + }); + + it("should generate the integration tests", () => { + expect(getIntegrationTestFile("./hero", "hero.zod")) + .toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + import * as spec from "./hero"; + import * as generated from "hero.zod"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type supermanSchemaInferredType = z.infer; + + expectType({} as supermanSchemaInferredType) + expectType({} as spec.Superman) + " + `); + }); + + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + describe("multiple imports", () => { const sourceText = ` import { Name } from "nameModule"; @@ -1115,6 +1171,78 @@ describe("generate", () => { }); }); + describe("one named import in one statement", () => { + const input = "./hero"; + const output = "./hero.zod"; + const inputOutputMappings = [{ input, output }]; + + const sourceText = ` + import { Hero as Superman } from "${input}" + + export interface Person { + id: number + hero: Superman + } + `; + + const { getZodSchemasFile } = generate({ + sourceText, + inputOutputMappings, + }); + + it("should generate the zod schemas with right import", () => { + expect(getZodSchemasFile(input)).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + import { heroSchema as supermanSchema } from "${output}"; + + export const personSchema = z.object({ + id: z.number(), + hero: supermanSchema + }); + " + `); + }); + }); + + describe("mixed named imports in one statement", () => { + const input = "./hero"; + const output = "./hero.zod"; + const inputOutputMappings = [{ input, output }]; + + const sourceText = ` + import { Hero as Superman, Villain } from "${input}" + + export interface Person { + id: number + hero: Superman + nemesis: Villain + } + `; + + const { getZodSchemasFile } = generate({ + sourceText, + inputOutputMappings, + }); + + it("should generate the zod schemas with right import", () => { + expect(getZodSchemasFile(input)).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + import { heroSchema as supermanSchema, villainSchema } from "${output}"; + + export const personSchema = z.object({ + id: z.number(), + hero: supermanSchema, + nemesis: villainSchema + }); + " + `); + }); + }); + describe("one import in one statement, alternate getSchemaName for mapping", () => { const input = "./hero"; const output = "./hero.zod"; diff --git a/src/core/generate.ts b/src/core/generate.ts index 24838b19..c65cca1b 100644 --- a/src/core/generate.ts +++ b/src/core/generate.ts @@ -19,6 +19,8 @@ import { import { getImportIdentifiers, createImportNode, + ImportIdentifier, + getSingleImportIdentierForNode, } from "../utils/importHandling"; import { generateIntegrationTests } from "./generateIntegrationTests"; @@ -129,7 +131,7 @@ export function generate({ if (ts.isImportDeclaration(node) && node.importClause) { const identifiers = getImportIdentifiers(node); - identifiers.forEach((i) => typeNameMapping.set(i, node)); + identifiers.forEach(({ name }) => typeNameMapping.set(name, node)); // Check if we're importing from a mapped file const eligibleMapping = inputOutputMappings.find( @@ -144,20 +146,26 @@ export function generate({ const schemaMethod = eligibleMapping.getSchemaName || DEFAULT_GET_SCHEMA; - const identifiers = getImportIdentifiers(node); - identifiers.forEach((i) => - importedZodNamesAvailable.set(i, schemaMethod(i)) + identifiers.forEach(({ name }) => + importedZodNamesAvailable.set(name, schemaMethod(name)) ); const zodImportNode = createImportNode( - identifiers.map(schemaMethod), + identifiers.map(({ name, original }) => { + return { + name: schemaMethod(name), + original: original ? schemaMethod(original) : undefined, + }; + }), eligibleMapping.output ); zodImportNodes.push(zodImportNode); } // Not a Zod import, handling it as 3rd party import later on else { - identifiers.forEach((i) => externalImportNamesAvailable.add(i)); + identifiers.forEach(({ name }) => + externalImportNamesAvailable.add(name) + ); } } }; @@ -189,7 +197,7 @@ export function generate({ const importedZodSchemas = new Set(); // All original import to keep in the target - const importsToKeep = new Map(); + const importsToKeep = new Map(); /** * We browse all the extracted type references from the source file @@ -208,10 +216,15 @@ export function generate({ // If the reference is part of a qualified name, we need to import it from the same file if (typeRef.partOfQualifiedName) { const identifiers = importsToKeep.get(node); + const importIdentifier = getSingleImportIdentierForNode( + node, + typeRef.name + ); + if (!importIdentifier) return; if (identifiers) { - identifiers.push(typeRef.name); + identifiers.push(importIdentifier); } else { - importsToKeep.set(node, [typeRef.name]); + importsToKeep.set(node, [importIdentifier]); } return; } @@ -379,7 +392,7 @@ ${Array.from(zodSchemasWithMissingDependencies).join("\n")}` const zodImportToOutput = zodImportNodes.filter((node) => { const nodeIdentifiers = getImportIdentifiers(node); - return nodeIdentifiers.some((i) => importedZodSchemas.has(i)); + return nodeIdentifiers.some(({ name }) => importedZodSchemas.has(name)); }); const originalImportsToOutput = Array.from(importsToKeep.keys()).map((node) => diff --git a/src/core/validateGeneratedTypes.ts b/src/core/validateGeneratedTypes.ts index 961ad789..6e999f05 100644 --- a/src/core/validateGeneratedTypes.ts +++ b/src/core/validateGeneratedTypes.ts @@ -65,7 +65,7 @@ export function validateGeneratedTypes({ ) { if (node.importClause) { const identifiers = getImportIdentifiers(node); - identifiers.forEach((i) => importsToHandleAsAny.add(i)); + identifiers.forEach(({ name }) => importsToHandleAsAny.add(name)); } } }; diff --git a/src/utils/importHandling.test.ts b/src/utils/importHandling.test.ts index 994b9f62..899b6710 100644 --- a/src/utils/importHandling.test.ts +++ b/src/utils/importHandling.test.ts @@ -17,7 +17,7 @@ describe("getImportIdentifiers", () => { `; expect(getImportIdentifiers(getImportNode(sourceText))).toEqual([ - "MyGlobal", + { name: "MyGlobal" }, ]); }); @@ -27,7 +27,7 @@ describe("getImportIdentifiers", () => { `; expect(getImportIdentifiers(getImportNode(sourceText))).toEqual([ - "MyGlobal", + { name: "MyGlobal" }, ]); }); @@ -37,7 +37,7 @@ describe("getImportIdentifiers", () => { `; expect(getImportIdentifiers(getImportNode(sourceText))).toEqual([ - "MyGlobal", + { name: "MyGlobal" }, ]); }); @@ -47,7 +47,7 @@ describe("getImportIdentifiers", () => { `; expect(getImportIdentifiers(getImportNode(sourceText))).toEqual([ - "MyGlobal", + { name: "MyGlobal", original: "AA" }, ]); }); @@ -57,8 +57,8 @@ describe("getImportIdentifiers", () => { `; expect(getImportIdentifiers(getImportNode(sourceText))).toEqual([ - "MyGlobal", - "MyGlobal2", + { name: "MyGlobal" }, + { name: "MyGlobal2" }, ]); }); @@ -68,8 +68,8 @@ describe("getImportIdentifiers", () => { `; expect(getImportIdentifiers(getImportNode(sourceText))).toEqual([ - "MyGlobal", - "MyGlobal2", + { name: "MyGlobal" }, + { name: "MyGlobal2" }, ]); }); }); @@ -85,7 +85,7 @@ describe("createImportNode", () => { } it("should create an ImportDeclaration node correctly", () => { - const identifiers = ["Test1", "Test2"]; + const identifiers = [{ name: "Test1" }, { name: "Test2" }]; const path = "./testPath"; const expected = 'import { Test1, Test2 } from "./testPath";'; @@ -96,11 +96,25 @@ describe("createImportNode", () => { }); it("should handle empty identifiers array", () => { - const identifiers: string[] = []; const path = "./testPath"; // Yes, this is valid const expected = 'import {} from "./testPath";'; + const result = createImportNode([], path); + + expect(printNode(result)).toEqual(expected); + }); + + it("should create an ImportDeclaration with alias", () => { + const identifiers = [ + { name: "Test1", original: "T1" }, + { name: "Test2" }, + { name: "Test3", original: "T3" }, + ]; + const path = "./testPath"; + + const expected = + 'import { T1 as Test1, Test2, T3 as Test3 } from "./testPath";'; const result = createImportNode(identifiers, path); diff --git a/src/utils/importHandling.ts b/src/utils/importHandling.ts index 6a25484c..5268578c 100644 --- a/src/utils/importHandling.ts +++ b/src/utils/importHandling.ts @@ -1,19 +1,27 @@ import ts from "typescript"; const { factory: f } = ts; +export type ImportIdentifier = { + name: string; + original?: string; +}; + /** * Extracts the list of import identifiers from an import clause * @param node an ImportDeclaration node * @returns an array of all identifiers found in statement */ -export function getImportIdentifiers(node: ts.ImportDeclaration): string[] { +export function getImportIdentifiers( + node: ts.ImportDeclaration +): ImportIdentifier[] { if (!node.importClause) return []; const { importClause } = node; - const importIdentifiers: string[] = []; + const importIdentifiers: ImportIdentifier[] = []; // Case `import MyGlobal from "module";` - if (importClause.name) importIdentifiers.push(importClause.name.text); + if (importClause.name) + importIdentifiers.push({ name: importClause.name.text }); if (importClause.namedBindings) { // Cases `import { A, B } from "module"` @@ -21,28 +29,46 @@ export function getImportIdentifiers(node: ts.ImportDeclaration): string[] { if (ts.isNamedImports(importClause.namedBindings)) { for (const element of importClause.namedBindings.elements) { if (ts.isImportSpecifier(element)) { - importIdentifiers.push(element.name.text); + importIdentifiers.push({ + name: element.name.text, + original: element.propertyName?.text, + }); } } } // Case `import * as A from "module"` else if (ts.isNamespaceImport(importClause.namedBindings)) { - importIdentifiers.push(importClause.namedBindings.name.text); + importIdentifiers.push({ name: importClause.namedBindings.name.text }); } } return importIdentifiers; } +export function getSingleImportIdentierForNode( + node: ts.ImportDeclaration, + identifier: string +): ImportIdentifier | undefined { + const allIdentifiers = getImportIdentifiers(node); + return allIdentifiers.find(({ name }) => name === identifier); +} + /** * Creates an import statement from the given arguments * @param identifiers array of types to import * @param path module path * @returns an ImportDeclaration node that corresponds to `import { ...identifiers } from "path"` */ -export function createImportNode(identifiers: string[], path: string) { - const specifiers = identifiers.map((i) => - f.createImportSpecifier(false, undefined, f.createIdentifier(i)) +export function createImportNode( + identifiers: ImportIdentifier[], + path: string +) { + const specifiers = identifiers.map(({ name, original }) => + f.createImportSpecifier( + false, + original ? f.createIdentifier(original) : undefined, + f.createIdentifier(name) + ) ); return f.createImportDeclaration(