mongoose-schema-parser
Version:
Parse mongoose schema to JSON
273 lines (216 loc) • 8.23 kB
JavaScript
const { objectDeepMerge, hasOwnProperty } = require('./utils')
const nestedSchemaTypes = ['ArrayOfSchema']
const parseSchema = (schema) => {
const schemaObj = getSchemaObject(schema)
const parsedSchema = Object.keys(schemaObj).reduce((parsed, propertyName) => {
const property = schemaObj[propertyName]
const propertyType = getPropertyType(property)
parsed[propertyName] = {
type: propertyType
}
// parse Schema
if (propertyType === 'Schema') { // normal schema
let propSchema
if (hasOwnProperty(property, 'obj') || hasOwnProperty(property, 'tree') || hasOwnProperty(property, 'type')) {
propSchema = property
} else { // schema defined as object
propSchema = { obj: property, options: {} }
}
parsed[propertyName].schema = parseSchema(propSchema)
} else {
// parse details
const propertyDetails = parsePropertyDetails(property)
if (propertyDetails) {
parsed[propertyName].details = propertyDetails
}
}
// check if nested schema
if (nestedSchemaTypes.indexOf(propertyType) !== -1) {
parsed[propertyName].schema = parseSchema(property)
}
return parsed
}, {})
return parsedSchema
}
/**
* Generate specific timestamp field.
* When field is nested also create nested schemas, which lead to final field.
*
* @param {Array} path - Path to timestamp field
* @return {Object} Returns generted schema.
*/
const _assembleTimestampFieldSchema = (path = []) => {
if (!path.length) { // handle non nested field
return { type: Date }
}
if (path.length <= 1) { // path end property -> add date schema and finish
return { [path]: { type: Date } }
}
const [pathPartName, ...pathParts] = path
// timestamp field is nested -> create nested schema and go further
const schema = function Schema () {} /* istanbul ignore function */
schema.name = 'Schema'
schema.obj = {
[pathPartName]: _assembleTimestampFieldSchema(pathParts)
}
return schema
}
/**
* Generate timestamp fields schema based on provided config.
*
* @param {Object} options - Mongoose timestamp options
* @return {Object} Returns generated timestamp fields schema.
*/
const _generateTimestampFiledsSchema = (options = {}) => {
// handle default fields
if (options === true || Object.keys(options).join('') === 'currentTime') {
return {
createdAt: { type: Date },
updatedAt: { type: Date }
}
}
const { createdAt, updatedAt } = options
// handle custom fields
const fields = Object.entries({ createdAt, updatedAt }).reduce((schema, [timestampOptName, timestampParamName], ix) => {
if (!timestampParamName) {
return schema
}
// when timestamps.{timestampField} is string, split it to path and field value, otherwise use default property name
const fieldPath = typeof timestampParamName === 'string' ? timestampParamName.split('.') : [timestampOptName]
const fieldName = fieldPath.shift()
if (!schema[fieldName]) {
schema[fieldName] = {}
}
schema[fieldName] = objectDeepMerge(schema[fieldName], _assembleTimestampFieldSchema(fieldPath))
return schema
}, {})
return fields
}
/**
* Parse data from schema options and manually generate specific fields, which are then added to parsed schema (_id and timestamps).
* NOTE:
* I am handling option fields this way, because I am parsing schema.obj property, where this fields are not present.
*
* @param {Object} options - Mongoose schema options
* @return {Object} Returns generated option fields schemas (_id, timestamps)
*/
const _getOptionFields = (options) => {
const fields = {}
// add _id field schema
if (options._id === true) {
fields._id = { type: function ObjectId () {} } /* istanbul ignore function */
}
// add timestamp fields schema
if (options.timestamps) {
const timestampFields = _generateTimestampFiledsSchema(options.timestamps)
Object.assign(fields, timestampFields)
}
return fields
}
const getSchemaObject = (schema) => {
let schemaObj = 'undefined'
if (Array.isArray(schema)) { // if ArrayOfSchema take first item from array
schemaObj = getSchemaObject(schema[0])
} else if (schema.obj || schema.tree) { // take obj
const additionalFileds = schema.options ? _getOptionFields(schema.options) : {}
schemaObj = { ...(schema.obj || schema.tree), ...additionalFileds }
} else if (schema.type) { // get schema inside type
// NOTE:
// if you define subschema as type, mongoose doesn't build
// schema like schema.obj but schema.type.obj
schemaObj = getSchemaObject(schema.type)
} else if (!!schema && typeof schema === 'object' && schema.constructor === Object) {
// NOTE:
// if you define inline schema inside array, mongoose doesn't build
// schema like schema.obj but puts it directly in object
schemaObj = schema
}
return schemaObj
}
const getPropertyType = (property) => {
let type = 'unknown'
/* istanbul ignore else */
if (property.type && property.type.name) { // simple type
// remove Schema from type name to extract simple type (introduced in mongoose 8)
type = property.type.name.startsWith('Schema') ? property.type.name.replace('Schema', '') : property.type.name
} else if (property.type) { // nested item defined as type
type = getPropertyType(property.type)
} else if (property.constructor.name) { // array, object, function, string or schema
type = getPropertyTypeFromConstructor(property)
}
return type
}
const getPropertyTypeFromConstructor = (property) => {
const constructorName = property.constructor.name
let type
switch (constructorName) {
case ('Array'):
type = getMongooseArrayType(property)
break
case ('Function'): // simple type (e.g. String,...)
type = property.name
break
case ('Object'): // Object or Schema
type = (!Object.keys(property).length) ? 'Object' : 'Schema'
break
default:
type = constructorName
}
return type
}
const parsePropertyDetails = (property) => {
if (Array.isArray(property)) {
// handle schema definitions in array (e.g. [{ ... }])
return parsePropertyDetails(property[0])
}
if (!property || !property.type) {
return
}
const detailNames = Object.keys(property)
const details = detailNames
.reduce((details, propName) => {
switch (propName) {
case 'required': // convert to boolean
details.required = !!property[propName]
break
case 'type':
// handle schema definitions in array type (e.g. { type: [{ type: ... }] })
if (Array.isArray(property[propName])) {
const arrayDetails = parsePropertyDetails(property[propName])
Object.assign(details, arrayDetails)
}
break
default:
details[propName] = property[propName]
}
return details
}, {})
return Object.entries(details).length > 0 ? details : undefined
}
const getMongooseArrayType = (arrayDetails) => {
let type = 'Array'
if (arrayDetails.length > 0) { // check if array content type is provided
const details = arrayDetails[0]
// details.name -> simpleTypes [String]
// details.constructor.name -> Schema, Object
// ignore details.name if name is schemas property, else use it as content type
const isNameSchemaProperty = ['object', 'function'].indexOf(typeof details.name) !== -1
// if name is schema property, use constructor name and replace Schema to extract simple type (introduced in mongoose 8)
let arrayContentType = (!isNameSchemaProperty && details.name) ? details.name.replace('Schema', '') : undefined
if (!arrayContentType) {
// if object - when details.type it goes for type definition, else nested schema
// else type definition
arrayContentType = details.constructor.name === 'Object' ? getPropertyType(details) : details.constructor.name
}
type = `ArrayOf${arrayContentType}`
}
return type
}
module.exports = {
getMongooseArrayType,
getPropertyTypeFromConstructor,
getPropertyType,
getSchemaObject,
parsePropertyDetails,
parseSchema
}