mongoose-tsgen
Version:
A Typescript interface generator for Mongoose that works out of the box.
388 lines (387 loc) • 20.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseTSConfig = exports.registerUserTs = exports.getModelTypes = void 0;
const tslib_1 = require("tslib");
const ts_morph_1 = require("ts-morph");
const glob_1 = (0, tslib_1.__importDefault)(require("glob"));
const path_1 = (0, tslib_1.__importDefault)(require("path"));
const fs = (0, tslib_1.__importStar)(require("fs"));
const strip_json_comments_1 = (0, tslib_1.__importDefault)(require("strip-json-comments"));
const resolve_1 = (0, tslib_1.__importDefault)(require("resolve"));
function getNameAndType(funcDeclaration) {
const name = funcDeclaration.getName();
const typeNode = funcDeclaration.getType();
const type = typeNode.getText(funcDeclaration);
return { name, type };
}
function findCommentsInFile(sourceFile, modelTypes, maxCommentDepth) {
var _a;
// TODO: this is reused from findTypesInFile, should abstract out instead
const schemaModelMapping = {};
Object.keys(modelTypes).forEach((modelName) => {
const { schemaVariableName } = modelTypes[modelName];
if (schemaVariableName)
schemaModelMapping[schemaVariableName] = modelName;
});
for (const statement of sourceFile.getStatements()) {
if (!ts_morph_1.Node.isVariableStatement(statement))
continue;
const varDeclarationList = statement.getChildAtIndexIfKind(0, ts_morph_1.SyntaxKind.VariableDeclarationList);
if (!varDeclarationList)
continue;
const varDeclaration = varDeclarationList.getFirstChildByKind(ts_morph_1.SyntaxKind.VariableDeclaration);
if (!varDeclaration)
continue;
const schemaName = (_a = varDeclaration.getFirstChildByKind(ts_morph_1.SyntaxKind.Identifier)) === null || _a === void 0 ? void 0 : _a.getText();
if (!schemaName)
continue;
const modelName = schemaModelMapping[schemaName];
if (!modelName) {
continue;
}
const newExpression = varDeclaration.getFirstChildByKind(ts_morph_1.SyntaxKind.NewExpression);
if (!newExpression)
continue;
const objLiteralExp = newExpression.getFirstChildByKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
if (!objLiteralExp)
continue;
const extractComments = (objLiteralExp, rootPath) => {
const propAssignments = objLiteralExp.getChildrenOfKind(ts_morph_1.SyntaxKind.PropertyAssignment);
propAssignments.forEach((propAssignment) => {
var _a;
const propName = (_a = propAssignment.getFirstChildByKind(ts_morph_1.SyntaxKind.Identifier)) === null || _a === void 0 ? void 0 : _a.getText();
if (!propName)
return;
const path = rootPath ? `${rootPath}.${propName}` : propName;
propAssignment.getLeadingCommentRanges().forEach((commentRange) => {
const commentText = commentRange.getText();
// skip comments that are not jsdocs
if (!commentText.startsWith("/**"))
return;
modelTypes[modelName].comments.push({
path,
comment: commentText
});
});
if (rootPath.split(".").length < maxCommentDepth) {
const nestedObjLiteralExp = propAssignment.getFirstChildByKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
if (nestedObjLiteralExp) {
extractComments(nestedObjLiteralExp, path);
}
}
});
};
extractComments(objLiteralExp, "");
}
// TODO: get virtual comments
return modelTypes;
}
function findTypesInFile(sourceFile, modelTypes) {
var _a, _b, _c, _d, _e, _f, _g;
const schemaModelMapping = {};
Object.keys(modelTypes).forEach((modelName) => {
const { schemaVariableName } = modelTypes[modelName];
if (schemaVariableName)
schemaModelMapping[schemaVariableName] = modelName;
});
for (const statement of sourceFile.getStatements()) {
if (!ts_morph_1.Node.isExpressionStatement(statement))
continue;
const binaryExpr = statement.getChildAtIndexIfKind(0, ts_morph_1.SyntaxKind.BinaryExpression);
const callExpr = statement.getChildAtIndexIfKind(0, ts_morph_1.SyntaxKind.CallExpression);
if (binaryExpr) {
// left is a propertyaccessexpression, children are [identifier, dottoken, identifier]
const left = binaryExpr.getLeft();
const right = binaryExpr.getRight();
if (left.getKind() !== ts_morph_1.SyntaxKind.PropertyAccessExpression)
continue;
if (right.getKind() !== ts_morph_1.SyntaxKind.AsExpression &&
right.getKind() !== ts_morph_1.SyntaxKind.ObjectLiteralExpression &&
right.getKind() !== ts_morph_1.SyntaxKind.TypeAssertionExpression)
continue;
const leftChildren = left.getChildren();
let modelName;
const hasSchemaIdentifier = leftChildren.some((child) => {
if (child.getKind() !== ts_morph_1.SyntaxKind.Identifier)
return false;
modelName = schemaModelMapping[child.getText()];
if (!modelName)
return false;
return true;
});
const hasDotToken = leftChildren.some((child) => child.getKind() === ts_morph_1.SyntaxKind.DotToken);
if (!hasSchemaIdentifier || !hasDotToken)
continue;
const hasMethodsIdentifier = leftChildren.some((child) => child.getKind() === ts_morph_1.SyntaxKind.Identifier && child.getText() === "methods");
const hasStaticsIdentifier = leftChildren.some((child) => child.getKind() === ts_morph_1.SyntaxKind.Identifier && child.getText() === "statics");
const hasQueryIdentifier = leftChildren.some((child) => child.getKind() === ts_morph_1.SyntaxKind.Identifier && child.getText() === "query");
let rightFuncDeclarations = [];
if (right.getKind() === ts_morph_1.SyntaxKind.ObjectLiteralExpression) {
rightFuncDeclarations = right.getChildrenOfKind(ts_morph_1.SyntaxKind.MethodDeclaration);
}
else if (right.getKind() === ts_morph_1.SyntaxKind.AsExpression) {
const objLiteralExp = right.getFirstChildByKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
if (objLiteralExp)
rightFuncDeclarations = objLiteralExp.getChildrenOfKind(ts_morph_1.SyntaxKind.MethodDeclaration);
}
else if (right.getKind() === ts_morph_1.SyntaxKind.TypeAssertionExpression) {
const objLiteralExp = right.getFirstChildByKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
if (objLiteralExp) {
rightFuncDeclarations = objLiteralExp.getChildrenOfKind(ts_morph_1.SyntaxKind.MethodDeclaration);
}
}
else {
rightFuncDeclarations = right.getChildrenOfKind(ts_morph_1.SyntaxKind.MethodDeclaration);
}
if (hasMethodsIdentifier) {
rightFuncDeclarations.forEach((declaration) => {
const { name, type } = getNameAndType(declaration);
modelTypes[modelName].methods[name] = type;
});
}
else if (hasStaticsIdentifier) {
rightFuncDeclarations.forEach((declaration) => {
const { name, type } = getNameAndType(declaration);
modelTypes[modelName].statics[name] = type;
});
}
else if (hasQueryIdentifier) {
rightFuncDeclarations.forEach((declaration) => {
const { name, type } = getNameAndType(declaration);
modelTypes[modelName].query[name] = type;
});
}
}
else if (callExpr) {
// virtual property
let propAccessExpr = callExpr.getFirstChildByKind(ts_morph_1.SyntaxKind.PropertyAccessExpression);
if ((propAccessExpr === null || propAccessExpr === void 0 ? void 0 : propAccessExpr.getName()) === "set") {
propAccessExpr = (_a = propAccessExpr
.getFirstChildByKind(ts_morph_1.SyntaxKind.CallExpression)) === null || _a === void 0 ? void 0 : _a.getFirstChildByKind(ts_morph_1.SyntaxKind.PropertyAccessExpression);
}
if ((propAccessExpr === null || propAccessExpr === void 0 ? void 0 : propAccessExpr.getName()) !== "get")
continue;
const schemaVariableName = (_d = (_c = (_b = propAccessExpr
.getFirstChildByKind(ts_morph_1.SyntaxKind.CallExpression)) === null || _b === void 0 ? void 0 : _b.getFirstChildByKind(ts_morph_1.SyntaxKind.PropertyAccessExpression)) === null || _c === void 0 ? void 0 : _c.getFirstChildByKind(ts_morph_1.SyntaxKind.Identifier)) === null || _d === void 0 ? void 0 : _d.getText();
if (schemaVariableName) {
if (process.env.DEBUG)
console.log("tsreader: Found virtual on schema: " + schemaVariableName);
}
else
continue;
const modelName = schemaModelMapping[schemaVariableName];
if (!modelName) {
if (process.env.DEBUG)
console.warn("tsreader: Associated model name not found for schema: " + schemaVariableName);
continue;
}
const funcExpr = (_e = propAccessExpr === null || propAccessExpr === void 0 ? void 0 : propAccessExpr.getParent()) === null || _e === void 0 ? void 0 : _e.getFirstChildByKind(ts_morph_1.SyntaxKind.FunctionExpression);
const type = (_f = funcExpr === null || funcExpr === void 0 ? void 0 : funcExpr.getType()) === null || _f === void 0 ? void 0 : _f.getText(funcExpr);
const callExpr2 = propAccessExpr.getFirstChildByKind(ts_morph_1.SyntaxKind.CallExpression);
const stringLiteral = callExpr2 === null || callExpr2 === void 0 ? void 0 : callExpr2.getArguments()[0];
const propAccessExpr2 = callExpr2 === null || callExpr2 === void 0 ? void 0 : callExpr2.getFirstChildByKind(ts_morph_1.SyntaxKind.PropertyAccessExpression);
if ((propAccessExpr2 === null || propAccessExpr2 === void 0 ? void 0 : propAccessExpr2.getName()) !== "virtual") {
if (process.env.DEBUG) {
console.warn("tsreader: virtual found on schema does not have virtual initializer");
}
continue;
}
const virtualName = stringLiteral === null || stringLiteral === void 0 ? void 0 : stringLiteral.getText();
let returnType = (_g = type === null || type === void 0 ? void 0 : type.split("=> ")) === null || _g === void 0 ? void 0 : _g[1];
if (!returnType || !virtualName) {
if (process.env.DEBUG)
console.warn("tsreader: virtualName or returnType not found: ", {
virtualName,
returnType
});
continue;
}
/**
* @experimental trying this out since certain virtual types are indeterminable and get set to void, which creates incorrect TS errors
* This should be a fine workaround because virtual properties shouldn't return solely `void`, they return real values.
*/
if (returnType === "void") {
if (process.env.DEBUG) {
console.warn("tsreader: return type found as void, this usually means we couldn't determine the type");
}
returnType = "any";
}
const virtualNameSanitized = virtualName.slice(1, virtualName.length - 1);
modelTypes[modelName].virtuals[virtualNameSanitized] = returnType;
}
}
return modelTypes;
}
const parseModelInitializer = (d, isModelNamedImport) => {
const callExpr = d.getFirstChildByKind(ts_morph_1.SyntaxKind.CallExpression);
if (!callExpr)
return undefined;
const callExprStr = callExpr.getText().replace(/[\r\n\t ]/g, "");
// if model is a named import, we can match this without `mongoose.` prefix
const pattern = isModelNamedImport
? /model(?:<.+?>)?\(["'`](\w+)["'`],(\w+),?\)/
: /mongoose\.model(?:<.+?>)?\(["'`](\w+)["'`],(\w+),?\)/;
const modelInitMatch = callExprStr.match(pattern);
if (!modelInitMatch) {
if (process.env.DEBUG) {
console.warn(`tsreader: Could not find model name in Mongoose model initialization: ${callExprStr}`);
}
return undefined;
}
const [, modelName, schemaVariableName] = modelInitMatch;
return { modelName, schemaVariableName };
};
function initModelTypes(sourceFile, filePath) {
if (process.env.DEBUG)
console.log("tsreader: Searching file for Mongoose schemas: " + filePath);
const modelTypes = {};
const mongooseImport = sourceFile.getImportDeclaration("mongoose");
let isModelNamedImport = false;
mongooseImport === null || mongooseImport === void 0 ? void 0 : mongooseImport.getNamedImports().forEach((importSpecifier) => {
if (importSpecifier.getText() === "model")
isModelNamedImport = true;
});
sourceFile.getVariableDeclarations().forEach((d) => {
var _a;
const { modelName, schemaVariableName } = (_a = parseModelInitializer(d, isModelNamedImport)) !== null && _a !== void 0 ? _a : {};
if (!modelName || !schemaVariableName)
return;
const modelVariableName = d.getName();
modelTypes[modelName] = {
schemaVariableName,
modelVariableName,
filePath,
methods: {},
statics: {},
query: {},
virtuals: {},
comments: []
};
});
const defaultExportAssignment = sourceFile.getExportAssignment((d) => !d.isExportEquals());
if (defaultExportAssignment) {
const defaultModelInit = parseModelInitializer(defaultExportAssignment, isModelNamedImport);
if (defaultModelInit) {
modelTypes[defaultModelInit.modelName] = {
schemaVariableName: defaultModelInit.schemaVariableName,
filePath,
methods: {},
statics: {},
query: {},
virtuals: {},
comments: []
};
}
}
if (process.env.DEBUG) {
const schemaNames = Object.keys(modelTypes);
if (schemaNames.length === 0)
console.warn(`tsreader: No schema found in file. If a schema exists & is exported, it will still be typed but will use generic types for methods, statics, queries & virtuals`);
else
console.log("tsreader: Schemas found: " + schemaNames);
}
return modelTypes;
}
const getModelTypes = (modelsPaths, maxCommentDepth = 2) => {
const project = new ts_morph_1.Project({});
project.addSourceFilesAtPaths(modelsPaths);
let allModelTypes = {};
// TODO: ideally we only parse the files that we know have methods, statics, or virtuals.
// Would save a lot of time
modelsPaths.forEach((modelPath) => {
const sourceFile = project.getSourceFileOrThrow(modelPath);
let modelTypes = initModelTypes(sourceFile, modelPath);
modelTypes = findTypesInFile(sourceFile, modelTypes);
modelTypes = findCommentsInFile(sourceFile, modelTypes, maxCommentDepth);
allModelTypes = {
...allModelTypes,
...modelTypes
};
});
return allModelTypes;
};
exports.getModelTypes = getModelTypes;
const registerUserTs = (basePath) => {
var _a, _b, _c;
const pathToSearch = basePath.endsWith(".json")
? basePath
: path_1.default.join(basePath, "**/tsconfig.json");
const files = glob_1.default.sync(pathToSearch, { ignore: "**/node_modules/**" });
if (files.length === 0)
throw new Error(`No tsconfig.json file found at path "${basePath}"`);
else if (files.length > 1)
throw new Error(`Multiple tsconfig.json files found. Please specify a more specific --project value.\nPaths found: ${files}`);
const foundPath = path_1.default.join(process.cwd(), files[0]);
if (process.env.DEBUG) {
console.log("tsreader: Registering tsconfig.json with ts-node at path: " + foundPath);
}
require("ts-node").register({
transpileOnly: true,
project: foundPath,
experimentalResolver: true,
compilerOptions: {
module: "commonjs",
moduleResolution: "node"
}
// The solution below would be more ideal, but it breaks for examples like https://github.com/francescov1/mongoose-tsgen/issues/134#issuecomment-2006954270.
// The module resolution is getting really weird with all the various module types and import strategies. We probably need to rethink how we handle this.
// https://github.com/TypeStrong/ts-node/issues/922#issuecomment-913361913
// "ts-node": {
// // These options are overrides used only by ts-node
// compilerOptions: {
// module: "commonjs"
// }
// }
});
// handle path aliases
try {
if (process.env.DEBUG) {
console.log(`tsreader: Parsing tsconfig.json at path '${foundPath}' to search for 'paths' field`);
}
const tsConfig = parseTSConfig(foundPath);
if ((_a = tsConfig === null || tsConfig === void 0 ? void 0 : tsConfig.compilerOptions) === null || _a === void 0 ? void 0 : _a.paths) {
const baseUrl = path_1.default.join(process.cwd(), (_c = (_b = tsConfig === null || tsConfig === void 0 ? void 0 : tsConfig.compilerOptions) === null || _b === void 0 ? void 0 : _b.baseUrl) !== null && _c !== void 0 ? _c : "");
if (process.env.DEBUG) {
console.log(`tsreader: Found 'paths' field in tsconfig.json, registering project with tsconfig-paths using baseUrl '${baseUrl}'`);
}
const cleanup = require("tsconfig-paths").register({
baseUrl,
paths: tsConfig.compilerOptions.paths
});
if (process.env.DEBUG) {
console.log("tsreader: tsconfig-paths registered");
}
return cleanup;
}
return null;
}
catch (err) {
throw new Error(`Error parsing your tsconfig.json file: ${err.message}`);
}
};
exports.registerUserTs = registerUserTs;
function parseTSConfig(tsconfigFilePath) {
const tsConfigString = fs.readFileSync(tsconfigFilePath, "utf8");
const tsConfig = JSON.parse((0, strip_json_comments_1.default)(tsConfigString));
// Handle the case where the tsconfig.json file has a "extends" property
if (tsConfig.extends) {
// Resolve the path to the extended tsconfig.json file
const extendedPath = resolve_1.default.sync(tsConfig.extends, {
basedir: path_1.default.dirname(tsconfigFilePath)
});
// Read and merge paths from the extended tsconfig.json recursively
const extendedConfig = parseTSConfig(extendedPath);
// Merge paths from extendedConfig into tsConfig
tsConfig.compilerOptions.paths = {
...extendedConfig.compilerOptions.paths,
...tsConfig.compilerOptions.paths
};
// We only want to set the base URL if its not already set, since the child tsconfig should always overwrite extended tsconfigs.
// So the first child we find with a base URL be the final base URL
if (extendedConfig.compilerOptions.baseUrl && !tsConfig.compilerOptions.baseUrl) {
tsConfig.compilerOptions.baseUrl = extendedConfig.compilerOptions.baseUrl;
}
}
return tsConfig;
}
exports.parseTSConfig = parseTSConfig;