From 3a694dd3e99699e7284709c53967a5dfcb1e1806 Mon Sep 17 00:00:00 2001 From: Gilad S Date: Mon, 31 Jan 2022 11:25:58 +0200 Subject: [PATCH] feat: ESM support (#8536) * feat: support importing TypeORM in esm projects Closes: #6974 Closes: #6941 * bugfix: generate index.mjs directly out of commonjs exports The new implementation generates ESM exports directly out of the commonjs exports, and provides a default export to maintain compatability with existing `import`s of the commonjs implementation * feat: support loading ESM entity and connection config files When TypeORM tries to load an entity file or a connection config file, it will determine what is the appropriate module system to use for the file and then `import` or `require` it as it sees fit. Closes: #7516 Closes: #7159 * fix: adapt ImportUtils.importOrRequireFile tests to older version of nodejs * fix: improved importOrRequireFile implementation * feat: add solution to circular dependency issue in ESM projects * docs: added FAQ regarding ESM projects * chore: add `"type": "commonjs"` to package.json * style * docs: improve `ts-node` usage examples for CLI commands in ESM projects * feat: add support for generating an ESM base project * refactor: renamed `Related` type to `Relation` * docs: added a section in the Getting Started guide regarding the `Relation` wrapper type in ESM projects * docs: improved documentation of the `Relation` type * docs: improved documentation of the `Relation` type * docs: added ESM support to the list of TypeORM features --- README.md | 38 +++++ docs/faq.md | 29 ++++ docs/migrations.md | 5 + docs/using-cli.md | 18 +- gulpfile.ts | 20 +++ package.json | 10 ++ src/commands/InitCommand.ts | 122 +++++++++----- src/common/RelationType.ts | 14 ++ src/connection/Connection.ts | 10 +- src/connection/ConnectionMetadataBuilder.ts | 12 +- src/connection/ConnectionOptionsReader.ts | 13 +- src/index.ts | 1 + src/util/DirectoryExportedClassesLoader.ts | 12 +- src/util/ImportUtils.ts | 73 ++++++++ .../basic/entity-metadata-validator.ts | 6 +- .../validator-intialized-relations.ts | 18 +- .../persistence-order/persistence-order.ts | 4 +- .../circular-eager-relations.ts | 4 +- test/functional/util/ImportUtils.ts | 159 ++++++++++++++++++ test/github-issues/6284/issue-6284.ts | 4 +- 20 files changed, 489 insertions(+), 83 deletions(-) create mode 100644 src/common/RelationType.ts create mode 100644 src/util/ImportUtils.ts create mode 100644 test/functional/util/ImportUtils.ts diff --git a/README.md b/README.md index 7d61ddf09a..cedddb2ffe 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ TypeORM is highly influenced by other ORMs, such as [Hibernate](http://hibernate * Supports MongoDB NoSQL database. * Works in NodeJS / Browser / Ionic / Cordova / React Native / NativeScript / Expo / Electron platforms. * TypeScript and JavaScript support. +* ESM and CommonJS support. * Produced code is performant, flexible, clean and maintainable. * Follows all possible best practices. * CLI. @@ -321,6 +322,9 @@ That's it, your application should successfully run and insert a new user into t You can continue to work with this project and integrate other modules you need and start creating more entities. +> You can generate an ESM project by running +`typeorm init --name MyProject --database postgres --module esm` command. + > You can generate an even more advanced project with express installed by running `typeorm init --name MyProject --database mysql --express` command. @@ -950,6 +954,40 @@ Note that we should use the `@JoinColumn` decorator only on one side of a relati Whichever side you put this decorator on will be the owning side of the relationship. The owning side of a relationship contains a column with a foreign key in the database. +### Relations in ESM projects + +If you use ESM in your TypeScript project, you should use the `Relation` wrapper type in relation properties to avoid circular dependency issues. +Let's modify our entities: + +```typescript +import { Entity, Column, PrimaryGeneratedColumn, OneToOne, JoinColumn, Relation } from "typeorm"; +import { Photo } from "./Photo"; + +@Entity() +export class PhotoMetadata { + + /* ... other columns */ + + @OneToOne(type => Photo, photo => photo.metadata) + @JoinColumn() + photo: Relation; +} +``` + +```typescript +import { Entity, Column, PrimaryGeneratedColumn, OneToOne, Relation } from "typeorm"; +import { PhotoMetadata } from "./PhotoMetadata"; + +@Entity() +export class Photo { + + /* ... other columns */ + + @OneToOne(type => PhotoMetadata, photoMetadata => photoMetadata.photo) + metadata: Relation; +} +``` + ### Loading objects with their relations Now let's load our photo and its photo metadata in a single query. diff --git a/docs/faq.md b/docs/faq.md index 3900e73ac2..7be8eb11ab 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -9,6 +9,7 @@ * [How to handle outDir TypeScript compiler option?](#how-to-handle-outdir-typescript-compiler-option) * [How to use TypeORM with ts-node?](#how-to-use-typeorm-with-ts-node) * [How to use Webpack for the backend](#how-to-use-webpack-for-the-backend) +* [How to use TypeORM in ESM projects?](#how-to-use-typeorm-in-esm-projects) ## How do I update a database schema? @@ -182,6 +183,12 @@ Also, if you want to use the ts-node CLI, you can execute TypeORM the following ts-node ./node_modules/.bin/typeorm schema:sync ``` +For ESM projects use this instead: + +``` +node --loader ts-node/esm ./node_modules/.bin/typeorm schema:sync +``` + ## How to use Webpack for the backend? Webpack produces warnings due to what it views as missing require statements -- require statements for all drivers supported by TypeORM. To suppress these warnings for unused drivers, you will need to edit your webpack config file. @@ -277,3 +284,25 @@ module.exports = { ], }; ``` + +## How to use TypeORM in ESM projects? + +Make sure to add `"type": "module"` in the `package.json` of your project so TypeORM will know to use `import( ... )` on files. + +To avoid circular dependency import issues use the `Related` wrapper type for relation type definitions in entities: + +```typescript +@Entity() +export class User { + + @OneToOne(() => Profile, profile => profile.user) + profile: Relation; + +} +``` + +Doing this prevents the type of the property from being saved in the transpiled code in the property metadata, preventing circular dependency issues. + +Since the type of the column is already defined using the `@OneToOne` decorator, there's no use of the additional type metadata saved by TypeScript. + +> Important: Do not use `Related` on non-relation column types diff --git a/docs/migrations.md b/docs/migrations.md index 0e42d47335..6d41393ae7 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -151,6 +151,11 @@ Example with `ts-node`: ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run ``` +Example with `ts-node` in ESM projects: +``` +node --loader ts-node/esm ./node_modules/typeorm/cli.js migration:run +``` + Example `ts-node` not using `node_modules` directly: ``` ts-node $(yarn bin typeorm) migration:run diff --git a/docs/using-cli.md b/docs/using-cli.md index 87729d7a7e..20db9c6f34 100644 --- a/docs/using-cli.md +++ b/docs/using-cli.md @@ -28,9 +28,9 @@ This CLI tool is written in javascript and to be run on node. If your entity fil You may setup ts-node in your project to ease the operation as follows: -Install ts-node globally: +Install ts-node: ``` -npm install -g ts-node +npm install ts-node --save-dev ``` Add typeorm command under scripts section in package.json @@ -41,6 +41,14 @@ Add typeorm command under scripts section in package.json } ``` +For ESM projects add this instead: +``` +"scripts": { + ... + "typeorm": "node --loader ts-node/esm ./node_modules/typeorm/cli.js" +} +``` + If you want to load more modules like [module-alias](https://github.com/ilearnio/module-alias) you can add more `--require my-module-supporting-register` Then you may run the command like this: @@ -91,6 +99,12 @@ To specify a specific database you use you can use `--database`: typeorm init --database mssql ``` +To generate an ESM base project you can use `--module esm`: + +``` +typeorm init --name my-project --module esm +``` + You can also generate a base project with Express: ``` diff --git a/gulpfile.ts b/gulpfile.ts index 7299cd2ad3..113ac1a0c8 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -4,6 +4,7 @@ import {Gulpclass, Task, SequenceTask, MergedTask} from "gulpclass"; +const fs = require("fs"); const gulp = require("gulp"); const del = require("del"); const shell = require("gulp-shell"); @@ -170,6 +171,24 @@ export class Gulpfile { .pipe(gulp.dest("./build/package")); } + /** + * Create ESM index file in the final package directory. + */ + @Task() + async packageCreateEsmIndex() { + const buildDir = "./build/package"; + const cjsIndex = require(`${buildDir}/index.js`); + const cjsKeys = Object.keys(cjsIndex).filter(key => key !== "default" && !key.startsWith("__")); + + const indexMjsContent = + 'import TypeORM from "./index.js";\n' + + `const {\n ${cjsKeys.join(",\n ")}\n} = TypeORM;\n` + + `export {\n ${cjsKeys.join(",\n ")}\n};\n` + + 'export default TypeORM;\n'; + + fs.writeFileSync(`${buildDir}/index.mjs`, indexMjsContent, "utf8"); + } + /** * Removes /// ((ok, fail) => { - exec(command, (error: any, stdout: any, stderr: any) => { + exec(command, {cwd}, (error: any, stdout: any, stderr: any) => { if (stdout) return ok(stdout); if (stderr) return fail(stderr); if (error) return fail(error); @@ -215,20 +222,36 @@ export class InitCommand implements yargs.CommandModule { /** * Gets contents of the ormconfig file. */ - protected static getTsConfigTemplate(): string { - return JSON.stringify({ - compilerOptions: { - lib: ["es5", "es6"], - target: "es5", - module: "commonjs", - moduleResolution: "node", - outDir: "./build", - emitDecoratorMetadata: true, - experimentalDecorators: true, - sourceMap: true + protected static getTsConfigTemplate(esmModule: boolean): string { + if (esmModule) + return JSON.stringify({ + compilerOptions: { + lib: ["es2021"], + target: "es2021", + module: "es2022", + moduleResolution: "node", + allowSyntheticDefaultImports: true, + outDir: "./build", + emitDecoratorMetadata: true, + experimentalDecorators: true, + sourceMap: true + } } - } - , undefined, 3); + , undefined, 3); + else + return JSON.stringify({ + compilerOptions: { + lib: ["es5", "es6"], + target: "es5", + module: "commonjs", + moduleResolution: "node", + outDir: "./build", + emitDecoratorMetadata: true, + experimentalDecorators: true, + sourceMap: true + } + } + , undefined, 3); } /** @@ -271,8 +294,8 @@ export class User { /** * Gets contents of the route file (used when express is enabled). */ - protected static getRoutesTemplate(): string { - return `import {UserController} from "./controller/UserController"; + protected static getRoutesTemplate(isEsm: boolean): string { + return `import {UserController} from "./controller/UserController${isEsm ? ".js" : ""}"; export const Routes = [{ method: "get", @@ -300,10 +323,10 @@ export const Routes = [{ /** * Gets contents of the user controller file (used when express is enabled). */ - protected static getControllerTemplate(): string { + protected static getControllerTemplate(isEsm: boolean): string { return `import {getRepository} from "typeorm"; import {NextFunction, Request, Response} from "express"; -import {User} from "../entity/User"; +import {User} from "../entity/User${isEsm ? ".js" : ""}"; export class UserController { @@ -332,15 +355,15 @@ export class UserController { /** * Gets contents of the main (index) application file. */ - protected static getAppIndexTemplate(express: boolean): string { + protected static getAppIndexTemplate(express: boolean, isEsm: boolean): string { if (express) { return `import "reflect-metadata"; import {createConnection} from "typeorm"; -import * as express from "express"; -import * as bodyParser from "body-parser"; +import ${!isEsm ? "* as " : ""}express from "express"; +import ${!isEsm ? "* as " : ""}bodyParser from "body-parser"; import {Request, Response} from "express"; -import {Routes} from "./routes"; -import {User} from "./entity/User"; +import {Routes} from "./routes${isEsm ? ".js" : ""}"; +import {User} from "./entity/User${isEsm ? ".js" : ""}"; createConnection().then(async connection => { @@ -387,7 +410,7 @@ createConnection().then(async connection => { } else { return `import "reflect-metadata"; import {createConnection} from "typeorm"; -import {User} from "./entity/User"; +import {User} from "./entity/User${isEsm ? ".js" : ""}"; createConnection().then(async connection => { @@ -413,11 +436,12 @@ createConnection().then(async connection => { /** * Gets contents of the new package.json file. */ - protected static getPackageJsonTemplate(projectName?: string): string { + protected static getPackageJsonTemplate(projectName?: string, projectIsEsm?: boolean): string { return JSON.stringify({ name: projectName || "new-typeorm-project", version: "0.0.1", description: "Awesome project developed with TypeORM.", + type: projectIsEsm ? "module" : "commonjs", devDependencies: { }, dependencies: { @@ -551,20 +575,20 @@ Steps to run this project: /** * Appends to a given package.json template everything needed. */ - protected static appendPackageJson(packageJsonContents: string, database: string, express: boolean /*, docker: boolean*/): string { + protected static appendPackageJson(packageJsonContents: string, database: string, express: boolean, projectIsEsm: boolean /*, docker: boolean*/): string { const packageJson = JSON.parse(packageJsonContents); if (!packageJson.devDependencies) packageJson.devDependencies = {}; Object.assign(packageJson.devDependencies, { - "ts-node": "3.3.0", - "@types/node": "^8.0.29", - "typescript": "3.3.3333" + "ts-node": "10.4.0", + "@types/node": "^16.11.10", + "typescript": "4.5.2" }); if (!packageJson.dependencies) packageJson.dependencies = {}; Object.assign(packageJson.dependencies, { "typeorm": require("../package.json").version, - "reflect-metadata": "^0.1.10" + "reflect-metadata": "^0.1.13" }); switch (database) { @@ -594,15 +618,23 @@ Steps to run this project: } if (express) { - packageJson.dependencies["express"] = "^4.15.4"; - packageJson.dependencies["body-parser"] = "^1.18.1"; + packageJson.dependencies["express"] = "^4.17.2"; + packageJson.dependencies["body-parser"] = "^1.19.1"; } if (!packageJson.scripts) packageJson.scripts = {}; - Object.assign(packageJson.scripts, { - start: /*(docker ? "docker-compose up && " : "") + */"ts-node src/index.ts", - typeorm: "node --require ts-node/register ./node_modules/typeorm/cli.js" - }); + + if (projectIsEsm) + Object.assign(packageJson.scripts, { + start: /*(docker ? "docker-compose up && " : "") + */"node --loader ts-node/esm src/index.ts", + typeorm: "node --loader ts-node/esm ./node_modules/typeorm/cli.js" + }); + else + Object.assign(packageJson.scripts, { + start: /*(docker ? "docker-compose up && " : "") + */"ts-node src/index.ts", + typeorm: "node --require ts-node/register ./node_modules/typeorm/cli.js" + }); + return JSON.stringify(packageJson, undefined, 3); } diff --git a/src/common/RelationType.ts b/src/common/RelationType.ts new file mode 100644 index 0000000000..5845e9c132 --- /dev/null +++ b/src/common/RelationType.ts @@ -0,0 +1,14 @@ +/** + * Wrapper type for relation type definitions in entities. + * Used to circumvent ESM modules circular dependency issue caused by reflection metadata saving the type of the property. + * + * Usage example: + * @Entity() + * export default class User { + * + * @OneToOne(() => Profile, profile => profile.user) + * profile: Relation; + * + * } + */ +export type Relation = T; diff --git a/src/connection/Connection.ts b/src/connection/Connection.ts index a518058205..2791456aaf 100644 --- a/src/connection/Connection.ts +++ b/src/connection/Connection.ts @@ -183,7 +183,7 @@ export class Connection { try { // build all metadatas registered in the current connection - this.buildMetadatas(); + await this.buildMetadatas(); await this.driver.afterConnect(); @@ -497,21 +497,21 @@ export class Connection { /** * Builds metadatas for all registered classes inside this connection. */ - protected buildMetadatas(): void { + protected async buildMetadatas(): Promise { const connectionMetadataBuilder = new ConnectionMetadataBuilder(this); const entityMetadataValidator = new EntityMetadataValidator(); // create subscribers instances if they are not disallowed from high-level (for example they can disallowed from migrations run process) - const subscribers = connectionMetadataBuilder.buildSubscribers(this.options.subscribers || []); + const subscribers = await connectionMetadataBuilder.buildSubscribers(this.options.subscribers || []); ObjectUtils.assign(this, { subscribers: subscribers }); // build entity metadatas - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas(this.options.entities || []); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas(this.options.entities || []); ObjectUtils.assign(this, { entityMetadatas: entityMetadatas }); // create migration instances - const migrations = connectionMetadataBuilder.buildMigrations(this.options.migrations || []); + const migrations = await connectionMetadataBuilder.buildMigrations(this.options.migrations || []); ObjectUtils.assign(this, { migrations: migrations }); // validate all created entity metadatas to make sure user created entities are valid and correct diff --git a/src/connection/ConnectionMetadataBuilder.ts b/src/connection/ConnectionMetadataBuilder.ts index 9e01518644..776efe3628 100644 --- a/src/connection/ConnectionMetadataBuilder.ts +++ b/src/connection/ConnectionMetadataBuilder.ts @@ -29,18 +29,18 @@ export class ConnectionMetadataBuilder { /** * Builds migration instances for the given classes or directories. */ - buildMigrations(migrations: (Function|string)[]): MigrationInterface[] { + async buildMigrations(migrations: (Function|string)[]): Promise { const [migrationClasses, migrationDirectories] = OrmUtils.splitClassesAndStrings(migrations); - const allMigrationClasses = [...migrationClasses, ...importClassesFromDirectories(this.connection.logger, migrationDirectories)]; + const allMigrationClasses = [...migrationClasses, ...(await importClassesFromDirectories(this.connection.logger, migrationDirectories))]; return allMigrationClasses.map(migrationClass => getFromContainer(migrationClass)); } /** * Builds subscriber instances for the given classes or directories. */ - buildSubscribers(subscribers: (Function|string)[]): EntitySubscriberInterface[] { + async buildSubscribers(subscribers: (Function|string)[]): Promise[]> { const [subscriberClasses, subscriberDirectories] = OrmUtils.splitClassesAndStrings(subscribers || []); - const allSubscriberClasses = [...subscriberClasses, ...importClassesFromDirectories(this.connection.logger, subscriberDirectories)]; + const allSubscriberClasses = [...subscriberClasses, ...(await importClassesFromDirectories(this.connection.logger, subscriberDirectories))]; return getMetadataArgsStorage() .filterSubscribers(allSubscriberClasses) .map(metadata => getFromContainer>(metadata.target)); @@ -49,14 +49,14 @@ export class ConnectionMetadataBuilder { /** * Builds entity metadatas for the given classes or directories. */ - buildEntityMetadatas(entities: (Function|EntitySchema|string)[]): EntityMetadata[] { + async buildEntityMetadatas(entities: (Function|EntitySchema|string)[]): Promise { // todo: instead we need to merge multiple metadata args storages const [entityClassesOrSchemas, entityDirectories] = OrmUtils.splitClassesAndStrings(entities || []); const entityClasses: Function[] = entityClassesOrSchemas.filter(entityClass => (entityClass instanceof EntitySchema) === false) as any; const entitySchemas: EntitySchema[] = entityClassesOrSchemas.filter(entityClass => entityClass instanceof EntitySchema) as any; - const allEntityClasses = [...entityClasses, ...importClassesFromDirectories(this.connection.logger, entityDirectories)]; + const allEntityClasses = [...entityClasses, ...(await importClassesFromDirectories(this.connection.logger, entityDirectories))]; allEntityClasses.forEach(entityClass => { // if we have entity schemas loaded from directories if (entityClass instanceof EntitySchema) { entitySchemas.push(entityClass); diff --git a/src/connection/ConnectionOptionsReader.ts b/src/connection/ConnectionOptionsReader.ts index bab18e106c..bd975641bd 100644 --- a/src/connection/ConnectionOptionsReader.ts +++ b/src/connection/ConnectionOptionsReader.ts @@ -6,6 +6,7 @@ import {ConnectionOptionsEnvReader} from "./options-reader/ConnectionOptionsEnvR import {ConnectionOptionsYmlReader} from "./options-reader/ConnectionOptionsYmlReader"; import {ConnectionOptionsXmlReader} from "./options-reader/ConnectionOptionsXmlReader"; import { TypeORMError } from "../error"; +import {importOrRequireFile} from "../util/ImportUtils"; /** * Reads connection options from the ormconfig. @@ -83,7 +84,7 @@ export class ConnectionOptionsReader { protected async load(): Promise { let connectionOptions: ConnectionOptions|ConnectionOptions[]|undefined = undefined; - const fileFormats = ["env", "js", "cjs", "ts", "json", "yml", "yaml", "xml"]; + const fileFormats = ["env", "js", "mjs", "cjs", "ts", "mts", "cts", "json", "yml", "yaml", "xml"]; // Detect if baseFilePath contains file extension const possibleExtension = this.baseFilePath.substr(this.baseFilePath.lastIndexOf(".")); @@ -108,10 +109,14 @@ export class ConnectionOptionsReader { if (PlatformTools.getEnvVariable("TYPEORM_CONNECTION") || PlatformTools.getEnvVariable("TYPEORM_URL")) { connectionOptions = await new ConnectionOptionsEnvReader().read(); - } else if (foundFileFormat === "js" || foundFileFormat === "cjs" || foundFileFormat === "ts") { - const configModule = await require(configFile); + } else if ( + foundFileFormat === "js" || foundFileFormat === "mjs" || foundFileFormat === "cjs" || + foundFileFormat === "ts" || foundFileFormat === "mts" || foundFileFormat === "cts" + ) { + const [importOrRequireResult, moduleSystem] = await importOrRequireFile(configFile); + const configModule = await importOrRequireResult; - if (configModule && "__esModule" in configModule && "default" in configModule) { + if (moduleSystem === "esm" || (configModule && "__esModule" in configModule && "default" in configModule)) { connectionOptions = configModule.default; } else { connectionOptions = configModule; diff --git a/src/index.ts b/src/index.ts index 1711ef02d7..65476dd44e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from "./common/EntityTarget"; export * from "./common/ObjectType"; export * from "./common/ObjectLiteral"; export * from "./common/DeepPartial"; +export * from "./common/RelationType"; export * from "./error"; export * from "./decorator/columns/Column"; export * from "./decorator/columns/CreateDateColumn"; diff --git a/src/util/DirectoryExportedClassesLoader.ts b/src/util/DirectoryExportedClassesLoader.ts index a7cc84bda9..7dc919e245 100644 --- a/src/util/DirectoryExportedClassesLoader.ts +++ b/src/util/DirectoryExportedClassesLoader.ts @@ -2,10 +2,11 @@ import glob from "glob"; import {PlatformTools} from "../platform/PlatformTools"; import {EntitySchema} from "../entity-schema/EntitySchema"; import {Logger} from "../logger/Logger"; +import {importOrRequireFile} from "./ImportUtils"; /** * Loads all exported classes from the given directory. */ -export function importClassesFromDirectories(logger: Logger, directories: string[], formats = [".js", ".cjs", ".ts"]): Function[] { +export async function importClassesFromDirectories(logger: Logger, directories: string[], formats = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]): Promise { const logLevel = "info"; const classesNotFoundMessage = "No classes were found using the provided glob pattern: "; @@ -33,12 +34,17 @@ export function importClassesFromDirectories(logger: Logger, directories: string } else if (allFiles.length > 0) { logger.log(logLevel, `${classesFoundMessage} "${directories}" : "${allFiles}"`); } - const dirs = allFiles + const dirPromises = allFiles .filter(file => { const dtsExtension = file.substring(file.length - 5, file.length); return formats.indexOf(PlatformTools.pathExtname(file)) !== -1 && dtsExtension !== ".d.ts"; }) - .map(file => require(PlatformTools.pathResolve(file))); + .map(async file => { + const [importOrRequireResult] = await importOrRequireFile(PlatformTools.pathResolve(file)); + return importOrRequireResult; + }); + + const dirs = await Promise.all(dirPromises); return loadFileClasses(dirs, []); } diff --git a/src/util/ImportUtils.ts b/src/util/ImportUtils.ts new file mode 100644 index 0000000000..c194d72c4d --- /dev/null +++ b/src/util/ImportUtils.ts @@ -0,0 +1,73 @@ +import fs from "fs"; +import path from "path"; + +export async function importOrRequireFile(filePath: string): Promise<[result: any, moduleType: "esm" | "commonjs"]> { + const tryToImport = async (): Promise<[any, "esm"]> => { + // `Function` is required to make sure the `import` statement wil stay `import` after + // transpilation and won't be converted to `require` + return [await Function("return filePath => import(filePath)")()(filePath), "esm"]; + }; + const tryToRequire = async (): Promise<[any, "commonjs"]> => { + return [require(filePath), "commonjs"]; + }; + + const extension = filePath.substring(filePath.lastIndexOf(".") + ".".length); + + if (extension === "mjs" || extension === "mts") + return tryToImport(); + else if (extension === "cjs" || extension === "cts") + return tryToRequire(); + else if (extension === "js" || extension === "ts") { + const packageJson = await getNearestPackageJson(filePath); + + if (packageJson != null) { + const isModule = (packageJson as any)?.type === "module"; + + if (isModule) + return tryToImport(); + else + return tryToRequire(); + } else + return tryToRequire(); + } + + return tryToRequire(); +} + +function getNearestPackageJson(filePath: string): Promise { + return new Promise((accept) => { + let currentPath = filePath; + + function searchPackageJson() { + const nextPath = path.dirname(currentPath); + + if (currentPath === nextPath) // the top of the file tree is reached + accept(null); + else { + currentPath = nextPath; + const potentialPackageJson = path.join(currentPath, "package.json"); + + fs.stat(potentialPackageJson, (err, stats) => { + if (err != null) + searchPackageJson(); + else if (stats.isFile()) { + fs.readFile(potentialPackageJson, "utf8", (err, data) => { + if (err != null) + accept(null); + else { + try { + accept(JSON.parse(data)); + } catch (err) { + accept(null); + } + } + }); + } else + searchPackageJson(); + }); + } + } + + searchPackageJson(); + }); +} diff --git a/test/functional/entity-metadata-validator/basic/entity-metadata-validator.ts b/test/functional/entity-metadata-validator/basic/entity-metadata-validator.ts index c2319b295b..b2c4ba0ceb 100644 --- a/test/functional/entity-metadata-validator/basic/entity-metadata-validator.ts +++ b/test/functional/entity-metadata-validator/basic/entity-metadata-validator.ts @@ -6,7 +6,7 @@ import {expect} from "chai"; describe("entity-metadata-validator", () => { - it("should throw error if relation count decorator used with ManyToOne or OneToOne relations", () => { + it("should throw error if relation count decorator used with ManyToOne or OneToOne relations", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -16,9 +16,9 @@ describe("entity-metadata-validator", () => { entities: [__dirname + "/entity/*{.js,.ts}"] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([__dirname + "/entity/*{.js,.ts}"]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([__dirname + "/entity/*{.js,.ts}"]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).to.throw(Error); }); -}); \ No newline at end of file +}); diff --git a/test/functional/entity-metadata-validator/initialized-relations/validator-intialized-relations.ts b/test/functional/entity-metadata-validator/initialized-relations/validator-intialized-relations.ts index 0a862da9ec..97e0692c76 100644 --- a/test/functional/entity-metadata-validator/initialized-relations/validator-intialized-relations.ts +++ b/test/functional/entity-metadata-validator/initialized-relations/validator-intialized-relations.ts @@ -12,7 +12,7 @@ import {Question} from "./entity/Question"; describe("entity-metadata-validator > initialized relations", () => { - it("should throw error if relation with initialized array was found on many-to-many relation", () => { + it("should throw error if relation with initialized array was found on many-to-many relation", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -22,12 +22,12 @@ describe("entity-metadata-validator > initialized relations", () => { entities: [Post, Category] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([Post, Category]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([Post, Category]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).to.throw(InitializedRelationError); }); - it("should throw error if relation with initialized array was found on one-to-many relation", () => { + it("should throw error if relation with initialized array was found on one-to-many relation", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -37,12 +37,12 @@ describe("entity-metadata-validator > initialized relations", () => { entities: [Image, ImageInfo] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([Image, ImageInfo]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([Image, ImageInfo]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).to.throw(InitializedRelationError); }); - it("should not throw error if relation with initialized array was not found", () => { + it("should not throw error if relation with initialized array was not found", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -52,12 +52,12 @@ describe("entity-metadata-validator > initialized relations", () => { entities: [Category] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([Category]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([Category]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).not.to.throw(InitializedRelationError); }); - it("should not throw error if relation with initialized array was found, but persistence for this relation was disabled", () => { + it("should not throw error if relation with initialized array was found, but persistence for this relation was disabled", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -67,9 +67,9 @@ describe("entity-metadata-validator > initialized relations", () => { entities: [Question, Category] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([Question, Category]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([Question, Category]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).not.to.throw(InitializedRelationError); }); -}); \ No newline at end of file +}); diff --git a/test/functional/persistence/persistence-order/persistence-order.ts b/test/functional/persistence/persistence-order/persistence-order.ts index 7fb27e42c0..4074dba734 100644 --- a/test/functional/persistence/persistence-order/persistence-order.ts +++ b/test/functional/persistence/persistence-order/persistence-order.ts @@ -11,7 +11,7 @@ describe("persistence > order of persistence execution operations", () => { describe("should throw exception when non-resolvable circular relations found", function() { - it("should throw CircularRelationsError", () => { + it("should throw CircularRelationsError", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -21,7 +21,7 @@ describe("persistence > order of persistence execution operations", () => { entities: [__dirname + "/entity/*{.js,.ts}"] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([__dirname + "/entity/*{.js,.ts}"]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([__dirname + "/entity/*{.js,.ts}"]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).to.throw(Error); }); diff --git a/test/functional/relations/eager-relations/circular-eager-relations/circular-eager-relations.ts b/test/functional/relations/eager-relations/circular-eager-relations/circular-eager-relations.ts index fd77638eab..2915f5f288 100644 --- a/test/functional/relations/eager-relations/circular-eager-relations/circular-eager-relations.ts +++ b/test/functional/relations/eager-relations/circular-eager-relations/circular-eager-relations.ts @@ -6,7 +6,7 @@ import {expect} from "chai"; describe("relations > eager relations > circular eager relations", () => { - it("should throw error if eager: true is set on both sides of relationship", () => { + it("should throw error if eager: true is set on both sides of relationship", async () => { const connection = new Connection({ // dummy connection options, connection won't be established anyway type: "mysql", host: "localhost", @@ -16,7 +16,7 @@ describe("relations > eager relations > circular eager relations", () => { entities: [__dirname + "/entity/*{.js,.ts}"] }); const connectionMetadataBuilder = new ConnectionMetadataBuilder(connection); - const entityMetadatas = connectionMetadataBuilder.buildEntityMetadatas([__dirname + "/entity/*{.js,.ts}"]); + const entityMetadatas = await connectionMetadataBuilder.buildEntityMetadatas([__dirname + "/entity/*{.js,.ts}"]); const entityMetadataValidator = new EntityMetadataValidator(); expect(() => entityMetadataValidator.validateMany(entityMetadatas, connection.driver)).to.throw(Error); }); diff --git a/test/functional/util/ImportUtils.ts b/test/functional/util/ImportUtils.ts new file mode 100644 index 0000000000..63a2a5b5ab --- /dev/null +++ b/test/functional/util/ImportUtils.ts @@ -0,0 +1,159 @@ +import { expect } from "chai"; +import fs from "fs"; +import path from "path"; +import {importOrRequireFile} from "../../../src/util/ImportUtils"; + +describe("ImportUtils.importOrRequireFile", () => { + const rmdirSync = (dir: string) => { + if (fs.rmSync != null) + fs.rmSync(dir, {recursive: true}); + else + fs.rmdirSync(dir, {recursive: true}); + }; + + it("should import .js file as ESM", async () => { + const testDir = path.join(__dirname, "testJsEsm"); + const srcDir = path.join(testDir, "src"); + + const packageJsonPath = path.join(testDir, "package.json"); + const packageJsonContent = {"type": "module"}; + + const jsFilePath = path.join(srcDir, "file.js"); + const jsFileContent =` + import path from "path"; + export default function test() {} + export const number = 6; + `; + + if (fs.existsSync(testDir)) + rmdirSync(testDir); + + fs.mkdirSync(srcDir, {recursive: true}); + + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJsonContent), "utf8"); + fs.writeFileSync(jsFilePath, jsFileContent, "utf8"); + + const [exports, moduleType] = await importOrRequireFile(jsFilePath); + + expect(exports).to.not.be.eq(null); + expect(moduleType).to.be.eq("esm"); + expect(exports.default).to.be.a("function"); + expect(exports.number).to.be.eq(6); + + rmdirSync(testDir); + }); + + it("should import .js file as CommonJS", async () => { + const testDir = path.join(__dirname, "testJsCommonJs"); + const srcDir = path.join(testDir, "src"); + + const packageJsonPath = path.join(testDir, "package.json"); + const packageJsonContent = {}; + + const jsFilePath = path.join(srcDir, "file.js"); + const jsFileContent =` + const path = require("path"); + module.exports = { + test() {}, + number: 6 + }; + `; + + if (fs.existsSync(testDir)) + rmdirSync(testDir); + + fs.mkdirSync(srcDir, {recursive: true}); + + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJsonContent), "utf8"); + fs.writeFileSync(jsFilePath, jsFileContent, "utf8"); + + const [exports, moduleType] = await importOrRequireFile(jsFilePath); + + expect(exports).to.not.be.eq(null); + expect(moduleType).to.be.eq("commonjs"); + expect(exports.test).to.be.a("function"); + expect(exports.number).to.be.eq(6); + + rmdirSync(testDir); + }); + + it("should import .mjs file as ESM", async () => { + const testDir = path.join(__dirname, "testMjsEsm"); + const srcDir = path.join(testDir, "src"); + + const jsFilePath = path.join(srcDir, "file.mjs"); + const jsFileContent =` + import path from "path"; + export default function test() {} + export const number = 6; + `; + + if (fs.existsSync(testDir)) + rmdirSync(testDir); + + fs.mkdirSync(srcDir, {recursive: true}); + + fs.writeFileSync(jsFilePath, jsFileContent, "utf8"); + + const [exports, moduleType] = await importOrRequireFile(jsFilePath); + + expect(exports).to.not.be.eq(null); + expect(moduleType).to.be.eq("esm"); + expect(exports.default).to.be.a("function"); + expect(exports.number).to.be.eq(6); + + rmdirSync(testDir); + }); + + it("should import .cjs file as CommonJS", async () => { + const testDir = path.join(__dirname, "testCjsCommonJs"); + const srcDir = path.join(testDir, "src"); + + const jsFilePath = path.join(srcDir, "file.cjs"); + const jsFileContent =` + const path = require("path"); + module.exports = { + test() {}, + number: 6 + }; + `; + + if (fs.existsSync(testDir)) + rmdirSync(testDir); + + fs.mkdirSync(srcDir, {recursive: true}); + + fs.writeFileSync(jsFilePath, jsFileContent, "utf8"); + + const [exports, moduleType] = await importOrRequireFile(jsFilePath); + + expect(exports).to.not.be.eq(null); + expect(moduleType).to.be.eq("commonjs"); + expect(exports.test).to.be.a("function"); + expect(exports.number).to.be.eq(6); + + rmdirSync(testDir); + }); + + it("should import .json file as CommonJS", async () => { + const testDir = path.join(__dirname, "testJsonCommonJS"); + + const jsonFilePath = path.join(testDir, "file.json"); + const jsonFileContent = {test: 6}; + + if (fs.existsSync(testDir)) + rmdirSync(testDir); + + fs.mkdirSync(testDir, {recursive: true}); + + fs.writeFileSync(jsonFilePath, JSON.stringify(jsonFileContent), "utf8"); + + const [exports, moduleType] = await importOrRequireFile(jsonFilePath); + + expect(exports).to.not.be.eq(null); + expect(moduleType).to.be.eq("commonjs"); + expect(exports.test).to.be.eq(6); + + rmdirSync(testDir); + }); +}); diff --git a/test/github-issues/6284/issue-6284.ts b/test/github-issues/6284/issue-6284.ts index 85231df509..e519a93e66 100644 --- a/test/github-issues/6284/issue-6284.ts +++ b/test/github-issues/6284/issue-6284.ts @@ -22,12 +22,12 @@ describe("github issues > #6284 cli support for cjs extension", () => { unlinkSync(cjsConfigPath); }); - it("loads cjs files via DirectoryExportedClassesloader", () => { + it("loads cjs files via DirectoryExportedClassesloader", async () => { const klassPath = [__dirname, "klass.cjs"].join("/"); const klass = `module.exports.Widget = class Widget {};`; writeFileSync(klassPath, klass); - const classes = importClassesFromDirectories(new LoggerFactory().create(), [`${__dirname}/*.cjs`]); + const classes = await importClassesFromDirectories(new LoggerFactory().create(), [`${__dirname}/*.cjs`]); expect(classes).to.be.an("Array"); expect(classes.length).to.eq(1);