Skip to content

Commit 53b2750

Browse files
authored
Merge pull request #14996 from Automattic/8.8
8.8
2 parents 8e4411d + 6d1eda7 commit 53b2750

22 files changed

+559
-24
lines changed

Diff for: lib/cast.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const ALLOWED_GEOWITHIN_GEOJSON_TYPES = ['Polygon', 'MultiPolygon'];
2828
* @param {Object} [options] the query options
2929
* @param {Boolean|"throw"} [options.strict] Wheter to enable all strict options
3030
* @param {Boolean|"throw"} [options.strictQuery] Enable strict Queries
31+
* @param {Boolean} [options.sanitizeFilter] avoid adding implict query selectors ($in)
3132
* @param {Boolean} [options.upsert]
3233
* @param {Query} [context] passed to setters
3334
* @api private
@@ -372,7 +373,7 @@ module.exports = function cast(schema, obj, options, context) {
372373

373374
}
374375
}
375-
} else if (Array.isArray(val) && ['Buffer', 'Array'].indexOf(schematype.instance) === -1) {
376+
} else if (Array.isArray(val) && ['Buffer', 'Array'].indexOf(schematype.instance) === -1 && !options.sanitizeFilter) {
376377
const casted = [];
377378
const valuesArray = val;
378379

Diff for: lib/helpers/document/applyTimestamps.js

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
'use strict';
2+
3+
const handleTimestampOption = require('../schema/handleTimestampOption');
4+
const mpath = require('mpath');
5+
6+
module.exports = applyTimestamps;
7+
8+
/**
9+
* Apply a given schema's timestamps to the given POJO
10+
*
11+
* @param {Schema} schema
12+
* @param {Object} obj
13+
* @param {Object} [options]
14+
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
15+
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
16+
*/
17+
18+
function applyTimestamps(schema, obj, options) {
19+
if (obj == null) {
20+
return obj;
21+
}
22+
23+
applyTimestampsToChildren(schema, obj, options);
24+
return applyTimestampsToDoc(schema, obj, options);
25+
}
26+
27+
/**
28+
* Apply timestamps to any subdocuments
29+
*
30+
* @param {Schema} schema subdocument schema
31+
* @param {Object} res subdocument
32+
* @param {Object} [options]
33+
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
34+
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
35+
*/
36+
37+
function applyTimestampsToChildren(schema, res, options) {
38+
for (const childSchema of schema.childSchemas) {
39+
const _path = childSchema.model.path;
40+
const _schema = childSchema.schema;
41+
if (!_path) {
42+
continue;
43+
}
44+
const _obj = mpath.get(_path, res);
45+
if (_obj == null || (Array.isArray(_obj) && _obj.flat(Infinity).length === 0)) {
46+
continue;
47+
}
48+
49+
applyTimestamps(_schema, _obj, options);
50+
}
51+
}
52+
53+
/**
54+
* Apply timestamps to a given document. Does not apply timestamps to subdocuments: use `applyTimestampsToChildren` instead
55+
*
56+
* @param {Schema} schema
57+
* @param {Object} obj
58+
* @param {Object} [options]
59+
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
60+
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
61+
*/
62+
63+
function applyTimestampsToDoc(schema, obj, options) {
64+
if (obj == null || typeof obj !== 'object') {
65+
return;
66+
}
67+
if (Array.isArray(obj)) {
68+
for (const el of obj) {
69+
applyTimestampsToDoc(schema, el, options);
70+
}
71+
return;
72+
}
73+
74+
if (schema.discriminators && Object.keys(schema.discriminators).length > 0) {
75+
for (const discriminatorKey of Object.keys(schema.discriminators)) {
76+
const discriminator = schema.discriminators[discriminatorKey];
77+
const key = discriminator.discriminatorMapping.key;
78+
const value = discriminator.discriminatorMapping.value;
79+
if (obj[key] == value) {
80+
schema = discriminator;
81+
break;
82+
}
83+
}
84+
}
85+
86+
const createdAt = handleTimestampOption(schema.options.timestamps, 'createdAt');
87+
const updatedAt = handleTimestampOption(schema.options.timestamps, 'updatedAt');
88+
const currentTime = options?.currentTime;
89+
90+
let ts = null;
91+
if (currentTime != null) {
92+
ts = currentTime();
93+
} else if (schema.base?.now) {
94+
ts = schema.base.now();
95+
} else {
96+
ts = new Date();
97+
}
98+
99+
if (createdAt && obj[createdAt] == null && !options?.isUpdate) {
100+
obj[createdAt] = ts;
101+
}
102+
if (updatedAt) {
103+
obj[updatedAt] = ts;
104+
}
105+
}

Diff for: lib/model.js

+56-8
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const applyReadConcern = require('./helpers/schema/applyReadConcern');
3131
const applySchemaCollation = require('./helpers/indexes/applySchemaCollation');
3232
const applyStaticHooks = require('./helpers/model/applyStaticHooks');
3333
const applyStatics = require('./helpers/model/applyStatics');
34+
const applyTimestampsHelper = require('./helpers/document/applyTimestamps');
3435
const applyWriteConcern = require('./helpers/schema/applyWriteConcern');
3536
const applyVirtualsHelper = require('./helpers/document/applyVirtuals');
3637
const assignVals = require('./helpers/populate/assignVals');
@@ -1220,6 +1221,7 @@ Model.createCollection = async function createCollection(options) {
12201221
*
12211222
* @param {Object} [options] options to pass to `ensureIndexes()`
12221223
* @param {Boolean} [options.background=null] if specified, overrides each index's `background` property
1224+
* @param {Boolean} [options.hideIndexes=false] set to `true` to hide indexes instead of dropping. Requires MongoDB server 4.4 or higher
12231225
* @return {Promise}
12241226
* @api public
12251227
*/
@@ -1440,8 +1442,10 @@ function getIndexesToDrop(schema, schemaIndexes, dbIndexes) {
14401442
*
14411443
* The returned promise resolves to a list of the dropped indexes' names as an array
14421444
*
1443-
* @param {Function} [callback] optional callback
1444-
* @return {Promise|undefined} Returns `undefined` if callback is specified, returns a promise if no callback.
1445+
* @param {Object} [options]
1446+
* @param {Array<String>} [options.toDrop] if specified, contains a list of index names to drop
1447+
* @param {Boolean} [options.hideIndexes=false] set to `true` to hide indexes instead of dropping. Requires MongoDB server 4.4 or higher
1448+
* @return {Promise<String>} list of dropped or hidden index names
14451449
* @api public
14461450
*/
14471451

@@ -1452,23 +1456,32 @@ Model.cleanIndexes = async function cleanIndexes(options) {
14521456
}
14531457
const model = this;
14541458

1455-
const collection = model.$__collection;
1456-
14571459
if (Array.isArray(options && options.toDrop)) {
1458-
const res = await _dropIndexes(options.toDrop, collection);
1460+
const res = await _dropIndexes(options.toDrop, model, options);
14591461
return res;
14601462
}
14611463

14621464
const res = await model.diffIndexes();
1463-
return await _dropIndexes(res.toDrop, collection);
1465+
return await _dropIndexes(res.toDrop, model, options);
14641466
};
14651467

1466-
async function _dropIndexes(toDrop, collection) {
1468+
async function _dropIndexes(toDrop, model, options) {
14671469
if (toDrop.length === 0) {
14681470
return [];
14691471
}
14701472

1471-
await Promise.all(toDrop.map(indexName => collection.dropIndex(indexName)));
1473+
const collection = model.$__collection;
1474+
if (options && options.hideIndexes) {
1475+
await Promise.all(toDrop.map(indexName => {
1476+
return model.db.db.command({
1477+
collMod: collection.collectionName,
1478+
index: { name: indexName, hidden: true }
1479+
});
1480+
}));
1481+
} else {
1482+
await Promise.all(toDrop.map(indexName => collection.dropIndex(indexName)));
1483+
}
1484+
14721485
return toDrop;
14731486
}
14741487

@@ -3563,6 +3576,41 @@ Model.applyVirtuals = function applyVirtuals(obj, virtualsToApply) {
35633576
return obj;
35643577
};
35653578

3579+
/**
3580+
* Apply this model's timestamps to a given POJO, including subdocument timestamps
3581+
*
3582+
* #### Example:
3583+
*
3584+
* const userSchema = new Schema({ name: String }, { timestamps: true });
3585+
* const User = mongoose.model('User', userSchema);
3586+
*
3587+
* const obj = { name: 'John' };
3588+
* User.applyTimestamps(obj);
3589+
* obj.createdAt; // 2024-06-01T18:00:00.000Z
3590+
* obj.updatedAt; // 2024-06-01T18:00:00.000Z
3591+
*
3592+
* @param {Object} obj object or document to apply virtuals on
3593+
* @param {Object} [options]
3594+
* @param {Boolean} [options.isUpdate=false] if true, treat this as an update: just set updatedAt, skip setting createdAt. If false, set both createdAt and updatedAt
3595+
* @param {Function} [options.currentTime] if set, Mongoose will call this function to get the current time.
3596+
* @returns {Object} obj
3597+
* @api public
3598+
*/
3599+
3600+
Model.applyTimestamps = function applyTimestamps(obj, options) {
3601+
if (obj == null) {
3602+
return obj;
3603+
}
3604+
// Nothing to do if this is already a hydrated document - it should already have timestamps
3605+
if (obj.$__ != null) {
3606+
return obj;
3607+
}
3608+
3609+
applyTimestampsHelper(this.schema, obj, options);
3610+
3611+
return obj;
3612+
};
3613+
35663614
/**
35673615
* Cast the given POJO to the model's schema
35683616
*

Diff for: lib/query.js

+45-1
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,38 @@ Query.prototype.select = function select() {
11421142
throw new TypeError('Invalid select() argument. Must be string or object.');
11431143
};
11441144

1145+
/**
1146+
* Enable or disable schema level projections for this query. Enabled by default.
1147+
* Set to `false` to include fields with `select: false` in the query result by default.
1148+
*
1149+
* #### Example:
1150+
*
1151+
* const userSchema = new Schema({
1152+
* email: { type: String, required: true },
1153+
* passwordHash: { type: String, select: false, required: true }
1154+
* });
1155+
* const UserModel = mongoose.model('User', userSchema);
1156+
*
1157+
* const doc = await UserModel.findOne().orFail().schemaLevelProjections(false);
1158+
*
1159+
* // Contains password hash, because `schemaLevelProjections()` overrides `select: false`
1160+
* doc.passwordHash;
1161+
*
1162+
* @method schemaLevelProjections
1163+
* @memberOf Query
1164+
* @instance
1165+
* @param {Boolean} value
1166+
* @return {Query} this
1167+
* @see SchemaTypeOptions https://mongoosejs.com/docs/schematypes.html#all-schema-types
1168+
* @api public
1169+
*/
1170+
1171+
Query.prototype.schemaLevelProjections = function schemaLevelProjections(value) {
1172+
this._mongooseOptions.schemaLevelProjections = value;
1173+
1174+
return this;
1175+
};
1176+
11451177
/**
11461178
* Sets this query's `sanitizeProjection` option. If set, `sanitizeProjection` does
11471179
* two things:
@@ -1689,6 +1721,10 @@ Query.prototype.setOptions = function(options, overwrite) {
16891721
this._mongooseOptions.translateAliases = options.translateAliases;
16901722
delete options.translateAliases;
16911723
}
1724+
if ('schemaLevelProjections' in options) {
1725+
this._mongooseOptions.schemaLevelProjections = options.schemaLevelProjections;
1726+
delete options.schemaLevelProjections;
1727+
}
16921728

16931729
if (options.lean == null && this.schema && 'lean' in this.schema.options) {
16941730
this._mongooseOptions.lean = this.schema.options.lean;
@@ -2222,6 +2258,7 @@ Query.prototype._unsetCastError = function _unsetCastError() {
22222258
* - `strict`: controls how Mongoose handles keys that aren't in the schema for updates. This option is `true` by default, which means Mongoose will silently strip any paths in the update that aren't in the schema. See the [`strict` mode docs](https://mongoosejs.com/docs/guide.html#strict) for more information.
22232259
* - `strictQuery`: controls how Mongoose handles keys that aren't in the schema for the query `filter`. This option is `false` by default, which means Mongoose will allow `Model.find({ foo: 'bar' })` even if `foo` is not in the schema. See the [`strictQuery` docs](https://mongoosejs.com/docs/guide.html#strictQuery) for more information.
22242260
* - `nearSphere`: use `$nearSphere` instead of `near()`. See the [`Query.prototype.nearSphere()` docs](https://mongoosejs.com/docs/api/query.html#Query.prototype.nearSphere())
2261+
* - `schemaLevelProjections`: if `false`, Mongoose will not apply schema-level `select: false` or `select: true` for this query
22252262
*
22262263
* Mongoose maintains a separate object for internal options because
22272264
* Mongoose sends `Query.prototype.options` to the MongoDB server, and the
@@ -4863,6 +4900,9 @@ Query.prototype.cast = function(model, obj) {
48634900
opts.strictQuery = this.options.strictQuery;
48644901
}
48654902
}
4903+
if ('sanitizeFilter' in this._mongooseOptions) {
4904+
opts.sanitizeFilter = this._mongooseOptions.sanitizeFilter;
4905+
}
48664906

48674907
try {
48684908
return cast(model.schema, obj, opts, this);
@@ -4946,7 +4986,11 @@ Query.prototype._applyPaths = function applyPaths() {
49464986
sanitizeProjection = this._mongooseOptions.sanitizeProjection;
49474987
}
49484988

4949-
helpers.applyPaths(this._fields, this.model.schema, sanitizeProjection);
4989+
const schemaLevelProjections = this._mongooseOptions.schemaLevelProjections ?? true;
4990+
4991+
if (schemaLevelProjections) {
4992+
helpers.applyPaths(this._fields, this.model.schema, sanitizeProjection);
4993+
}
49504994

49514995
let _selectPopulatedPaths = true;
49524996

Diff for: lib/schema.js

+10
Original file line numberDiff line numberDiff line change
@@ -2304,6 +2304,7 @@ Schema.prototype.indexes = function() {
23042304
* @param {Boolean} [options.count=false] Only works with populate virtuals. If [truthy](https://masteringjs.io/tutorials/fundamentals/truthy), this populate virtual will contain the number of documents rather than the documents themselves when you `populate()`.
23052305
* @param {Function|null} [options.get=null] Adds a [getter](https://mongoosejs.com/docs/tutorials/getters-setters.html) to this virtual to transform the populated doc.
23062306
* @param {Object|Function} [options.match=null] Apply a default [`match` option to populate](https://mongoosejs.com/docs/populate.html#match), adding an additional filter to the populate query.
2307+
* @param {Boolean} [options.applyToArray=false] If true and the given `name` is a direct child of an array, apply the virtual to the array rather than the elements.
23072308
* @return {VirtualType}
23082309
*/
23092310

@@ -2416,6 +2417,15 @@ Schema.prototype.virtual = function(name, options) {
24162417
return mem[part];
24172418
}, this.tree);
24182419

2420+
if (options && options.applyToArray && parts.length > 1) {
2421+
const path = this.path(parts.slice(0, -1).join('.'));
2422+
if (path && path.$isMongooseArray) {
2423+
return path.virtual(parts[parts.length - 1], options);
2424+
} else {
2425+
throw new MongooseError(`Path "${path}" is not an array`);
2426+
}
2427+
}
2428+
24192429
return virtuals[name];
24202430
};
24212431

Diff for: lib/schema/array.js

+35
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ const SchemaArrayOptions = require('../options/schemaArrayOptions');
1111
const SchemaType = require('../schemaType');
1212
const CastError = SchemaType.CastError;
1313
const Mixed = require('./mixed');
14+
const VirtualOptions = require('../options/virtualOptions');
15+
const VirtualType = require('../virtualType');
1416
const arrayDepth = require('../helpers/arrayDepth');
1517
const cast = require('../cast');
1618
const clone = require('../helpers/clone');
19+
const getConstructorName = require('../helpers/getConstructorName');
1720
const isOperator = require('../helpers/query/isOperator');
1821
const util = require('util');
1922
const utils = require('../utils');
@@ -217,6 +220,12 @@ SchemaArray._checkRequired = SchemaType.prototype.checkRequired;
217220

218221
SchemaArray.checkRequired = SchemaType.checkRequired;
219222

223+
/*!
224+
* Virtuals defined on this array itself.
225+
*/
226+
227+
SchemaArray.prototype.virtuals = null;
228+
220229
/**
221230
* Check if the given value satisfies the `required` validator.
222231
*
@@ -575,6 +584,32 @@ SchemaArray.prototype.castForQuery = function($conditional, val, context) {
575584
}
576585
};
577586

587+
/**
588+
* Add a virtual to this array. Specifically to this array, not the individual elements.
589+
*
590+
* @param {String} name
591+
* @param {Object} [options]
592+
* @api private
593+
*/
594+
595+
SchemaArray.prototype.virtual = function virtual(name, options) {
596+
if (name instanceof VirtualType || getConstructorName(name) === 'VirtualType') {
597+
return this.virtual(name.path, name.options);
598+
}
599+
options = new VirtualOptions(options);
600+
601+
if (utils.hasUserDefinedProperty(options, ['ref', 'refPath'])) {
602+
throw new MongooseError('Cannot set populate virtual as a property of an array');
603+
}
604+
605+
const virtual = new VirtualType(options, name);
606+
if (this.virtuals === null) {
607+
this.virtuals = {};
608+
}
609+
this.virtuals[name] = virtual;
610+
return virtual;
611+
};
612+
578613
function cast$all(val, context) {
579614
if (!Array.isArray(val)) {
580615
val = [val];

0 commit comments

Comments
 (0)