diff --git a/changelog.md b/changelog.md index c2a1ccb..316926b 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,7 @@ ## Unreleased - Add support for `@save` in `api.ts` ([#89](https://github.com/hasura/ndc-open-api-lambda/pull/89)) +- Add support for preserving imports from stale files ([#90](https://github.com/hasura/ndc-open-api-lambda/pull/90)) ## [[1.5.2](https://github.com/hasura/ndc-open-api-lambda/releases/tag/v1.5.2)] 2025-03-25 diff --git a/docs/documentation.md b/docs/documentation.md index 516772a..f87045c 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -76,6 +76,8 @@ When re-introspecting the connector, user changes in `functions.ts` or `api.ts` This will ensure that the statements marked with `@save` are not overwritten and the saved statements will be added if missing in the newly generated `functions.ts` or the `apt.ts` file +> **NOTE:** `import` statements are always preserved and orgranized for both files, they don't need the `@save` annotation + Example ```javascript diff --git a/src/app/parser/typescript/cleanup.ts b/src/app/parser/typescript/cleanup.ts index 90b6158..55ef1b6 100644 --- a/src/app/parser/typescript/cleanup.ts +++ b/src/app/parser/typescript/cleanup.ts @@ -19,16 +19,12 @@ export function fixImports(generatedCodeList: types.GeneratedCode[]) { project.createSourceFile( generatedCode.filePath, generatedCode.fileContent, - { - overwrite: true, - }, + { overwrite: true }, ); } try { for (const sourceFile of project.getSourceFiles()) { - sourceFile.fixMissingImports().organizeImports(); - // find the source file in the generated code file list let curSourceFile = generatedCodeList.filter( @@ -41,12 +37,18 @@ export function fixImports(generatedCodeList: types.GeneratedCode[]) { path.basename(sourceFile.getFilePath()), )[0]; - if (curSourceFile) { + if (curSourceFile) { // if the current file is either functions.ts or api.ts, fix imports and organize them + sourceFile.fixMissingImports().organizeImports(); curSourceFile!.fileContent = sourceFile.getFullText(); } else { - logger.error( - `Error while fixing imports: Unable to find the source file for ${sourceFile.getFilePath()}\n\nSkipping import fixing and cleanup for this file`, - ); + if ( + path.basename(sourceFile.getFilePath()) === "functions.ts" || + path.basename(sourceFile.getFilePath()) === "api.ts" + ) { // we only want to show this error if we're unable to find functions.ts or api.ts + logger.error( + `Error while fixing imports: Unable to find the source file for ${sourceFile.getFilePath()}\n\nSkipping import fixing and cleanup for this file`, + ); + } } } } catch (error) { diff --git a/src/app/parser/typescript/index.ts b/src/app/parser/typescript/index.ts index 36dcb94..9475329 100644 --- a/src/app/parser/typescript/index.ts +++ b/src/app/parser/typescript/index.ts @@ -26,5 +26,8 @@ export function preserveUserChanges( morph.preserveSavedVariables(staleSourceFile, freshSourceFile); morph.preserveSavedFunctions(staleSourceFile, freshSourceFile); + // imports don't need @save annotation, they are always preserved and organized + morph.preserveImportDeclarations(staleSourceFile, freshSourceFile); + return freshSourceFile.getFullText(); } diff --git a/src/app/parser/typescript/morph.test.ts b/src/app/parser/typescript/morph.test.ts index b050847..32d35e6 100644 --- a/src/app/parser/typescript/morph.test.ts +++ b/src/app/parser/typescript/morph.test.ts @@ -137,6 +137,40 @@ describe("morph::user-defined-types", async () => { } }); +const importDeclarationsTests: TestCase[] = [ + { + name: "Preserve Imports", + directory: "./test-data/morph-tests/imports/", + } +]; + +describe("morph::import-declarations", async () => { + for (const testCase of importDeclarationsTests) { + before(function () { + setupTest(testCase); + }); + + it(testCase.name, async () => { + morph.preserveImportDeclarations( + testCase.staleSourceFile!, + testCase.freshSourceFile!, + ); + + const gotStr = await prettier.format( + testCase.freshSourceFile?.getFullText()!, + { + parser: "typescript", + }, + ); + + assert.equal(testCase.mergedFileContents, gotStr); + + // uncomment to update merged golden file + // fs.writeFileSync(path.resolve(testCase.directory, "merged.ts"), gotStr); + }); + } +}); + function setupTest(testCase: TestCase) { testCase.directory = path.resolve(__dirname, testCase.directory); const staleProject = new ts.Project(); diff --git a/src/app/parser/typescript/morph.ts b/src/app/parser/typescript/morph.ts index af7dd32..9ed7f15 100644 --- a/src/app/parser/typescript/morph.ts +++ b/src/app/parser/typescript/morph.ts @@ -65,6 +65,29 @@ export function preserveSavedClasses( preserveNode(staleSourceClasses, freshSourceClasses, freshTsSourceFile); } +export function preserveImportDeclarations( + staleTsSourceFile: tsMorph.SourceFile, + freshTsSourceFile: tsMorph.SourceFile, +) { + const staleSourceImports = walk.getAllImportDeclarationsMap(staleTsSourceFile); + const freshSourceImports = walk.getAllImportDeclarationsMap(freshTsSourceFile); + + staleSourceImports.forEach((staleNode, staleNodeName) => { + const freshNode = freshSourceImports.get(staleNodeName); + + if (freshNode) { + // this import statement already exists in the fresh source file + // replace it with the stale import statement + freshNode.replaceWithText(staleNode.getText()); + } else { + // this import statement does not exist in the fresh source file + // add it to the fresh source + freshTsSourceFile.insertStatements(0, staleNode.getText()); + } + }) +} + + /** * this function preservers nodes that are marked with `@save` annotation in the stale source file * if a saved node is missing in the fresh source file, it will be copied to it diff --git a/src/app/parser/typescript/test-data/morph-tests/imports/a.ts b/src/app/parser/typescript/test-data/morph-tests/imports/a.ts new file mode 100644 index 0000000..c593983 --- /dev/null +++ b/src/app/parser/typescript/test-data/morph-tests/imports/a.ts @@ -0,0 +1,3 @@ +export function helloWorld() { + return "hello world"; +} \ No newline at end of file diff --git a/src/app/parser/typescript/test-data/morph-tests/imports/fresh.ts b/src/app/parser/typescript/test-data/morph-tests/imports/fresh.ts new file mode 100644 index 0000000..f5bf997 --- /dev/null +++ b/src/app/parser/typescript/test-data/morph-tests/imports/fresh.ts @@ -0,0 +1,5 @@ +import * as a from "./a"; +import * as tsMorph from "ts-morph"; +import { exit } from "process"; +import { SemVer } from "semver"; + diff --git a/src/app/parser/typescript/test-data/morph-tests/imports/merged.ts b/src/app/parser/typescript/test-data/morph-tests/imports/merged.ts new file mode 100644 index 0000000..3d99d9d --- /dev/null +++ b/src/app/parser/typescript/test-data/morph-tests/imports/merged.ts @@ -0,0 +1,13 @@ +import * as fs from "fs"; +import * as path from "path"; +import { helloWorld } from "./a"; +import { + SourceFile, + SourceFileEmitOptions, + NamespaceImport, + NamedImports, + NamedExports, + ModuleDeclaration, +} from "ts-morph"; +import * as process from "process"; +import { SemVer } from "semver"; diff --git a/src/app/parser/typescript/test-data/morph-tests/imports/stale.ts b/src/app/parser/typescript/test-data/morph-tests/imports/stale.ts new file mode 100644 index 0000000..f9e6c70 --- /dev/null +++ b/src/app/parser/typescript/test-data/morph-tests/imports/stale.ts @@ -0,0 +1,14 @@ +import { helloWorld } from "./a"; +import { + SourceFile, + SourceFileEmitOptions, + NamespaceImport, + NamedImports, + NamedExports, + ModuleDeclaration, +} from "ts-morph"; +import * as process from "process"; +import * as path from "path"; +import * as fs from "fs"; + +export function hello() {} diff --git a/src/app/parser/typescript/test-data/walk-tests/imports/got b/src/app/parser/typescript/test-data/walk-tests/imports/got new file mode 100644 index 0000000..c33da06 --- /dev/null +++ b/src/app/parser/typescript/test-data/walk-tests/imports/got @@ -0,0 +1,8 @@ +import * as a from "./another-file"; +import * as b from "./another-dir/another-file"; +import * as tsMorph from "ts-morph"; +import { exit } from "process"; +import { SemVer } from "semver"; +import * as path from "path"; +import * as fs from "fs"; +import * as ts from "typescript" diff --git a/src/app/parser/typescript/test-data/walk-tests/imports/want.json b/src/app/parser/typescript/test-data/walk-tests/imports/want.json new file mode 100644 index 0000000..87e2a20 --- /dev/null +++ b/src/app/parser/typescript/test-data/walk-tests/imports/want.json @@ -0,0 +1,10 @@ +{ + "\"./another-file\"": "import * as a from \"./another-file\";", + "\"./another-dir/another-file\"": "import * as b from \"./another-dir/another-file\";", + "\"ts-morph\"": "import * as tsMorph from \"ts-morph\";", + "\"process\"": "import { exit } from \"process\";", + "\"semver\"": "import { SemVer } from \"semver\";", + "\"path\"": "import * as path from \"path\";", + "\"fs\"": "import * as fs from \"fs\";", + "\"typescript\"": "import * as ts from \"typescript\"" +} \ No newline at end of file diff --git a/src/app/parser/typescript/walk.test.ts b/src/app/parser/typescript/walk.test.ts index 6523137..7f95dc5 100644 --- a/src/app/parser/typescript/walk.test.ts +++ b/src/app/parser/typescript/walk.test.ts @@ -136,6 +136,36 @@ describe("walk::user-defined-types-tests", async () => { } }); +const importDeclarationsTests: TestCase[] = [{ + name: "getImportDeclarationTests", + testFile: "./test-data/walk-tests/imports/got", + goldenFile: "./test-data/walk-tests/imports/want.json", +}]; + +describe("walk::import-declarations-tests", async () => { + for (const testCase of importDeclarationsTests) { + before(function () { + setupTestCase(testCase); + }); + + it(`${testCase.name}`, function () { + const project = new ts.Project(); + const tsSourceFile = project.addSourceFileAtPath(testCase.testFile); + + const importsMap = tsWalk.getAllImportDeclarationsMap(tsSourceFile); + const gotImports: Record = {}; + + importsMap.forEach((importDeclaration, importName) => { + gotImports[importName] = importDeclaration.getText(); + }); + + assert.deepEqual(testCase.goldenFileContents, gotImports); + + // writeFileSync(testCase.goldenFile, JSON.stringify(gotImports, null, 2)); + }); + } +}); + function setupTestCase(testCase: TestCase) { testCase.testFile = path.resolve(__dirname, testCase.testFile); testCase.goldenFile = path.resolve(__dirname, testCase.goldenFile); diff --git a/src/app/parser/typescript/walk.ts b/src/app/parser/typescript/walk.ts index 7f9c2ef..ad7f24a 100644 --- a/src/app/parser/typescript/walk.ts +++ b/src/app/parser/typescript/walk.ts @@ -236,3 +236,47 @@ export function getClassDeclarations(tsSourceFile: ts.SourceFile): ts.ClassDecla }); return allClassDeclarations; } + +export function getAllImportDecalarations(tsSourceFile: ts.SourceFile): ts.ImportDeclaration[] { + const allImportDeclarations: ts.ImportDeclaration[] = []; + if (!tsSourceFile) { + return allImportDeclarations; + } + tsSourceFile.forEachChild((node) => { + if (ts.Node.isImportDeclaration(node)) { + allImportDeclarations.push(node as ts.ImportDeclaration); + } + }); + + return allImportDeclarations; +} + +/** + * get all import declarations as a map of imported library to ts.ImportDeclaration + */ +export function getAllImportDeclarationsMap(tsSourceFile: ts.SourceFile): Map { + const allImportDeclarations = getAllImportDecalarations(tsSourceFile); + const allImportDeclarationsMap: Map = new Map(); + allImportDeclarations.forEach((importDeclaration) => { + const importLibrary = getLibraryFromImportDeclaration(importDeclaration)?.getText(); + if (!importLibrary) { + logger.error("unable to get import library from import declaration: ", importDeclaration.getText()); + return; + } + allImportDeclarationsMap.set(importLibrary, importDeclaration); + }); + return allImportDeclarationsMap; +} + +function getLibraryFromImportDeclaration(importDeclaration: ts.ImportDeclaration): ts.StringLiteral | undefined { + for (let i = importDeclaration.getChildCount() - 1; i >= 0; i--) { + // import declarations are of the format: import * as tsMorph from "ts-morph"; + // the library name is "ts-morph", which is a string literal + // the library name is typically the last string literal in the import declaration + const child = importDeclaration.getChildAtIndexIfKind(i, ts.SyntaxKind.StringLiteral); + if (child) { + return child + } + } + return undefined; +} diff --git a/src/app/writer/index.ts b/src/app/writer/index.ts index 3bd8d96..64301fa 100644 --- a/src/app/writer/index.ts +++ b/src/app/writer/index.ts @@ -16,10 +16,10 @@ export async function writeToFileSystem(codeToWrite: types.GeneratedCode[]) { (element) => element.fileType === "functions-ts", )[0]!; - await apiWriter.writeToFileSystem(apiTsCode); - await functionsWriter.writeToFileSystem(functionsTsCode, apiTsCode); await packageJsonWriter.writeToFileSystem(); tsConfigWriter.writeToFileSystem(); + await apiWriter.writeToFileSystem(apiTsCode); + await functionsWriter.writeToFileSystem(functionsTsCode, apiTsCode); logger.info("running npm install :: installing dependencies"); if (process.env.HASURA_PLUGIN_CONNECTOR_CONTEXT_PATH) {