UNPKG

mongoose-tsgen

Version:

A Typescript interface generator for Mongoose that works out of the box.

438 lines (432 loc) 20.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getTypeFromKeyValue = exports.loadModels = exports.BASE_TYPES = exports.getShouldLeanIncludeVirtuals = exports.convertBaseTypeToTs = exports.isMapType = exports.getSubdocName = void 0; const tslib_1 = require("tslib"); const mongoose_1 = (0, tslib_1.__importDefault)(require("mongoose")); const lodash_1 = (0, tslib_1.__importDefault)(require("lodash")); const pluralize_1 = (0, tslib_1.__importDefault)(require("pluralize")); const stringBuilder_1 = require("../writer/stringBuilder"); const getSubdocName = (path, modelName = "") => { let subDocName = modelName + path .split(".") .map((p) => p[0].toUpperCase() + p.slice(1)) .join(""); subDocName = pluralize_1.default.singular(subDocName); // // If a user names a field "model", it will conflict with the model name, so we need to rename it. // // https://github.com/francescov1/mongoose-tsgen/issues/128 if (subDocName === `${modelName}Model`) { // NOTE: This wasnt behavior for usage from getTypeFromKeyValue, but it should probably be here anyways. // If causes issues, add a param to control it subDocName += "Field"; } return subDocName; }; exports.getSubdocName = getSubdocName; const isMapType = (val) => { return val === Map || val === mongoose_1.default.Schema.Types.Map; }; exports.isMapType = isMapType; const convertBaseTypeToTs = ({ key, val, isDocument, noMongoose, datesAsStrings }) => { var _a, _b, _c; // NOTE: ideally we check actual type of value to ensure its Schema.Types.Mixed (the same way we do with Schema.Types.ObjectId), // but this doesnt seem to work for some reason // {} is treated as Mixed if (val.schemaName === "Mixed" || ((_a = val.type) === null || _a === void 0 ? void 0 : _a.schemaName) === "Mixed" || (val.constructor === Object && lodash_1.default.isEmpty(val)) || (((_b = val.type) === null || _b === void 0 ? void 0 : _b.constructor) === Object && lodash_1.default.isEmpty(val.type))) { return "any"; } const isMap = (0, exports.isMapType)(val.type); const mongooseType = isMap ? val.of : val.type; // If the user specifies a map with no type, we set to any if (isMap && !mongooseType) { return "any"; } switch (mongooseType) { case mongoose_1.default.Schema.Types.String: case String: case "String": // NOTE: This handles the `enum` field being both an array of values and being a TS enum (so that we can support this feature: https://github.com/Automattic/mongoose/issues/9546) if (val.enum && ((_c = Object.values(val.enum)) === null || _c === void 0 ? void 0 : _c.length) > 0) { // User passed a typescript enum to the enum property of the String field config. const enumValues = Object.values(val.enum); const includesNull = enumValues.includes(null); const enumValuesWithoutNull = enumValues.filter((str) => str !== null); let enumTypscriptType = `"` + enumValuesWithoutNull.join(`" | "`) + `"`; if (includesNull) enumTypscriptType += ` | null`; return enumTypscriptType; } return "string"; case mongoose_1.default.Schema.Types.Number: case Number: case "Number": return key === "__v" ? undefined : "number"; case mongoose_1.default.Schema.Types.Decimal128: case mongoose_1.default.Types.Decimal128: return isDocument ? "mongoose.Types.Decimal128" : "number"; case mongoose_1.default.Schema.Types.Boolean: case Boolean: case "Boolean": return "boolean"; case mongoose_1.default.Schema.Types.Date: case Date: case "Date": return datesAsStrings ? "string" : "Date"; case mongoose_1.default.Types.Buffer: case mongoose_1.default.Schema.Types.Buffer: case Buffer: case "Buffer": return isDocument ? "mongoose.Types.Buffer" : "Buffer"; case mongoose_1.default.Schema.Types.ObjectId: case mongoose_1.default.Types.ObjectId: case "ObjectId": // _id fields have type set to the string "ObjectId" return noMongoose ? "string" : "mongoose.Types.ObjectId"; case Object: return "any"; default: if (lodash_1.default.isPlainObject(val)) { // This indicates to the parent func that this type is nested and we need to traverse one level deeper return "{}"; } console.warn(`parser: Unknown type detected for field "${key}", using type "any". Please create an issue in the mongoose-tsgen GitHub repo to have this case handled.`); return "any"; } }; exports.convertBaseTypeToTs = convertBaseTypeToTs; const getShouldLeanIncludeVirtuals = (schema) => { var _a, _b; // Check the toObject options to determine if virtual property should be included. // See https://mongoosejs.com/docs/api.html#document_Document-toObject for toObject option documentation. const toObjectOptions = (_b = (_a = schema.options) === null || _a === void 0 ? void 0 : _a.toObject) !== null && _b !== void 0 ? _b : {}; if ((!toObjectOptions.virtuals && !toObjectOptions.getters) || (toObjectOptions.virtuals === false && toObjectOptions.getters === true)) return false; return true; }; exports.getShouldLeanIncludeVirtuals = getShouldLeanIncludeVirtuals; exports.BASE_TYPES = new Set([ Object, String, "String", Number, "Number", Boolean, "Boolean", Date, "Date", Buffer, "Buffer", Map, mongoose_1.default.Schema.Types.String, mongoose_1.default.Schema.Types.Number, mongoose_1.default.Schema.Types.Boolean, mongoose_1.default.Schema.Types.Date, mongoose_1.default.Schema.Types.Map, mongoose_1.default.Types.Buffer, mongoose_1.default.Schema.Types.Buffer, mongoose_1.default.Schema.Types.ObjectId, mongoose_1.default.Types.ObjectId, mongoose_1.default.Types.Decimal128, mongoose_1.default.Schema.Types.Decimal128 ]); const loadModels = (modelsPaths) => { // We use a dict with model names as keys to ensure uniqueness. If the user exports the same model twice, we only want to register it once. const nameToModelMap = {}; // TODO: Type guard const checkAndRegisterModel = (obj) => { if (!(obj === null || obj === void 0 ? void 0 : obj.modelName) || !(obj === null || obj === void 0 ? void 0 : obj.schema)) return false; nameToModelMap[obj.modelName] = obj; return true; }; modelsPaths.forEach((singleModelPath) => { var _a; let exportedData; try { if (process.env.DEBUG) { console.log("parser: Attempting to import model from path: " + singleModelPath); } exportedData = require(singleModelPath); } catch (err) { const error = ((_a = err.message) === null || _a === void 0 ? void 0 : _a.includes(`Cannot find module '${singleModelPath}'`)) ? new Error(`Could not find a module at path ${singleModelPath}.`) : err; throw error; } const prevSchemaCount = Object.keys(nameToModelMap).length; // NOTE: This was used to find the most likely names of the model based on the filename, and only check those properties for mongoose models. Now, we check all properties, but this could be used as a "strict" option down the road. // we check each file's export object for property names that would commonly export the schema. // Here is the priority (using the filename as a starting point to determine model name): // default export, model name (ie `User`), model name lowercase (ie `user`), collection name (ie `users`), collection name uppercased (ie `Users`). // If none of those exist, we assume the export object is set to the schema directly /* // if exported data has a default export, use that if (checkAndRegisterModel(exportedData.default) || checkAndRegisterModel(exportedData)) return; // if no default export, look for a property matching file name const { name: filenameRoot } = path.parse(singleModelPath); // capitalize first char const modelName = filenameRoot.charAt(0).toUpperCase() + filenameRoot.slice(1); const collectionNameUppercased = modelName + "s"; let modelNameLowercase = filenameRoot.endsWith("s") ? filenameRoot.slice(0, -1) : filenameRoot; modelNameLowercase = modelNameLowercase.toLowerCase(); const collectionName = modelNameLowercase + "s"; // check likely names that schema would be exported from if ( checkAndRegisterModel(exportedData[modelName]) || checkAndRegisterModel(exportedData[modelNameLowercase]) || checkAndRegisterModel(exportedData[collectionName]) || checkAndRegisterModel(exportedData[collectionNameUppercased]) ) return; */ // check if exported object is a model checkAndRegisterModel(exportedData); // iterate through each exported property, check if val is a schema and add to schemas if so for (const obj of Object.values(exportedData)) { checkAndRegisterModel(obj); } const schemaCount = Object.keys(nameToModelMap).length - prevSchemaCount; if (schemaCount === 0) { console.warn(`A module was found at ${singleModelPath}, but no new exported models were found. If this file contains a Mongoose schema, ensure it is exported and its name does not conflict with others.`); } }); return Object.values(nameToModelMap); }; exports.loadModels = loadModels; // TODO: This is one of the most complex functions, and should be refactored. // TODO: May want to splti up the string building with the type extraction const getTypeFromKeyValue = ({ key, val: valOriginal, isDocument, shouldLeanIncludeVirtuals, noMongoose, datesAsStrings }) => { var _a, _b, _c, _d; // if the value is an object, we need to deepClone it to ensure changes to `val` aren't persisted in parent function let val = lodash_1.default.isPlainObject(valOriginal) ? lodash_1.default.cloneDeep(valOriginal) : valOriginal; let valueType; const requiredValue = Array.isArray(val.required) ? val.required[0] : val.required; let isOptional = requiredValue !== true; let isArray = Array.isArray(val); let isUntypedArray = false; let isMapOfArray = false; /** * If _isDefaultSetToUndefined is set, it means this is a subdoc array with `default: undefined`, indicating that mongoose will not automatically * assign an empty array to the value. Therefore, isOptional = true. In other cases, isOptional is false since the field will be automatically initialized * with an empty array */ const isArrayOuterDefaultSetToUndefined = Boolean(val._isDefaultSetToUndefined); // this means its a subdoc if (isArray) { val = val[0]; if (val === undefined && (val === null || val === void 0 ? void 0 : val.type) === undefined) { isUntypedArray = true; isOptional = isArrayOuterDefaultSetToUndefined !== null && isArrayOuterDefaultSetToUndefined !== void 0 ? isArrayOuterDefaultSetToUndefined : false; } else { isOptional = (_a = val._isDefaultSetToUndefined) !== null && _a !== void 0 ? _a : false; } // Array optionality is a bit overcomplicated, see https://github.com/francescov1/mongoose-tsgen/issues/124. // If user explicitely sets required: false, we override our logic and assume they know best. if (requiredValue === false) { isOptional = true; } } else if (Array.isArray(val.type)) { val.type = val.type[0]; isArray = true; if (val.type === undefined) { isUntypedArray = true; isOptional = isArrayOuterDefaultSetToUndefined !== null && isArrayOuterDefaultSetToUndefined !== void 0 ? isArrayOuterDefaultSetToUndefined : false; } else if (val.type.type) { /** * Arrays can also take the following format. * This is used when validation needs to be done on both the element itself and the full array. * This format implies `required: true`. * * ``` * friends: { * type: [ * { * type: Schema.Types.ObjectId, * ref: "User", * validate: [ * function(userId: mongoose.Types.ObjectId) { return !this.friends.includes(userId); } * ] * } * ], * validate: [function(val) { return val.length <= 3; } ] * } * ``` */ if (val.type.ref) val.ref = val.type.ref; val.type = val.type.type; isOptional = false; } else if (val.index === "2dsphere") { // 2dsphere index is a special edge case which does not have an inherent default value of [] isOptional = true; } else if ("default" in val && val.default === undefined && requiredValue !== true) { // If default: undefined, it means the field should not default with an empty array. isOptional = true; } else { isOptional = isArrayOuterDefaultSetToUndefined; } // Array optionality is a bit overcomplicated, see https://github.com/francescov1/mongoose-tsgen/issues/124. // If user explicitely sets required: false, we override our logic and assume they know best. if (requiredValue === false) { isOptional = true; } } if (exports.BASE_TYPES.has(val)) val = { type: val }; const isMap = (0, exports.isMapType)(val === null || val === void 0 ? void 0 : val.type); // // handles maps of arrays as per https://github.com/francescov1/mongoose-tsgen/issues/63 if (isMap && Array.isArray(val.of)) { val.of = val.of[0]; isMapOfArray = true; isArray = true; } if (val === Array || (val === null || val === void 0 ? void 0 : val.type) === Array || isUntypedArray) { // treat Array constructor and [] as an Array<Mixed> isArray = true; valueType = "any"; isOptional = isArrayOuterDefaultSetToUndefined !== null && isArrayOuterDefaultSetToUndefined !== void 0 ? isArrayOuterDefaultSetToUndefined : false; // Array optionality is a bit overcomplicated, see https://github.com/francescov1/mongoose-tsgen/issues/124. // If user explicitely sets required: false, we override our logic and assume they know best. if (requiredValue === false) { isOptional = true; } } else if (val._inferredInterfaceName) { valueType = val._inferredInterfaceName + (isDocument ? "Document" : ""); } else if (isMap && ((_b = val.of) === null || _b === void 0 ? void 0 : _b._inferredInterfaceName)) { valueType = val.of._inferredInterfaceName + (isDocument ? "Document" : ""); isOptional = val.of.required !== true; } else if (val.path && val.path && val.setters && val.getters) { // check for virtual properties // skip id property if (key === "id") return ""; // if not lean doc and lean docs shouldnt include virtuals, ignore entry if (!isDocument && !shouldLeanIncludeVirtuals) return ""; // If the val has the _aliasRootField property, it means this field is an alias for another field, and _aliasRootField contains the other field's type. // So we can re-call this function using _aliasRootField. if (val._aliasRootField) { return (0, exports.getTypeFromKeyValue)({ key, val: val._aliasRootField, isDocument, shouldLeanIncludeVirtuals, noMongoose, datesAsStrings }); } valueType = "any"; isOptional = false; } else if (key && [ "get", "set", "schemaName", "_defaultCaster", "defaultOptions", "_checkRequired", "_cast", "checkRequired", "cast", "__v" ].includes(key)) { return ""; } else if (val.ref) { let docRef = (_d = (_c = val.ref).replace) === null || _d === void 0 ? void 0 : _d.call(_c, `'`, ""); if (typeof val.ref === "function") { if (noMongoose) { valueType = "string"; } else { // If we get a function, we cant determine the document that we would populate, so just assume it's an ObjectId valueType = "mongoose.Types.ObjectId"; // If generating the document version, we can also provide document as an option to reflect the populated case. But for // lean docs we can't do this cause we don't have a base type to extend from (since we can't determine it when parsing only JS). // Later the tsReader can implement a function typechecker to subtitute the type with the more exact one. if (isDocument) { valueType += " | mongoose.Document"; } } } else if (docRef) { // If val.ref is an invalid type (not a string) then this gets skipped. if (docRef.includes(".")) { docRef = (0, exports.getSubdocName)(docRef); } const populatedType = isDocument ? `${docRef}Document` : docRef; valueType = val.autopopulate // support for mongoose-autopopulate ? populatedType : `${populatedType}["_id"] | ${populatedType}`; } } else { // _ids are always required if (key === "_id") isOptional = false; const convertedType = (0, exports.convertBaseTypeToTs)({ key, val, isDocument, noMongoose, datesAsStrings }); // TODO: we should detect nested types from unknown types and handle differently. // Currently, if we get an unknown type (ie not handled) then users run into a "max callstack exceeded error" if (convertedType === "{}") { const nestedSchema = lodash_1.default.cloneDeep(val); valueType = "{\n"; Object.keys(nestedSchema).forEach((key) => { valueType += (0, exports.getTypeFromKeyValue)({ key, val: nestedSchema[key], isDocument, shouldLeanIncludeVirtuals, noMongoose, datesAsStrings }); }); valueType += "}"; isOptional = false; } else { valueType = convertedType; } } if (!valueType) return ""; if (isMap && !isMapOfArray) valueType = isDocument ? `mongoose.Types.Map<${valueType}>` : `Map<string, ${valueType}>`; if (isArray) { if (isDocument) valueType = `mongoose.Types.${val._isSubdocArray ? "Document" : ""}Array<` + valueType + ">"; else { // if valueType includes a space, likely means its a union type (ie "number | string") so lets wrap it in brackets when adding the array to the type if (valueType.includes(" ")) valueType = `(${valueType})`; valueType = `${valueType}[]`; } } // a little messy, but if we have a map of arrays, we need to wrap the value after adding the array info if (isMap && isMapOfArray) valueType = isDocument ? `mongoose.Types.Map<${valueType}>` : `Map<string, ${valueType}>`; if ((val === null || val === void 0 ? void 0 : val.default) === null) { valueType += " | null"; } return (0, stringBuilder_1.convertKeyValueToLine)({ key, valueType, isOptional }); }; exports.getTypeFromKeyValue = getTypeFromKeyValue;