From 9800153d07e31940f9c66759cd059b4d4a0bdd93 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Thu, 9 Jan 2025 11:22:30 -0500 Subject: [PATCH 01/15] feat(schema): introduce basic jsonSchema() method to convert Mongoose schema to JSONSchema re: #11162 --- lib/schema.js | 81 ++++++++++++++++++++++++++++++ test/schema.test.js | 119 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/lib/schema.js b/lib/schema.js index 319d0791e8..d116639a28 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2886,6 +2886,87 @@ Schema.prototype._preCompile = function _preCompile() { this.plugin(idGetter, { deduplicate: true }); }; +/** + * @param {Object} [options] + * @param [Boolean] [options.useBsonType=false] if true, specify each path's type using `bsonType` rather than `type` for MongoDB $jsonSchema support + */ + +Schema.prototype.jsonSchema = function jsonSchema(options) { + const useBsonType = options?.useBsonType ?? false; + const result = { required: [], properties: {} }; + for (const path of Object.keys(this.paths)) { + const schemaType = this.paths[path]; + + // Nested paths are stored as `nested.path` in the schema type, so create nested paths in the json schema + // when necessary. + const isNested = schemaType._presplitPath.length > 1; + let jsonSchemaForPath = result; + if (isNested) { + for (let i = 0; i < schemaType._presplitPath.length - 1; ++i) { + const subpath = schemaType._presplitPath[i]; + if (jsonSchemaForPath.properties[subpath] == null) { + jsonSchemaForPath.properties[subpath] = { + bsonType: ['object', 'null'], + required: [], + properties: {} + }; + } + jsonSchemaForPath = jsonSchemaForPath.properties[subpath]; + } + } + + const lastSubpath = schemaType._presplitPath[schemaType._presplitPath.length - 1]; + let isRequired = false; + if (path === '_id') { + jsonSchemaForPath.required.push('_id'); + isRequired = true; + } else if (schemaType.options.required && typeof schemaType.options.required !== 'function') { + // Only `required: true` paths are required, conditional required is not required + jsonSchemaForPath.required.push(lastSubpath); + isRequired = true; + } + let bsonType = undefined; + let type = undefined; + + if (schemaType.instance === 'Number') { + bsonType = ['number']; + type = ['number']; + } else if (schemaType.instance === 'String') { + bsonType = ['string']; + type = ['string']; + } else if (schemaType.instance === 'Boolean') { + bsonType = ['bool']; + type = ['boolean']; + } else if (schemaType.instance === 'Date') { + bsonType = ['date']; + type = ['string']; + } else if (schemaType.instance === 'ObjectId') { + bsonType = ['objectId']; + type = ['string']; + } else if (schemaType.instance === 'Decimal128') { + bsonType = ['decimal']; + type = ['string']; + } + + if (bsonType) { + if (!isRequired) { + bsonType = [...bsonType, 'null']; + type = [...type, 'null']; + } + jsonSchemaForPath.properties[lastSubpath] = useBsonType + ? { bsonType: bsonType.length === 1 ? bsonType[0] : bsonType } + : { type: type.length === 1 ? type[0] : type }; + if (schemaType.options.enum) { + jsonSchemaForPath.properties[lastSubpath].enum = isRequired + ? schemaType.options.enum + : [...schemaType.options.enum, null]; + } + } + } + + return result; +}; + /*! * Module exports. */ diff --git a/test/schema.test.js b/test/schema.test.js index b8f4daf21a..8f0a94aa3b 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3320,4 +3320,123 @@ describe('schema', function() { sinon.restore(); } }); + + describe('jsonSchema() (gh-11162)', function() { + it('handles basic example with only top-level keys', async function() { + const schema = new Schema({ + name: { type: String, required: true }, + age: Number, + ageSource: { + type: String, + required: function() { return this.age != null; }, + enum: ['document', 'self-reported'] + } + }, { autoCreate: false, autoIndex: false }); + + assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + required: ['name', '_id'], + properties: { + _id: { + bsonType: 'objectId' + }, + name: { + bsonType: 'string' + }, + age: { + bsonType: ['number', 'null'] + }, + ageSource: { + bsonType: ['string', 'null'], + enum: ['document', 'self-reported', null] + } + } + }); + + assert.deepStrictEqual(schema.jsonSchema(), { + required: ['name', '_id'], + properties: { + _id: { + type: 'string' + }, + name: { + type: 'string' + }, + age: { + type: ['number', 'null'] + }, + ageSource: { + type: ['string', 'null'], + enum: ['document', 'self-reported', null] + } + } + }); + + const collectionName = 'gh11162'; + try { + await db.createCollection(collectionName, { + validator: { + $jsonSchema: schema.jsonSchema({ useBsonType: true }) + } + }); + const Test = db.model('Test', schema, collectionName); + + const doc1 = await Test.create({ name: 'Taco' }); + assert.equal(doc1.name, 'Taco'); + + const doc2 = await Test.create({ name: 'Billy', age: null, ageSource: null }); + assert.equal(doc2.name, 'Billy'); + assert.strictEqual(doc2.age, null); + assert.strictEqual(doc2.ageSource, null); + + const doc3 = await Test.create({ name: 'John', age: 30, ageSource: 'document' }); + assert.equal(doc3.name, 'John'); + assert.equal(doc3.age, 30); + assert.equal(doc3.ageSource, 'document'); + + await assert.rejects( + Test.create([{ name: 'Foobar', age: null, ageSource: 'something else' }], { validateBeforeSave: false }), + /MongoServerError: Document failed validation/ + ); + + await assert.rejects( + Test.create([{}], { validateBeforeSave: false }), + /MongoServerError: Document failed validation/ + ); + } finally { + await db.dropCollection(collectionName); + } + }); + + it('handles nested paths, subdocuments, and document arrays', async function() { + const schema = new Schema({ + name: { + first: String, + last: { type: String, required: true } + }, + /* subdoc: new Schema({ + prop: Number + }), + docArr: [{ field: Date }] */ + }); + + assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + required: ['_id'], + properties: { + name: { + bsonType: ['object', 'null'], + required: ['last'], + properties: { + first: { + bsonType: ['string', 'null'] + }, + last: { + bsonType: 'string' + } + } + }, + _id: { bsonType: 'objectId' } + } + }); + }); + }); }); From 942911c2e8b5136588318d7dd7960da39d43d466 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 13 Jan 2025 10:49:02 -0500 Subject: [PATCH 02/15] fix(schema): WIP array and document array support for jsonSchema() --- lib/schema.js | 29 +++++++++++++++++++++++++ test/schema.test.js | 52 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index d116639a28..f0dc518e6f 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2887,6 +2887,16 @@ Schema.prototype._preCompile = function _preCompile() { }; /** + * Returns a JSON schema representation of this Schema. + * + * In addition to types, `jsonSchema()` supports the following Mongoose validators: + * - `enum` for strings and numbers + * + * #### Example: + * const schema = new Schema({ name: String }); + * schema.jsonSchema(); // { } + * schema.jsonSchema({ useBsonType: true }); // + * * @param {Object} [options] * @param [Boolean] [options.useBsonType=false] if true, specify each path's type using `bsonType` rather than `type` for MongoDB $jsonSchema support */ @@ -2927,6 +2937,7 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { } let bsonType = undefined; let type = undefined; + let additionalProperties = {}; if (schemaType.instance === 'Number') { bsonType = ['number']; @@ -2946,6 +2957,23 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { } else if (schemaType.instance === 'Decimal128') { bsonType = ['decimal']; type = ['string']; + } else if (schemaType.instance === 'Embedded') { + bsonType = ['object'], + type = ['object']; + additionalProperties = schemaType.schema.jsonSchema(options); + } else if (schemaType.instance === 'Array') { + bsonType = ['array']; + type = ['array']; + if (schemaType.schema) { + // DocumentArray + if (useBsonType) { + additionalProperties.items = { bsonType: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; + } else { + additionalProperties.items = { type: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; + } + } else { + // Primitive array + } } if (bsonType) { @@ -2961,6 +2989,7 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { ? schemaType.options.enum : [...schemaType.options.enum, null]; } + Object.assign(jsonSchemaForPath.properties[lastSubpath], additionalProperties); } } diff --git a/test/schema.test.js b/test/schema.test.js index 8f0a94aa3b..288be0baa2 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3407,16 +3407,45 @@ describe('schema', function() { } }); - it('handles nested paths, subdocuments, and document arrays', async function() { + it('handles arrays and document arrays', async function() { + const schema = new Schema({ + tags: [String], + docArr: [new Schema({ field: Date }, { _id: false })] + }); + + assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + required: ['_id'], + properties: { + tags: { + bsonType: ['array', 'null'], + items: { + bsonType: ['string', 'null'] + } + }, + docArr: { + bsonType: ['array', 'null'], + items: { + bsonType: ['object', 'null'], + required: [], + properties: { + field: { bsonType: ['date', 'null'] } + } + } + }, + _id: { bsonType: 'objectId' } + } + }); + }); + + it('handles nested paths and subdocuments', async function() { const schema = new Schema({ name: { first: String, last: { type: String, required: true } }, - /* subdoc: new Schema({ + subdoc: new Schema({ prop: Number - }), - docArr: [{ field: Date }] */ + }, { _id: false }) }); assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { @@ -3426,11 +3455,16 @@ describe('schema', function() { bsonType: ['object', 'null'], required: ['last'], properties: { - first: { - bsonType: ['string', 'null'] - }, - last: { - bsonType: 'string' + first: { bsonType: ['string', 'null'] }, + last: { bsonType: 'string' } + } + }, + subdoc: { + bsonType: ['object', 'null'], + required: [], + properties: { + prop: { + bsonType: ['number', 'null'] } } }, From cf1f3b3f5fbf5e2c67871d0800abc98a29b2f79e Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 13 Jan 2025 12:02:56 -0500 Subject: [PATCH 03/15] fix(schema): support primitive arrays and arrays of arrays in JSONschema --- lib/schema.js | 116 +++++++++++++++++++++++++++++--------------- test/schema.test.js | 93 +++++++++++++++++++++++------------ 2 files changed, 138 insertions(+), 71 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index f0dc518e6f..7f83d8b9cb 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2935,46 +2935,9 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { jsonSchemaForPath.required.push(lastSubpath); isRequired = true; } - let bsonType = undefined; - let type = undefined; - let additionalProperties = {}; - - if (schemaType.instance === 'Number') { - bsonType = ['number']; - type = ['number']; - } else if (schemaType.instance === 'String') { - bsonType = ['string']; - type = ['string']; - } else if (schemaType.instance === 'Boolean') { - bsonType = ['bool']; - type = ['boolean']; - } else if (schemaType.instance === 'Date') { - bsonType = ['date']; - type = ['string']; - } else if (schemaType.instance === 'ObjectId') { - bsonType = ['objectId']; - type = ['string']; - } else if (schemaType.instance === 'Decimal128') { - bsonType = ['decimal']; - type = ['string']; - } else if (schemaType.instance === 'Embedded') { - bsonType = ['object'], - type = ['object']; - additionalProperties = schemaType.schema.jsonSchema(options); - } else if (schemaType.instance === 'Array') { - bsonType = ['array']; - type = ['array']; - if (schemaType.schema) { - // DocumentArray - if (useBsonType) { - additionalProperties.items = { bsonType: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; - } else { - additionalProperties.items = { type: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; - } - } else { - // Primitive array - } - } + const convertedSchemaType = _schemaTypeToJSONSchema(schemaType, isRequired, options); + const { additionalProperties } = convertedSchemaType; + let { bsonType, type } = convertedSchemaType; if (bsonType) { if (!isRequired) { @@ -2993,9 +2956,82 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { } } + // Otherwise MongoDB errors with "$jsonSchema keyword 'required' cannot be an empty array" + if (result.required.length === 0) { + delete result.required; + } return result; }; +/*! + * Internal helper for converting an individual schematype to JSON schema properties. Recursively called for + * arrays. + * + * @param {SchemaType} schemaType + * @param {Boolean} isRequired + * @param {Object} options + */ + +function _schemaTypeToJSONSchema(schemaType, isRequired, options) { + const useBsonType = options?.useBsonType ?? false; + let bsonType = undefined; + let type = undefined; + let additionalProperties = {}; + + if (schemaType.instance === 'Number') { + bsonType = ['number']; + type = ['number']; + } else if (schemaType.instance === 'String') { + bsonType = ['string']; + type = ['string']; + } else if (schemaType.instance === 'Boolean') { + bsonType = ['bool']; + type = ['boolean']; + } else if (schemaType.instance === 'Date') { + bsonType = ['date']; + type = ['string']; + } else if (schemaType.instance === 'ObjectId') { + bsonType = ['objectId']; + type = ['string']; + } else if (schemaType.instance === 'Decimal128') { + bsonType = ['decimal']; + type = ['string']; + } else if (schemaType.instance === 'Embedded') { + bsonType = ['object'], + type = ['object']; + additionalProperties = schemaType.schema.jsonSchema(options); + } else if (schemaType.instance === 'Array') { + bsonType = ['array']; + type = ['array']; + if (schemaType.schema) { + // DocumentArray + if (useBsonType) { + additionalProperties.items = { bsonType: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; + } else { + additionalProperties.items = { type: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; + } + } else { + // Primitive array + const embeddedSchemaType = schemaType.getEmbeddedSchemaType(); + const isRequired = embeddedSchemaType.options.required && typeof embeddedSchemaType.options.required !== 'function'; + const convertedSchemaType = _schemaTypeToJSONSchema(embeddedSchemaType, isRequired, options); + let bsonType = convertedSchemaType.bsonType; + let type = convertedSchemaType.type; + if (!isRequired) { + bsonType = [...bsonType, 'null']; + type = [...type, 'null']; + } + if (useBsonType) { + additionalProperties.items = { bsonType, ...convertedSchemaType.additionalProperties }; + } else { + additionalProperties.items = { type, ...convertedSchemaType.additionalProperties }; + } + } + } + + return { bsonType, type, additionalProperties }; +} + /*! * Module exports. */ diff --git a/test/schema.test.js b/test/schema.test.js index 288be0baa2..804496ceb3 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3322,6 +3322,12 @@ describe('schema', function() { }); describe('jsonSchema() (gh-11162)', function() { + const collectionName = 'gh11162'; + + afterEach(async function() { + await db.dropCollection(collectionName); + }); + it('handles basic example with only top-level keys', async function() { const schema = new Schema({ name: { type: String, required: true }, @@ -3371,45 +3377,41 @@ describe('schema', function() { } }); - const collectionName = 'gh11162'; - try { - await db.createCollection(collectionName, { - validator: { - $jsonSchema: schema.jsonSchema({ useBsonType: true }) - } - }); - const Test = db.model('Test', schema, collectionName); + await db.createCollection(collectionName, { + validator: { + $jsonSchema: schema.jsonSchema({ useBsonType: true }) + } + }); + const Test = db.model('Test', schema, collectionName); - const doc1 = await Test.create({ name: 'Taco' }); - assert.equal(doc1.name, 'Taco'); + const doc1 = await Test.create({ name: 'Taco' }); + assert.equal(doc1.name, 'Taco'); - const doc2 = await Test.create({ name: 'Billy', age: null, ageSource: null }); - assert.equal(doc2.name, 'Billy'); - assert.strictEqual(doc2.age, null); - assert.strictEqual(doc2.ageSource, null); + const doc2 = await Test.create({ name: 'Billy', age: null, ageSource: null }); + assert.equal(doc2.name, 'Billy'); + assert.strictEqual(doc2.age, null); + assert.strictEqual(doc2.ageSource, null); - const doc3 = await Test.create({ name: 'John', age: 30, ageSource: 'document' }); - assert.equal(doc3.name, 'John'); - assert.equal(doc3.age, 30); - assert.equal(doc3.ageSource, 'document'); + const doc3 = await Test.create({ name: 'John', age: 30, ageSource: 'document' }); + assert.equal(doc3.name, 'John'); + assert.equal(doc3.age, 30); + assert.equal(doc3.ageSource, 'document'); - await assert.rejects( - Test.create([{ name: 'Foobar', age: null, ageSource: 'something else' }], { validateBeforeSave: false }), - /MongoServerError: Document failed validation/ - ); + await assert.rejects( + Test.create([{ name: 'Foobar', age: null, ageSource: 'something else' }], { validateBeforeSave: false }), + /MongoServerError: Document failed validation/ + ); - await assert.rejects( - Test.create([{}], { validateBeforeSave: false }), - /MongoServerError: Document failed validation/ - ); - } finally { - await db.dropCollection(collectionName); - } + await assert.rejects( + Test.create([{}], { validateBeforeSave: false }), + /MongoServerError: Document failed validation/ + ); }); it('handles arrays and document arrays', async function() { const schema = new Schema({ tags: [String], + coordinates: [[{ type: Number, required: true }]], docArr: [new Schema({ field: Date }, { _id: false })] }); @@ -3422,11 +3424,19 @@ describe('schema', function() { bsonType: ['string', 'null'] } }, + coordinates: { + bsonType: ['array', 'null'], + items: { + bsonType: ['array', 'null'], + items: { + bsonType: ['number'] + } + } + }, docArr: { bsonType: ['array', 'null'], items: { bsonType: ['object', 'null'], - required: [], properties: { field: { bsonType: ['date', 'null'] } } @@ -3435,6 +3445,18 @@ describe('schema', function() { _id: { bsonType: 'objectId' } } }); + + await db.createCollection(collectionName, { + validator: { + $jsonSchema: schema.jsonSchema({ useBsonType: true }) + } + }); + const Test = db.model('Test', schema, collectionName); + + const now = new Date(); + await Test.create({ tags: ['javascript'], coordinates: [[0, 0]], docArr: [{ field: now }] }); + + await Test.create({ tags: 'javascript', coordinates: [[0, 0]], docArr: [{}] }); }); it('handles nested paths and subdocuments', async function() { @@ -3461,7 +3483,6 @@ describe('schema', function() { }, subdoc: { bsonType: ['object', 'null'], - required: [], properties: { prop: { bsonType: ['number', 'null'] @@ -3471,6 +3492,16 @@ describe('schema', function() { _id: { bsonType: 'objectId' } } }); + + await db.createCollection(collectionName, { + validator: { + $jsonSchema: schema.jsonSchema({ useBsonType: true }) + } + }); + const Test = db.model('Test', schema, collectionName); + + await Test.create({ name: { last: 'James' }, subdoc: {} }); + await Test.create({ name: { first: 'Mike', last: 'James' }, subdoc: { prop: 42 } }); }); }); }); From d0e8d57fdd0a9a4f377a2f598a749f743a7dd995 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 13 Jan 2025 12:26:09 -0500 Subject: [PATCH 04/15] fix(schema): test jsonSchema() output with AJV --- lib/schema.js | 31 +++++++++++++++++++++++-------- package.json | 1 + test/schema.test.js | 23 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index 7f83d8b9cb..a94cda42a4 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2889,13 +2889,20 @@ Schema.prototype._preCompile = function _preCompile() { /** * Returns a JSON schema representation of this Schema. * + * By default, returns normal JSON schema representation, which is not typically what you want to use with + * [MongoDB's `$jsonSchema` collection option](https://www.mongodb.com/docs/manual/core/schema-validation/specify-json-schema/). + * Use the `useBsonType: true` option to return MongoDB `$jsonSchema` syntax instead. + * * In addition to types, `jsonSchema()` supports the following Mongoose validators: * - `enum` for strings and numbers * * #### Example: * const schema = new Schema({ name: String }); - * schema.jsonSchema(); // { } - * schema.jsonSchema({ useBsonType: true }); // + * // // { required: ['_id'], properties: { name: { type: ['string', 'null'] }, _id: { type: 'string' } } } + * schema.jsonSchema(); + * + * // { required: ['_id'], properties: { name: { bsonType: ['string', 'null'] }, _id: { bsonType: 'objectId' } } } + * schema.jsonSchema({ useBsonType: true }); * * @param {Object} [options] * @param [Boolean] [options.useBsonType=false] if true, specify each path's type using `bsonType` rather than `type` for MongoDB $jsonSchema support @@ -2903,7 +2910,7 @@ Schema.prototype._preCompile = function _preCompile() { Schema.prototype.jsonSchema = function jsonSchema(options) { const useBsonType = options?.useBsonType ?? false; - const result = { required: [], properties: {} }; + const result = useBsonType ? { required: [], properties: {} } : { type: 'object', required: [], properties: {} }; for (const path of Object.keys(this.paths)) { const schemaType = this.paths[path]; @@ -2915,11 +2922,17 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { for (let i = 0; i < schemaType._presplitPath.length - 1; ++i) { const subpath = schemaType._presplitPath[i]; if (jsonSchemaForPath.properties[subpath] == null) { - jsonSchemaForPath.properties[subpath] = { - bsonType: ['object', 'null'], - required: [], - properties: {} - }; + jsonSchemaForPath.properties[subpath] = useBsonType + ? { + bsonType: ['object', 'null'], + required: [], + properties: {} + } + : { + type: ['object', 'null'], + required: [], + properties: {} + }; } jsonSchemaForPath = jsonSchemaForPath.properties[subpath]; } @@ -3027,6 +3040,8 @@ function _schemaTypeToJSONSchema(schemaType, isRequired, options) { additionalProperties.items = { type, ...convertedSchemaType.additionalProperties }; } } + } else { + throw new Error(`Cannot convert schema to JSON schema: unsupported schematype ${schemaType.instance}`); } return { bsonType, type, additionalProperties }; diff --git a/package.json b/package.json index 453705f05e..7690e1dca5 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "acquit": "1.3.0", "acquit-ignore": "0.2.1", "acquit-require": "0.1.1", + "ajv": "8.17.1", "assert-browserify": "2.0.0", "babel-loader": "8.2.5", "broken-link-checker": "^0.7.8", diff --git a/test/schema.test.js b/test/schema.test.js index 804496ceb3..04d05979ed 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -6,6 +6,7 @@ const start = require('./common'); +const Ajv = require('ajv'); const mongoose = start.mongoose; const assert = require('assert'); const sinon = require('sinon'); @@ -3359,6 +3360,7 @@ describe('schema', function() { }); assert.deepStrictEqual(schema.jsonSchema(), { + type: 'object', required: ['name', '_id'], properties: { _id: { @@ -3406,6 +3408,15 @@ describe('schema', function() { Test.create([{}], { validateBeforeSave: false }), /MongoServerError: Document failed validation/ ); + + const ajv = new Ajv(); + const validate = ajv.compile(schema.jsonSchema()); + + assert.ok(validate({ _id: 'test', name: 'Taco' })); + assert.ok(validate({ _id: 'test', name: 'Billy', age: null, ageSource: null })); + assert.ok(validate({ _id: 'test', name: 'John', age: 30, ageSource: 'document' })); + assert.ok(!validate({ _id: 'test', name: 'Foobar', age: null, ageSource: 'something else' })); + assert.ok(!validate({})); }); it('handles arrays and document arrays', async function() { @@ -3457,6 +3468,12 @@ describe('schema', function() { await Test.create({ tags: ['javascript'], coordinates: [[0, 0]], docArr: [{ field: now }] }); await Test.create({ tags: 'javascript', coordinates: [[0, 0]], docArr: [{}] }); + + const ajv = new Ajv(); + const validate = ajv.compile(schema.jsonSchema()); + + assert.ok(validate({ _id: 'test', tags: ['javascript'], coordinates: [[0, 0]], docArr: [{ field: '2023-07-16' }] })); + assert.ok(validate({ _id: 'test', tags: ['javascript'], coordinates: [[0, 0]], docArr: [{}] })); }); it('handles nested paths and subdocuments', async function() { @@ -3502,6 +3519,12 @@ describe('schema', function() { await Test.create({ name: { last: 'James' }, subdoc: {} }); await Test.create({ name: { first: 'Mike', last: 'James' }, subdoc: { prop: 42 } }); + + const ajv = new Ajv(); + const validate = ajv.compile(schema.jsonSchema()); + + assert.ok(validate({ _id: 'test', name: { last: 'James' }, subdoc: {} })); + assert.ok(validate({ _id: 'test', name: { first: 'Mike', last: 'James' }, subdoc: { prop: 42 } })); }); }); }); From e8a35c3a039096de324081d0b0c210b2fa5d44dd Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 13 Jan 2025 15:30:10 -0500 Subject: [PATCH 05/15] fix(schema): map support for jsonSchema() --- lib/schema.js | 49 +++++++++++++++--- lib/schema/map.js | 7 +++ test/schema.test.js | 122 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 7 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index a94cda42a4..f44135707b 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2914,6 +2914,11 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { for (const path of Object.keys(this.paths)) { const schemaType = this.paths[path]; + // Skip Map embedded paths, maps will be handled seperately. + if (schemaType._presplitPath.indexOf('$*') !== -1) { + continue; + } + // Nested paths are stored as `nested.path` in the schema type, so create nested paths in the json schema // when necessary. const isNested = schemaType._presplitPath.length > 1; @@ -2925,12 +2930,10 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { jsonSchemaForPath.properties[subpath] = useBsonType ? { bsonType: ['object', 'null'], - required: [], properties: {} } : { type: ['object', 'null'], - required: [], properties: {} }; } @@ -2941,9 +2944,15 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { const lastSubpath = schemaType._presplitPath[schemaType._presplitPath.length - 1]; let isRequired = false; if (path === '_id') { + if (!jsonSchemaForPath.required) { + jsonSchemaForPath.required = []; + } jsonSchemaForPath.required.push('_id'); isRequired = true; } else if (schemaType.options.required && typeof schemaType.options.required !== 'function') { + if (!jsonSchemaForPath.required) { + jsonSchemaForPath.required = []; + } // Only `required: true` paths are required, conditional required is not required jsonSchemaForPath.required.push(lastSubpath); isRequired = true; @@ -3019,9 +3028,9 @@ function _schemaTypeToJSONSchema(schemaType, isRequired, options) { if (schemaType.schema) { // DocumentArray if (useBsonType) { - additionalProperties.items = { bsonType: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; + additionalProperties.items = { ...schemaType.schema.jsonSchema(options), bsonType: ['object', 'null'] }; } else { - additionalProperties.items = { type: ['object', 'null'], ...schemaType.schema.jsonSchema(options) }; + additionalProperties.items = { ...schemaType.schema.jsonSchema(options), type: ['object', 'null'] }; } } else { // Primitive array @@ -3035,13 +3044,39 @@ function _schemaTypeToJSONSchema(schemaType, isRequired, options) { type = [...type, 'null']; } if (useBsonType) { - additionalProperties.items = { bsonType, ...convertedSchemaType.additionalProperties }; + additionalProperties.items = { ...convertedSchemaType.additionalProperties, bsonType }; + } else { + additionalProperties.items = { ...convertedSchemaType.additionalProperties, type }; + } + } + } else if (schemaType.instance === 'Map') { + bsonType = ['object']; + type = ['object']; + const embeddedSchemaType = schemaType.getEmbeddedSchemaType(); + const isRequired = embeddedSchemaType.options.required && typeof embeddedSchemaType.options.required !== 'function'; + if (embeddedSchemaType.schema) { + // Map of objects + additionalProperties.additionalProperties = useBsonType + ? { ...embeddedSchemaType.schema.jsonSchema(options), bsonType: ['object', 'null'] } + : { ...embeddedSchemaType.schema.jsonSchema(options), type: ['object', 'null'] }; + + } else { + // Map of primitives + const convertedSchemaType = _schemaTypeToJSONSchema(embeddedSchemaType, isRequired, options); + let bsonType = convertedSchemaType.bsonType; + let type = convertedSchemaType.type; + if (!isRequired) { + bsonType = [...bsonType, 'null']; + type = [...type, 'null']; + } + if (useBsonType) { + additionalProperties.additionalProperties = { bsonType: bsonType.length === 1 ? bsonType[0] : bsonType }; } else { - additionalProperties.items = { type, ...convertedSchemaType.additionalProperties }; + additionalProperties.additionalProperties = { type: type.length === 1 ? type[0] : type }; } } } else { - throw new Error(`Cannot convert schema to JSON schema: unsupported schematype ${schemaType.instance}`); + throw new Error(`Cannot convert schema to JSON schema: unsupported schematype "${schemaType.instance}"`); } return { bsonType, type, additionalProperties }; diff --git a/lib/schema/map.js b/lib/schema/map.js index 1c7c41ae90..976245a198 100644 --- a/lib/schema/map.js +++ b/lib/schema/map.js @@ -67,6 +67,13 @@ class SchemaMap extends SchemaType { } return schematype; } + + /** + * Returns the embedded schema type (i.e. the `.$*` path) + */ + getEmbeddedSchemaType() { + return this.$__schemaType; + } } /** diff --git a/test/schema.test.js b/test/schema.test.js index 04d05979ed..e73b11f236 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3526,5 +3526,127 @@ describe('schema', function() { assert.ok(validate({ _id: 'test', name: { last: 'James' }, subdoc: {} })); assert.ok(validate({ _id: 'test', name: { first: 'Mike', last: 'James' }, subdoc: { prop: 42 } })); }); + + it('handles maps', async function() { + const schema = new Schema({ + props: { + type: Map, + of: String, + required: true + }, + subdocs: { + type: Map, + of: new Schema({ + name: String, + age: { type: Number, required: true } + }, { _id: false }) + }, + nested: { + myMap: { + type: Map, + of: Number + } + } + }); + + assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + required: ['props', '_id'], + properties: { + props: { + bsonType: 'object', + additionalProperties: { + bsonType: ['string', 'null'] + } + }, + subdocs: { + bsonType: ['object', 'null'], + additionalProperties: { + bsonType: ['object', 'null'], + required: ['age'], + properties: { + name: { bsonType: ['string', 'null'] }, + age: { bsonType: 'number' } + } + } + }, + nested: { + bsonType: ['object', 'null'], + properties: { + myMap: { + bsonType: ['object', 'null'], + additionalProperties: { + bsonType: ['number', 'null'] + } + } + } + }, + _id: { bsonType: 'objectId' } + } + }); + + await db.createCollection(collectionName, { + validator: { + $jsonSchema: schema.jsonSchema({ useBsonType: true }) + } + }); + const Test = db.model('Test', schema, collectionName); + + await Test.create({ + props: new Map([['key', 'value']]), + subdocs: { + captain: { + name: 'Jean-Luc Picard', + age: 59 + } + }, + nested: { + myMap: { + answer: 42 + } + } + }); + + await assert.rejects( + Test.create([{ + props: new Map([['key', 'value']]), + subdocs: { + captain: {} + } + }], { validateBeforeSave: false }), + /MongoServerError: Document failed validation/ + ); + + const ajv = new Ajv(); + const validate = ajv.compile(schema.jsonSchema()); + + assert.ok(validate({ + _id: 'test', + props: { someKey: 'someValue' }, + subdocs: { + captain: { + name: 'Jean-Luc Picard', + age: 59 + } + }, + nested: { + myMap: { + answer: 42 + } + } + })); + assert.ok(!validate({ + props: { key: 'value' }, + subdocs: { + captain: {} + } + })); + assert.ok(!validate({ + nested: { + myMap: { + answer: 'not a number' + } + } + })); + }); }); }); From eff58ce2360775244bc8274ffbf06ae6787d24ca Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 15 Jan 2025 08:57:47 -0500 Subject: [PATCH 06/15] test: add test case for map of arrays, implementation still WIP --- test/schema.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/schema.test.js b/test/schema.test.js index e73b11f236..0020361b43 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3546,6 +3546,10 @@ describe('schema', function() { type: Map, of: Number } + }, + arrs: { + type: Map, + of: [String] } }); @@ -3580,6 +3584,18 @@ describe('schema', function() { } } }, + arrs: { + bsonType: ['object', 'null'], + additionalProperties: { + bsonType: ['array', 'null'], + items: { + bsonType: ['object', 'null'], + additionalProperties: { + bsonType: ['string', 'null'] + } + } + } + }, _id: { bsonType: 'objectId' } } }); From cf34a9f8d64c5377a31f16920fddd3205f4352b5 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 15 Jan 2025 16:15:21 -0500 Subject: [PATCH 07/15] fix: handle maps of arrays --- lib/schema.js | 20 +++++++++++++------- test/schema.test.js | 25 +++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index f44135707b..360f0747a5 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -3055,11 +3055,17 @@ function _schemaTypeToJSONSchema(schemaType, isRequired, options) { const embeddedSchemaType = schemaType.getEmbeddedSchemaType(); const isRequired = embeddedSchemaType.options.required && typeof embeddedSchemaType.options.required !== 'function'; if (embeddedSchemaType.schema) { - // Map of objects - additionalProperties.additionalProperties = useBsonType - ? { ...embeddedSchemaType.schema.jsonSchema(options), bsonType: ['object', 'null'] } - : { ...embeddedSchemaType.schema.jsonSchema(options), type: ['object', 'null'] }; - + if (embeddedSchemaType.instance === 'Array') { + // Map of document arrays + additionalProperties.additionalProperties = useBsonType + ? { bsonType: ['array', 'null'], items: { bsonType: ['object', 'null'], ...embeddedSchemaType.schema.jsonSchema(options) } } + : { type: ['array', 'null'], items: { type: ['object', 'null'], ...embeddedSchemaType.schema.jsonSchema(options) } }; + } else { + // Map of objects + additionalProperties.additionalProperties = useBsonType + ? { ...embeddedSchemaType.schema.jsonSchema(options), bsonType: ['object', 'null'] } + : { ...embeddedSchemaType.schema.jsonSchema(options), type: ['object', 'null'] }; + } } else { // Map of primitives const convertedSchemaType = _schemaTypeToJSONSchema(embeddedSchemaType, isRequired, options); @@ -3070,9 +3076,9 @@ function _schemaTypeToJSONSchema(schemaType, isRequired, options) { type = [...type, 'null']; } if (useBsonType) { - additionalProperties.additionalProperties = { bsonType: bsonType.length === 1 ? bsonType[0] : bsonType }; + additionalProperties.additionalProperties = { bsonType: bsonType.length === 1 ? bsonType[0] : bsonType, ...convertedSchemaType.additionalProperties }; } else { - additionalProperties.additionalProperties = { type: type.length === 1 ? type[0] : type }; + additionalProperties.additionalProperties = { type: type.length === 1 ? type[0] : type, ...convertedSchemaType.additionalProperties }; } } } else { diff --git a/test/schema.test.js b/test/schema.test.js index 0020361b43..d89695ac41 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3550,6 +3550,10 @@ describe('schema', function() { arrs: { type: Map, of: [String] + }, + docArrs: { + type: Map, + of: [new Schema({ name: String }, { _id: false })] } }); @@ -3585,13 +3589,24 @@ describe('schema', function() { } }, arrs: { + bsonType: ['object', 'null'], + additionalProperties: { + bsonType: ['array', 'null'], + items: { + bsonType: ['string', 'null'] + } + } + }, + docArrs: { bsonType: ['object', 'null'], additionalProperties: { bsonType: ['array', 'null'], items: { bsonType: ['object', 'null'], - additionalProperties: { - bsonType: ['string', 'null'] + properties: { + name: { + bsonType: ['string', 'null'] + } } } } @@ -3619,6 +3634,12 @@ describe('schema', function() { myMap: { answer: 42 } + }, + arrs: { + key: ['value'] + }, + docArrs: { + otherKey: [{ name: 'otherValue' }] } }); From 553b29c0a146364b4a7dea8d2a16d822ad5a4846 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Wed, 15 Jan 2025 16:16:06 -0500 Subject: [PATCH 08/15] test: expand map jsonSchema tests --- test/schema.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/schema.test.js b/test/schema.test.js index d89695ac41..4fe3199d88 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3669,6 +3669,12 @@ describe('schema', function() { myMap: { answer: 42 } + }, + arrs: { + key: ['value'] + }, + docArrs: { + otherKey: [{ name: 'otherValue' }] } })); assert.ok(!validate({ From 9a89b46555c2c19c72f1e30b4d5dd212a77c947c Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Thu, 16 Jan 2025 16:08:53 -0500 Subject: [PATCH 09/15] Update lib/schema.js Co-authored-by: hasezoey --- lib/schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/schema.js b/lib/schema.js index 360f0747a5..5086019c73 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2898,7 +2898,7 @@ Schema.prototype._preCompile = function _preCompile() { * * #### Example: * const schema = new Schema({ name: String }); - * // // { required: ['_id'], properties: { name: { type: ['string', 'null'] }, _id: { type: 'string' } } } + * // { required: ['_id'], properties: { name: { type: ['string', 'null'] }, _id: { type: 'string' } } } * schema.jsonSchema(); * * // { required: ['_id'], properties: { name: { bsonType: ['string', 'null'] }, _id: { bsonType: 'objectId' } } } From 402bbecb15867e531f950809df7896874887beac Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 17 Jan 2025 15:18:05 -0500 Subject: [PATCH 10/15] add Buffer and UUID, add link to JSON schema docs, add additional test coverage --- lib/schema.js | 8 ++- test/schema.test.js | 148 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/lib/schema.js b/lib/schema.js index 5086019c73..92fbc50b8d 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2889,7 +2889,7 @@ Schema.prototype._preCompile = function _preCompile() { /** * Returns a JSON schema representation of this Schema. * - * By default, returns normal JSON schema representation, which is not typically what you want to use with + * By default, returns normal [JSON schema representation](https://json-schema.org/learn/getting-started-step-by-step), which is not typically what you want to use with * [MongoDB's `$jsonSchema` collection option](https://www.mongodb.com/docs/manual/core/schema-validation/specify-json-schema/). * Use the `useBsonType: true` option to return MongoDB `$jsonSchema` syntax instead. * @@ -3018,6 +3018,12 @@ function _schemaTypeToJSONSchema(schemaType, isRequired, options) { } else if (schemaType.instance === 'Decimal128') { bsonType = ['decimal']; type = ['string']; + } else if (schemaType.instance === 'Buffer') { + bsonType = ['binData']; + type = ['string']; + } else if (schemaType.instance === 'UUID') { + bsonType = ['binData']; + type = ['string']; } else if (schemaType.instance === 'Embedded') { bsonType = ['object'], type = ['object']; diff --git a/test/schema.test.js b/test/schema.test.js index 4fe3199d88..3cee617a1a 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3419,6 +3419,86 @@ describe('schema', function() { assert.ok(!validate({})); }); + it('handles all primitive data types', async function() { + const schema = new Schema({ + num: Number, + str: String, + bool: Boolean, + date: Date, + id: mongoose.ObjectId, + decimal: mongoose.Types.Decimal128, + buf: Buffer, + uuid: 'UUID' + }); + + assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + required: ['_id'], + properties: { + num: { + bsonType: ['number', 'null'] + }, + str: { + bsonType: ['string', 'null'] + }, + bool: { + bsonType: ['bool', 'null'] + }, + date: { + bsonType: ['date', 'null'] + }, + id: { + bsonType: ['objectId', 'null'] + }, + decimal: { + bsonType: ['decimal', 'null'] + }, + buf: { + bsonType: ['binData', 'null'] + }, + uuid: { + bsonType: ['binData', 'null'] + }, + _id: { + bsonType: 'objectId' + } + } + }); + + assert.deepStrictEqual(schema.jsonSchema(), { + type: 'object', + required: ['_id'], + properties: { + num: { + type: ['number', 'null'] + }, + str: { + type: ['string', 'null'] + }, + bool: { + type: ['boolean', 'null'] + }, + date: { + type: ['string', 'null'] + }, + id: { + type: ['string', 'null'] + }, + decimal: { + type: ['string', 'null'] + }, + buf: { + type: ['string', 'null'] + }, + uuid: { + type: ['string', 'null'] + }, + _id: { + type: 'string' + } + } + }); + }); + it('handles arrays and document arrays', async function() { const schema = new Schema({ tags: [String], @@ -3691,5 +3771,73 @@ describe('schema', function() { } })); }); + + it('handles map with required element', async function() { + const schema = new Schema({ + props: { + type: Map, + of: { type: String, required: true } + } + }); + + assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + required: ['_id'], + properties: { + props: { + bsonType: ['object', 'null'], + additionalProperties: { + bsonType: 'string' + } + }, + _id: { + bsonType: 'objectId' + } + } + }); + + assert.deepStrictEqual(schema.jsonSchema(), { + type: 'object', + required: ['_id'], + properties: { + props: { + type: ['object', 'null'], + additionalProperties: { + type: 'string' + } + }, + _id: { + type: 'string' + } + } + }); + }) + + it('handles required enums', function() { + const RacoonSchema = new Schema({ + name: { type: String, enum: ['Edwald', 'Tobi'], required: true } + }); + + assert.deepStrictEqual(RacoonSchema.jsonSchema({ useBsonType: true }), { + required: ['name', '_id'], + properties: { + name: { + bsonType: 'string', + enum: ['Edwald', 'Tobi'] + }, + _id: { + bsonType: 'objectId' + } + } + }); + }); + + it('throws error on mixed type', function() { + const schema = new Schema({ + mixed: mongoose.Mixed + }); + + assert.throws(() => schema.jsonSchema({ useBsonType: true }), /unsupported schematype "Mixed"/); + assert.throws(() => schema.jsonSchema(), /unsupported schematype "Mixed"/); + }); }); }); From fd6022ba801297678d9b5ea6dde18ccbca4e6820 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 17 Jan 2025 15:21:17 -0500 Subject: [PATCH 11/15] style: fix lint --- test/schema.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/schema.test.js b/test/schema.test.js index 3cee617a1a..05f0a23ac8 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3810,7 +3810,7 @@ describe('schema', function() { } } }); - }) + }); it('handles required enums', function() { const RacoonSchema = new Schema({ From 879443ddca525770a35e24aee9ef2f6fcd3e40fd Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 17 Jan 2025 15:38:51 -0500 Subject: [PATCH 12/15] fix tests --- test/schema.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/schema.test.js b/test/schema.test.js index 05f0a23ac8..02cc4fecdc 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3326,7 +3326,11 @@ describe('schema', function() { const collectionName = 'gh11162'; afterEach(async function() { - await db.dropCollection(collectionName); + await db.dropCollection(collectionName).catch(err => { + if (err.message !== 'ns not found') { + throw err; + } + }); }); it('handles basic example with only top-level keys', async function() { From 00a2778c3f2d52829340cac91f92123ec732a7c0 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sun, 19 Jan 2025 19:48:06 -0500 Subject: [PATCH 13/15] refactor(schema): move logic for converting SchemaType to JSON schema into the individual SchemaType classes Re: #11162 --- lib/helpers/createJSONSchemaTypeDefinition.js | 24 ++++ lib/schema.js | 132 +----------------- lib/schema/array.js | 18 +++ lib/schema/bigint.js | 14 ++ lib/schema/boolean.js | 14 ++ lib/schema/buffer.js | 14 ++ lib/schema/date.js | 14 ++ lib/schema/decimal128.js | 14 ++ lib/schema/documentArray.js | 18 +++ lib/schema/double.js | 13 ++ lib/schema/int32.js | 14 ++ lib/schema/map.js | 28 ++++ lib/schema/number.js | 14 ++ lib/schema/objectId.js | 14 ++ lib/schema/string.js | 14 ++ lib/schema/subdocument.js | 17 +++ lib/schema/uuid.js | 14 ++ lib/schemaType.js | 12 ++ test/schema.test.js | 53 ++++++- 19 files changed, 324 insertions(+), 131 deletions(-) create mode 100644 lib/helpers/createJSONSchemaTypeDefinition.js diff --git a/lib/helpers/createJSONSchemaTypeDefinition.js b/lib/helpers/createJSONSchemaTypeDefinition.js new file mode 100644 index 0000000000..40e108262d --- /dev/null +++ b/lib/helpers/createJSONSchemaTypeDefinition.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Handles creating `{ type: 'object' }` vs `{ bsonType: 'object' }` vs `{ bsonType: ['object', 'null'] }` + * + * @param {String} type + * @param {String} bsonType + * @param {Boolean} useBsonType + * @param {Boolean} isRequired + */ + +module.exports = function createJSONSchemaTypeArray(type, bsonType, useBsonType, isRequired) { + if (useBsonType) { + if (isRequired) { + return { bsonType }; + } + return { bsonType: [bsonType, 'null'] }; + } else { + if (isRequired) { + return { type }; + } + return { type: [type, 'null'] }; + } +}; diff --git a/lib/schema.js b/lib/schema.js index 92fbc50b8d..e89cd49c24 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2957,24 +2957,11 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { jsonSchemaForPath.required.push(lastSubpath); isRequired = true; } - const convertedSchemaType = _schemaTypeToJSONSchema(schemaType, isRequired, options); - const { additionalProperties } = convertedSchemaType; - let { bsonType, type } = convertedSchemaType; - - if (bsonType) { - if (!isRequired) { - bsonType = [...bsonType, 'null']; - type = [...type, 'null']; - } - jsonSchemaForPath.properties[lastSubpath] = useBsonType - ? { bsonType: bsonType.length === 1 ? bsonType[0] : bsonType } - : { type: type.length === 1 ? type[0] : type }; - if (schemaType.options.enum) { - jsonSchemaForPath.properties[lastSubpath].enum = isRequired - ? schemaType.options.enum - : [...schemaType.options.enum, null]; - } - Object.assign(jsonSchemaForPath.properties[lastSubpath], additionalProperties); + jsonSchemaForPath.properties[lastSubpath] = schemaType.toJSONSchema(options); + if (schemaType.options.enum) { + jsonSchemaForPath.properties[lastSubpath].enum = isRequired + ? schemaType.options.enum + : [...schemaType.options.enum, null]; } } @@ -2985,115 +2972,6 @@ Schema.prototype.jsonSchema = function jsonSchema(options) { return result; }; -/*! - * Internal helper for converting an individual schematype to JSON schema properties. Recursively called for - * arrays. - * - * @param {SchemaType} schemaType - * @param {Boolean} isRequired - * @param {Object} options - */ - -function _schemaTypeToJSONSchema(schemaType, isRequired, options) { - const useBsonType = options?.useBsonType ?? false; - let bsonType = undefined; - let type = undefined; - let additionalProperties = {}; - - if (schemaType.instance === 'Number') { - bsonType = ['number']; - type = ['number']; - } else if (schemaType.instance === 'String') { - bsonType = ['string']; - type = ['string']; - } else if (schemaType.instance === 'Boolean') { - bsonType = ['bool']; - type = ['boolean']; - } else if (schemaType.instance === 'Date') { - bsonType = ['date']; - type = ['string']; - } else if (schemaType.instance === 'ObjectId') { - bsonType = ['objectId']; - type = ['string']; - } else if (schemaType.instance === 'Decimal128') { - bsonType = ['decimal']; - type = ['string']; - } else if (schemaType.instance === 'Buffer') { - bsonType = ['binData']; - type = ['string']; - } else if (schemaType.instance === 'UUID') { - bsonType = ['binData']; - type = ['string']; - } else if (schemaType.instance === 'Embedded') { - bsonType = ['object'], - type = ['object']; - additionalProperties = schemaType.schema.jsonSchema(options); - } else if (schemaType.instance === 'Array') { - bsonType = ['array']; - type = ['array']; - if (schemaType.schema) { - // DocumentArray - if (useBsonType) { - additionalProperties.items = { ...schemaType.schema.jsonSchema(options), bsonType: ['object', 'null'] }; - } else { - additionalProperties.items = { ...schemaType.schema.jsonSchema(options), type: ['object', 'null'] }; - } - } else { - // Primitive array - const embeddedSchemaType = schemaType.getEmbeddedSchemaType(); - const isRequired = embeddedSchemaType.options.required && typeof embeddedSchemaType.options.required !== 'function'; - const convertedSchemaType = _schemaTypeToJSONSchema(embeddedSchemaType, isRequired, options); - let bsonType = convertedSchemaType.bsonType; - let type = convertedSchemaType.type; - if (!isRequired) { - bsonType = [...bsonType, 'null']; - type = [...type, 'null']; - } - if (useBsonType) { - additionalProperties.items = { ...convertedSchemaType.additionalProperties, bsonType }; - } else { - additionalProperties.items = { ...convertedSchemaType.additionalProperties, type }; - } - } - } else if (schemaType.instance === 'Map') { - bsonType = ['object']; - type = ['object']; - const embeddedSchemaType = schemaType.getEmbeddedSchemaType(); - const isRequired = embeddedSchemaType.options.required && typeof embeddedSchemaType.options.required !== 'function'; - if (embeddedSchemaType.schema) { - if (embeddedSchemaType.instance === 'Array') { - // Map of document arrays - additionalProperties.additionalProperties = useBsonType - ? { bsonType: ['array', 'null'], items: { bsonType: ['object', 'null'], ...embeddedSchemaType.schema.jsonSchema(options) } } - : { type: ['array', 'null'], items: { type: ['object', 'null'], ...embeddedSchemaType.schema.jsonSchema(options) } }; - } else { - // Map of objects - additionalProperties.additionalProperties = useBsonType - ? { ...embeddedSchemaType.schema.jsonSchema(options), bsonType: ['object', 'null'] } - : { ...embeddedSchemaType.schema.jsonSchema(options), type: ['object', 'null'] }; - } - } else { - // Map of primitives - const convertedSchemaType = _schemaTypeToJSONSchema(embeddedSchemaType, isRequired, options); - let bsonType = convertedSchemaType.bsonType; - let type = convertedSchemaType.type; - if (!isRequired) { - bsonType = [...bsonType, 'null']; - type = [...type, 'null']; - } - if (useBsonType) { - additionalProperties.additionalProperties = { bsonType: bsonType.length === 1 ? bsonType[0] : bsonType, ...convertedSchemaType.additionalProperties }; - } else { - additionalProperties.additionalProperties = { type: type.length === 1 ? type[0] : type, ...convertedSchemaType.additionalProperties }; - } - } - } else { - throw new Error(`Cannot convert schema to JSON schema: unsupported schematype "${schemaType.instance}"`); - } - - return { bsonType, type, additionalProperties }; -} - /*! * Module exports. */ diff --git a/lib/schema/array.js b/lib/schema/array.js index a555c308cc..06b1e988cb 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -21,6 +21,7 @@ const isOperator = require('../helpers/query/isOperator'); const util = require('util'); const utils = require('../utils'); const castToNumber = require('./operators/helpers').castToNumber; +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const geospatial = require('./operators/geospatial'); const getDiscriminatorByValue = require('../helpers/discriminator/getDiscriminatorByValue'); @@ -700,6 +701,23 @@ handle.$ne = SchemaArray.prototype._castForQuery; handle.$nin = SchemaType.prototype.$conditionalHandlers.$nin; handle.$in = SchemaType.prototype.$conditionalHandlers.$in; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaArray.prototype.toJSONSchema = function toJSONSchema(options) { + const embeddedSchemaType = this.getEmbeddedSchemaType(); + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return { + ...createJSONSchemaTypeDefinition('array', 'array', options?.useBsonType, isRequired), + items: embeddedSchemaType.toJSONSchema(options) + }; +}; + /*! * Module exports. */ diff --git a/lib/schema/bigint.js b/lib/schema/bigint.js index 4dcebcbd41..474d77461f 100644 --- a/lib/schema/bigint.js +++ b/lib/schema/bigint.js @@ -7,6 +7,7 @@ const CastError = require('../error/cast'); const SchemaType = require('../schemaType'); const castBigInt = require('../cast/bigint'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); /** * BigInt SchemaType constructor. @@ -240,6 +241,19 @@ SchemaBigInt.prototype._castNullish = function _castNullish(v) { return v; }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaBigInt.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('string', 'long', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/boolean.js b/lib/schema/boolean.js index 1cbade08c6..b11162621f 100644 --- a/lib/schema/boolean.js +++ b/lib/schema/boolean.js @@ -7,6 +7,7 @@ const CastError = require('../error/cast'); const SchemaType = require('../schemaType'); const castBoolean = require('../cast/boolean'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); /** * Boolean SchemaType constructor. @@ -290,6 +291,19 @@ SchemaBoolean.prototype._castNullish = function _castNullish(v) { return v; }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaBoolean.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('boolean', 'bool', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/buffer.js b/lib/schema/buffer.js index 4d5c1af7d5..8111956fb9 100644 --- a/lib/schema/buffer.js +++ b/lib/schema/buffer.js @@ -7,6 +7,7 @@ const MongooseBuffer = require('../types/buffer'); const SchemaBufferOptions = require('../options/schemaBufferOptions'); const SchemaType = require('../schemaType'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const handleBitwiseOperator = require('./operators/bitwise'); const utils = require('../utils'); @@ -300,6 +301,19 @@ SchemaBuffer.prototype.castForQuery = function($conditional, val, context) { return casted ? casted.toObject({ transform: false, virtuals: false }) : casted; }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaBuffer.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('string', 'binData', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/date.js b/lib/schema/date.js index 6cbfee8386..6d671f51e5 100644 --- a/lib/schema/date.js +++ b/lib/schema/date.js @@ -8,6 +8,7 @@ const MongooseError = require('../error/index'); const SchemaDateOptions = require('../options/schemaDateOptions'); const SchemaType = require('../schemaType'); const castDate = require('../cast/date'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const getConstructorName = require('../helpers/getConstructorName'); const utils = require('../utils'); @@ -426,6 +427,19 @@ SchemaDate.prototype.castForQuery = function($conditional, val, context) { return handler.call(this, val); }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaDate.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('string', 'date', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/decimal128.js b/lib/schema/decimal128.js index 136529ec04..3c7f3e28ca 100644 --- a/lib/schema/decimal128.js +++ b/lib/schema/decimal128.js @@ -7,6 +7,7 @@ const SchemaType = require('../schemaType'); const CastError = SchemaType.CastError; const castDecimal128 = require('../cast/decimal128'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const isBsonType = require('../helpers/isBsonType'); /** @@ -221,6 +222,19 @@ SchemaDecimal128.prototype.$conditionalHandlers = { $lte: handleSingle }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaDecimal128.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('string', 'decimal', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/documentArray.js b/lib/schema/documentArray.js index 413dc4a8fb..58ecf920cc 100644 --- a/lib/schema/documentArray.js +++ b/lib/schema/documentArray.js @@ -12,6 +12,7 @@ const SchemaDocumentArrayOptions = require('../options/schemaDocumentArrayOptions'); const SchemaType = require('../schemaType'); const cast = require('../cast'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const discriminator = require('../helpers/model/discriminator'); const handleIdOption = require('../helpers/schema/handleIdOption'); const handleSpreadDoc = require('../helpers/document/handleSpreadDoc'); @@ -651,6 +652,23 @@ function cast$elemMatch(val, context) { return cast(schema, val, null, this && this.$$context); } +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaDocumentArray.prototype.toJSONSchema = function toJSONSchema(options) { + const itemsTypeDefinition = createJSONSchemaTypeDefinition('object', 'object', options?.useBsonType, false); + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return { + ...createJSONSchemaTypeDefinition('array', 'array', options?.useBsonType, isRequired), + items: { ...itemsTypeDefinition, ...this.schema.jsonSchema(options) } + }; +}; + /*! * Module exports. */ diff --git a/lib/schema/double.js b/lib/schema/double.js index 79c9475218..23b1f33b38 100644 --- a/lib/schema/double.js +++ b/lib/schema/double.js @@ -7,6 +7,7 @@ const CastError = require('../error/cast'); const SchemaType = require('../schemaType'); const castDouble = require('../cast/double'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); /** * Double SchemaType constructor. @@ -204,6 +205,18 @@ SchemaDouble.prototype.$conditionalHandlers = { $lte: handleSingle }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaDouble.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('number', 'double', options?.useBsonType, isRequired); +}; /*! * Module exports. diff --git a/lib/schema/int32.js b/lib/schema/int32.js index 6838d22f2b..7cf2c364dc 100644 --- a/lib/schema/int32.js +++ b/lib/schema/int32.js @@ -7,6 +7,7 @@ const CastError = require('../error/cast'); const SchemaType = require('../schemaType'); const castInt32 = require('../cast/int32'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const handleBitwiseOperator = require('./operators/bitwise'); /** @@ -246,6 +247,19 @@ SchemaInt32.prototype.castForQuery = function($conditional, val, context) { } }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaInt32.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('number', 'int', options?.useBsonType, isRequired); +}; + /*! * Module exports. diff --git a/lib/schema/map.js b/lib/schema/map.js index 976245a198..c65f21b931 100644 --- a/lib/schema/map.js +++ b/lib/schema/map.js @@ -7,6 +7,8 @@ const MongooseMap = require('../types/map'); const SchemaMapOptions = require('../options/schemaMapOptions'); const SchemaType = require('../schemaType'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); + /*! * ignore */ @@ -74,6 +76,32 @@ class SchemaMap extends SchemaType { getEmbeddedSchemaType() { return this.$__schemaType; } + + /** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + + toJSONSchema(options) { + const useBsonType = options?.useBsonType; + const embeddedSchemaType = this.getEmbeddedSchemaType(); + + const isRequired = this.options.required && typeof this.options.required !== 'function'; + const result = createJSONSchemaTypeDefinition('object', 'object', useBsonType, isRequired); + + if (embeddedSchemaType.schema) { + result.additionalProperties = useBsonType + ? { ...embeddedSchemaType.toJSONSchema(options) } + : { ...embeddedSchemaType.toJSONSchema(options) }; + } else { + result.additionalProperties = embeddedSchemaType.toJSONSchema(options); + } + + return result; + } } /** diff --git a/lib/schema/number.js b/lib/schema/number.js index a5188a81cc..728dfe570b 100644 --- a/lib/schema/number.js +++ b/lib/schema/number.js @@ -8,6 +8,7 @@ const MongooseError = require('../error/index'); const SchemaNumberOptions = require('../options/schemaNumberOptions'); const SchemaType = require('../schemaType'); const castNumber = require('../cast/number'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const handleBitwiseOperator = require('./operators/bitwise'); const utils = require('../utils'); @@ -442,6 +443,19 @@ SchemaNumber.prototype.castForQuery = function($conditional, val, context) { return val; }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaNumber.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = (this.options.required && typeof this.options.required !== 'function') || this.path === '_id'; + return createJSONSchemaTypeDefinition('number', 'number', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/objectId.js b/lib/schema/objectId.js index 927a168df4..6eb0fbed08 100644 --- a/lib/schema/objectId.js +++ b/lib/schema/objectId.js @@ -7,6 +7,7 @@ const SchemaObjectIdOptions = require('../options/schemaObjectIdOptions'); const SchemaType = require('../schemaType'); const castObjectId = require('../cast/objectid'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const getConstructorName = require('../helpers/getConstructorName'); const oid = require('../types/objectid'); const isBsonType = require('../helpers/isBsonType'); @@ -290,6 +291,19 @@ function resetId(v) { return v; } +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaObjectId.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = (this.options.required && typeof this.options.required !== 'function') || this.path === '_id'; + return createJSONSchemaTypeDefinition('string', 'objectId', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/string.js b/lib/schema/string.js index b832dbd988..1e84cac627 100644 --- a/lib/schema/string.js +++ b/lib/schema/string.js @@ -8,6 +8,7 @@ const SchemaType = require('../schemaType'); const MongooseError = require('../error/index'); const SchemaStringOptions = require('../options/schemaStringOptions'); const castString = require('../cast/string'); +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const utils = require('../utils'); const isBsonType = require('../helpers/isBsonType'); @@ -698,6 +699,19 @@ SchemaString.prototype.castForQuery = function($conditional, val, context) { } }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaString.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('string', 'string', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schema/subdocument.js b/lib/schema/subdocument.js index 9a77d82c87..14d2a54834 100644 --- a/lib/schema/subdocument.js +++ b/lib/schema/subdocument.js @@ -12,6 +12,7 @@ const SchemaType = require('../schemaType'); const applyDefaults = require('../helpers/document/applyDefaults'); const $exists = require('./operators/exists'); const castToNumber = require('./operators/helpers').castToNumber; +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const discriminator = require('../helpers/model/discriminator'); const geospatial = require('./operators/geospatial'); const getConstructor = require('../helpers/discriminator/getConstructor'); @@ -396,3 +397,19 @@ SchemaSubdocument.prototype.clone = function() { schematype._appliedDiscriminators = this._appliedDiscriminators; return schematype; }; + +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaSubdocument.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return { + ...this.schema.jsonSchema(options), + ...createJSONSchemaTypeDefinition('object', 'object', options?.useBsonType, isRequired) + }; +}; diff --git a/lib/schema/uuid.js b/lib/schema/uuid.js index 6eb5d2f5ae..bb26415948 100644 --- a/lib/schema/uuid.js +++ b/lib/schema/uuid.js @@ -7,6 +7,7 @@ const MongooseBuffer = require('../types/buffer'); const SchemaType = require('../schemaType'); const CastError = SchemaType.CastError; +const createJSONSchemaTypeDefinition = require('../helpers/createJSONSchemaTypeDefinition'); const utils = require('../utils'); const handleBitwiseOperator = require('./operators/bitwise'); @@ -351,6 +352,19 @@ SchemaUUID.prototype.castForQuery = function($conditional, val, context) { } }; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaUUID.prototype.toJSONSchema = function toJSONSchema(options) { + const isRequired = this.options.required && typeof this.options.required !== 'function'; + return createJSONSchemaTypeDefinition('string', 'binData', options?.useBsonType, isRequired); +}; + /*! * Module exports. */ diff --git a/lib/schemaType.js b/lib/schemaType.js index d57cc775e6..22c9edbd47 100644 --- a/lib/schemaType.js +++ b/lib/schemaType.js @@ -1771,6 +1771,18 @@ SchemaType.prototype.getEmbeddedSchemaType = function getEmbeddedSchemaType() { SchemaType.prototype._duplicateKeyErrorMessage = null; +/** + * Returns this schema type's representation in a JSON schema. + * + * @param [options] + * @param [options.useBsonType=false] If true, return a representation with `bsonType` for use with MongoDB's `$jsonSchema`. + * @returns {Object} JSON schema properties + */ + +SchemaType.prototype.toJSONSchema = function toJSONSchema() { + throw new Error('Converting unsupported SchemaType to JSON Schema: ' + this.instance); +}; + /*! * Module exports. */ diff --git a/test/schema.test.js b/test/schema.test.js index 02cc4fecdc..6e4daf866c 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -3432,7 +3432,10 @@ describe('schema', function() { id: mongoose.ObjectId, decimal: mongoose.Types.Decimal128, buf: Buffer, - uuid: 'UUID' + uuid: 'UUID', + bigint: BigInt, + double: 'Double', + int32: 'Int32' }); assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { @@ -3462,6 +3465,15 @@ describe('schema', function() { uuid: { bsonType: ['binData', 'null'] }, + bigint: { + bsonType: ['long', 'null'] + }, + double: { + bsonType: ['double', 'null'] + }, + int32: { + bsonType: ['int', 'null'] + }, _id: { bsonType: 'objectId' } @@ -3496,6 +3508,15 @@ describe('schema', function() { uuid: { type: ['string', 'null'] }, + bigint: { + type: ['string', 'null'] + }, + double: { + type: ['number', 'null'] + }, + int32: { + type: ['number', 'null'] + }, _id: { type: 'string' } @@ -3524,7 +3545,7 @@ describe('schema', function() { items: { bsonType: ['array', 'null'], items: { - bsonType: ['number'] + bsonType: 'number' } } }, @@ -3594,6 +3615,30 @@ describe('schema', function() { } }); + assert.deepStrictEqual(schema.jsonSchema(), { + required: ['_id'], + type: 'object', + properties: { + name: { + type: ['object', 'null'], + required: ['last'], + properties: { + first: { type: ['string', 'null'] }, + last: { type: 'string' } + } + }, + subdoc: { + type: ['object', 'null'], + properties: { + prop: { + type: ['number', 'null'] + } + } + }, + _id: { type: 'string' } + } + }); + await db.createCollection(collectionName, { validator: { $jsonSchema: schema.jsonSchema({ useBsonType: true }) @@ -3840,8 +3885,8 @@ describe('schema', function() { mixed: mongoose.Mixed }); - assert.throws(() => schema.jsonSchema({ useBsonType: true }), /unsupported schematype "Mixed"/); - assert.throws(() => schema.jsonSchema(), /unsupported schematype "Mixed"/); + assert.throws(() => schema.jsonSchema({ useBsonType: true }), /unsupported SchemaType to JSON Schema: Mixed/); + assert.throws(() => schema.jsonSchema(), /unsupported SchemaType to JSON Schema: Mixed/); }); }); }); From e89339949624ab74e78ad44bdafc4db48aad08ec Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 20 Jan 2025 16:02:33 -0500 Subject: [PATCH 14/15] fix: rename jsonSchema -> toJSONSchema for consistency --- lib/schema.js | 6 ++--- lib/schema/documentArray.js | 2 +- lib/schema/subdocument.js | 2 +- test/schema.test.js | 44 ++++++++++++++++++------------------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/schema.js b/lib/schema.js index e89cd49c24..0204c6cc9c 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2899,16 +2899,16 @@ Schema.prototype._preCompile = function _preCompile() { * #### Example: * const schema = new Schema({ name: String }); * // { required: ['_id'], properties: { name: { type: ['string', 'null'] }, _id: { type: 'string' } } } - * schema.jsonSchema(); + * schema.toJSONSchema(); * * // { required: ['_id'], properties: { name: { bsonType: ['string', 'null'] }, _id: { bsonType: 'objectId' } } } - * schema.jsonSchema({ useBsonType: true }); + * schema.toJSONSchema({ useBsonType: true }); * * @param {Object} [options] * @param [Boolean] [options.useBsonType=false] if true, specify each path's type using `bsonType` rather than `type` for MongoDB $jsonSchema support */ -Schema.prototype.jsonSchema = function jsonSchema(options) { +Schema.prototype.toJSONSchema = function toJSONSchema(options) { const useBsonType = options?.useBsonType ?? false; const result = useBsonType ? { required: [], properties: {} } : { type: 'object', required: [], properties: {} }; for (const path of Object.keys(this.paths)) { diff --git a/lib/schema/documentArray.js b/lib/schema/documentArray.js index 58ecf920cc..77b78fa860 100644 --- a/lib/schema/documentArray.js +++ b/lib/schema/documentArray.js @@ -665,7 +665,7 @@ SchemaDocumentArray.prototype.toJSONSchema = function toJSONSchema(options) { const isRequired = this.options.required && typeof this.options.required !== 'function'; return { ...createJSONSchemaTypeDefinition('array', 'array', options?.useBsonType, isRequired), - items: { ...itemsTypeDefinition, ...this.schema.jsonSchema(options) } + items: { ...itemsTypeDefinition, ...this.schema.toJSONSchema(options) } }; }; diff --git a/lib/schema/subdocument.js b/lib/schema/subdocument.js index 14d2a54834..3afdb8ee28 100644 --- a/lib/schema/subdocument.js +++ b/lib/schema/subdocument.js @@ -409,7 +409,7 @@ SchemaSubdocument.prototype.clone = function() { SchemaSubdocument.prototype.toJSONSchema = function toJSONSchema(options) { const isRequired = this.options.required && typeof this.options.required !== 'function'; return { - ...this.schema.jsonSchema(options), + ...this.schema.toJSONSchema(options), ...createJSONSchemaTypeDefinition('object', 'object', options?.useBsonType, isRequired) }; }; diff --git a/test/schema.test.js b/test/schema.test.js index 6e4daf866c..416502b03a 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -2173,7 +2173,7 @@ describe('schema', function() { const keys = Object.keys(SchemaStringOptions.prototype). filter(key => key !== 'constructor' && key !== 'populate'); const functions = Object.keys(Schema.Types.String.prototype). - filter(key => ['constructor', 'cast', 'castForQuery', 'checkRequired'].indexOf(key) === -1); + filter(key => ['constructor', 'cast', 'castForQuery', 'checkRequired', 'toJSONSchema'].indexOf(key) === -1); assert.deepEqual(keys.sort(), functions.sort()); }); @@ -3344,7 +3344,7 @@ describe('schema', function() { } }, { autoCreate: false, autoIndex: false }); - assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), { required: ['name', '_id'], properties: { _id: { @@ -3363,7 +3363,7 @@ describe('schema', function() { } }); - assert.deepStrictEqual(schema.jsonSchema(), { + assert.deepStrictEqual(schema.toJSONSchema(), { type: 'object', required: ['name', '_id'], properties: { @@ -3385,7 +3385,7 @@ describe('schema', function() { await db.createCollection(collectionName, { validator: { - $jsonSchema: schema.jsonSchema({ useBsonType: true }) + $jsonSchema: schema.toJSONSchema({ useBsonType: true }) } }); const Test = db.model('Test', schema, collectionName); @@ -3414,7 +3414,7 @@ describe('schema', function() { ); const ajv = new Ajv(); - const validate = ajv.compile(schema.jsonSchema()); + const validate = ajv.compile(schema.toJSONSchema()); assert.ok(validate({ _id: 'test', name: 'Taco' })); assert.ok(validate({ _id: 'test', name: 'Billy', age: null, ageSource: null })); @@ -3438,7 +3438,7 @@ describe('schema', function() { int32: 'Int32' }); - assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), { required: ['_id'], properties: { num: { @@ -3480,7 +3480,7 @@ describe('schema', function() { } }); - assert.deepStrictEqual(schema.jsonSchema(), { + assert.deepStrictEqual(schema.toJSONSchema(), { type: 'object', required: ['_id'], properties: { @@ -3531,7 +3531,7 @@ describe('schema', function() { docArr: [new Schema({ field: Date }, { _id: false })] }); - assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), { required: ['_id'], properties: { tags: { @@ -3564,7 +3564,7 @@ describe('schema', function() { await db.createCollection(collectionName, { validator: { - $jsonSchema: schema.jsonSchema({ useBsonType: true }) + $jsonSchema: schema.toJSONSchema({ useBsonType: true }) } }); const Test = db.model('Test', schema, collectionName); @@ -3575,7 +3575,7 @@ describe('schema', function() { await Test.create({ tags: 'javascript', coordinates: [[0, 0]], docArr: [{}] }); const ajv = new Ajv(); - const validate = ajv.compile(schema.jsonSchema()); + const validate = ajv.compile(schema.toJSONSchema()); assert.ok(validate({ _id: 'test', tags: ['javascript'], coordinates: [[0, 0]], docArr: [{ field: '2023-07-16' }] })); assert.ok(validate({ _id: 'test', tags: ['javascript'], coordinates: [[0, 0]], docArr: [{}] })); @@ -3592,7 +3592,7 @@ describe('schema', function() { }, { _id: false }) }); - assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), { required: ['_id'], properties: { name: { @@ -3615,7 +3615,7 @@ describe('schema', function() { } }); - assert.deepStrictEqual(schema.jsonSchema(), { + assert.deepStrictEqual(schema.toJSONSchema(), { required: ['_id'], type: 'object', properties: { @@ -3641,7 +3641,7 @@ describe('schema', function() { await db.createCollection(collectionName, { validator: { - $jsonSchema: schema.jsonSchema({ useBsonType: true }) + $jsonSchema: schema.toJSONSchema({ useBsonType: true }) } }); const Test = db.model('Test', schema, collectionName); @@ -3650,7 +3650,7 @@ describe('schema', function() { await Test.create({ name: { first: 'Mike', last: 'James' }, subdoc: { prop: 42 } }); const ajv = new Ajv(); - const validate = ajv.compile(schema.jsonSchema()); + const validate = ajv.compile(schema.toJSONSchema()); assert.ok(validate({ _id: 'test', name: { last: 'James' }, subdoc: {} })); assert.ok(validate({ _id: 'test', name: { first: 'Mike', last: 'James' }, subdoc: { prop: 42 } })); @@ -3686,7 +3686,7 @@ describe('schema', function() { } }); - assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), { required: ['props', '_id'], properties: { props: { @@ -3746,7 +3746,7 @@ describe('schema', function() { await db.createCollection(collectionName, { validator: { - $jsonSchema: schema.jsonSchema({ useBsonType: true }) + $jsonSchema: schema.toJSONSchema({ useBsonType: true }) } }); const Test = db.model('Test', schema, collectionName); @@ -3783,7 +3783,7 @@ describe('schema', function() { ); const ajv = new Ajv(); - const validate = ajv.compile(schema.jsonSchema()); + const validate = ajv.compile(schema.toJSONSchema()); assert.ok(validate({ _id: 'test', @@ -3829,7 +3829,7 @@ describe('schema', function() { } }); - assert.deepStrictEqual(schema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), { required: ['_id'], properties: { props: { @@ -3844,7 +3844,7 @@ describe('schema', function() { } }); - assert.deepStrictEqual(schema.jsonSchema(), { + assert.deepStrictEqual(schema.toJSONSchema(), { type: 'object', required: ['_id'], properties: { @@ -3866,7 +3866,7 @@ describe('schema', function() { name: { type: String, enum: ['Edwald', 'Tobi'], required: true } }); - assert.deepStrictEqual(RacoonSchema.jsonSchema({ useBsonType: true }), { + assert.deepStrictEqual(RacoonSchema.toJSONSchema({ useBsonType: true }), { required: ['name', '_id'], properties: { name: { @@ -3885,8 +3885,8 @@ describe('schema', function() { mixed: mongoose.Mixed }); - assert.throws(() => schema.jsonSchema({ useBsonType: true }), /unsupported SchemaType to JSON Schema: Mixed/); - assert.throws(() => schema.jsonSchema(), /unsupported SchemaType to JSON Schema: Mixed/); + assert.throws(() => schema.toJSONSchema({ useBsonType: true }), /unsupported SchemaType to JSON Schema: Mixed/); + assert.throws(() => schema.toJSONSchema(), /unsupported SchemaType to JSON Schema: Mixed/); }); }); }); From 57b48f9426d39d8683fc4392939ae2d761574beb Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 20 Jan 2025 16:08:21 -0500 Subject: [PATCH 15/15] types: add toJSONSchema to typescript types --- types/index.d.ts | 2 ++ types/schematypes.d.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/types/index.d.ts b/types/index.d.ts index f1a6b22bad..32554a048b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -508,6 +508,8 @@ declare module 'mongoose' { statics: { [F in keyof TStaticMethods]: TStaticMethods[F] } & { [name: string]: (this: TModelType, ...args: any[]) => unknown }; + toJSONSchema(options?: { useBsonType?: boolean }): Record; + /** Creates a virtual type with the given name. */ virtual>( name: keyof TVirtuals | string, diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index d5d81c7d56..5f364f0cea 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -300,6 +300,8 @@ declare module 'mongoose' { /** Declares a full text index. */ text(bool: boolean): this; + toJSONSchema(options?: { useBsonType?: boolean }): Record; + /** Defines a custom function for transforming this path when converting a document to JSON. */ transform(fn: (value: any) => any): this;