# Mongoose
###### tags: `MongoDB`
Mongoose對MongoDB的關係就相當於Express對nodejs
前者可以讓我們更方便使用後者的功能

[Mongoose doc](https://mongoosejs.com/docs/guide.html)
## Schema
資料庫的model會以schema的格式為原型來建立
大致上的用法是這樣,更多設定能參考Mongoose的文件
```javascript=
const tourSchema = new mongoose.Schema({ // 建立schema
name: {
type: String,
required: true,
unique: true
},
price: {
type: Number,
required: true
},
rating: {
type: Number,
default: 4.5
},
pictures: [String] //String陣列
})
const Tour = mongoose.model('Tour', tourSchema) // 以tourSchema為原型建立Tour model
```
### virtual property
有時候同樣的資料要使用不同單位時,我們不希望這項資料再占一格schema的欄位,這時候可以用vitual property來計算這些需要轉換的資料,定義方法如下
```javascript=
// tourModel.js
tourSchema.virtual('durationWeeks').get(function() {
return this.duration / 7 // 這樣就能把duration/7 存到 durationWeeks這個virtual property裡面了
})
```
之後再倒schema的部分傳入option
```javascript=
const tourSchema = new mongoose.Schema({
// definition
},
{ // option
toJSON: { virtuals: true }, // 在JSON格式輸出時顯示virtual property
toObject: { virtuals: true } // 在Object格式輸出時顯示virtual property
})
```
## 新增資料
<font color='red'>不在schema規則下的資料都會被忽略</font>
```javascript=
const createTour = new Tour({
name: 'The Forest Hiker',
price: 123,
rating: 4.8
})
createTour.save().then(res =>{ // .save()會回傳一個promise
console.log(res) // 顯示新增的資料
}).catch(err => console.log(err)) // 錯誤處理
```
### mongoose CRUD
[Query指令](https://mongoosejs.com/docs/queries.html)
## 搭配express操作資料庫
### filter 操作語法
這邊會介紹find()搭配express的req.query用法
req.query會記錄透過網址傳進來的參數
postman上也會顯示這些參數

在server端console.log(req.query)的結果會像這樣
```
{ a: '123', b: '546', c: '789' }
```
這個能夠搭配find()來查詢資料庫中特定條件的資料
例如我這樣post

```javascript=
const allTours = await Tour.find(req.query)
// 相當於
// const allTours = await Tour.find({
// ratingsAverage: '4.5',
// difficulty: 'easy'
// })
```
<font color='red'>題外話</font>
find()查詢除了搭配mongodb原生的query語法外,在mongoose上還能有比較好理解的寫法
能將條件直接串在find()後面
```javascript=
const allTours = await Tour.find().where('difficulty').equals('easy').where('price').gte(500)
```
### 進階filter操作
如果要使用req.query還要能搭配{$gte}、{$lte}等語法進行查詢的話要怎麼做呢?一開始可能會想到直接把$塞到url裡,但這是行不通的,因為url不收
這時候要把gte、lte等字放在[]裡
```
http://127.0.0.1:8000/api/v1/tours/?duration[gte]=55&difficulty=easy&page=2
```
這樣出來的req.query就會是這樣
```
{ duration: { gte: '5' }, difficulty: 'easy', page: '2' }
```
之後再透過JSON.stringify和replace()等操作把$放入
最後在將加工完的query物件放到find()裡面進行查詢
```javascript=
exports.getAllTours = async(req, res) => {
try {
console.log(req.query)
const queryObj = {...req.query} // 用解構方法產生一個新物件放入新的變數做操作,才不會改到原來的req.query
const excludeField = ['page', 'limit', 'sort', 'field']
excludeField.forEach(item => delete queryObj[item]) // 過濾無效的關鍵字
let queryStr = JSON.stringify(queryObj) //
queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/, match => `$${match}`) // replace放入$符號
console.log(queryStr) // 查看replace後的字串
console.log(JSON.parse(queryStr)) // 查看query物件轉換結果
const query = Tour.find(JSON.parse(queryStr)) // 將換換後的query物件放回find()查詢
const allTours = await query // 返回查詢結果
// const allTours = await Tour.find().where('difficulty').equals('easy').where('price').gte(500)
res.status(200).json({
status: 'success',
results: allTours.length,
data: allTours
})
} catch (error) {
res.status(400).json({
status: 'failed',
data: error
})
}
}
```
### Sort
query本身就有一個sort方法可以使用,只要在後面接字串就會自動以該字串升冪排列資料,在字串前加-則會變降冪,動以該字串升冪排列資料,在字串前加-則會變降冪,若有多組串在一起則會對前一種結果相同的資料作排列
例如req.query.sort('price -days -rating')
會先按照price升冪排列再將price相同的資料以days做比較降冪排列,若days還有相同的資料再以rating做比較作降冪排列
一般sort在url會這樣子傳
```
http://127.0.0.1:8000/api/v1/tours/?sort=-price,-duration
```
所以要透過以下方法將req.query中的sort進行重組變成純字串,中間用空格隔開的形式
```javascript=
if(req.query.sort) {
query.sort(req.query.sort.split(',').join(''))
} else {
query.sort('-createdAt')
}
```
### field limiting (只拿特定資訊)
通常一個資料都會包含很多資訊,而大部份的資訊可能使用者都不會用到,這時候送到client端的資料就能做field limit的處理
例如資料內只需要包含名字、價錢、分數就可以透過這個方法來找
field limit是透過select方法來達成,而select接的參數形式跟sort一樣
例如使用者只需要name,duration,difficulty,price的資料,url可以這樣傳
```
http://127.0.0.1:8000/api/v1/tours/?fields=name,duration,difficulty,price
```
```javascript=
if(req.query.fields) {
query.select(req.query.fields.split(',').join(' '))
} else {
query.select('-__v') // 前面加一個-代表不要這個資料
}
```
回來的結果就會像這樣,只有name,duration,difficulty,price (id是為了確保資料獨特性 無法消掉)
```
"data": [
{
"_id": "6045c3aa8c25544b48eb80df",
"name": "The Forest Hiker",
"duration": 5,
"difficulty": "easy",
"price": 397
},
]
```
除了使用這招,還能夠直接在schema設定特定資料能不能被抓出來
只要加上select: false,該項就不會在查詢的時候被顯示出來
```javascript=
const tourSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'A tour needs a name'],
unique: true
select: false
}
})
```
### pagination(分頁)
分頁是很重要的功能,可以降低client端和server端的負擔,因為一次給大量資料client端會吃不消,而server端也會需要處理很多資料,分頁最好都是由後端處理
雖然前端也能處理,不過前端處理的前提是建立在已經收了大量資料的情況下,這是個不太好的選擇
pagination會用到skip()及limit()功能
skip()接收的參數代表跳過多少筆資料
limit()接收的參數代表一頁顯示多少資料
例如我要分10筆資料為一頁
第一頁會是skip(0).limit(10)
第二頁是skip(10).limit(10)
第三頁是skip(20).limit(10)
```javascript=
const page = req.query.page * 1 || 1;
const limit = req.query.limit * 1 || 100;
const skip = (page - 1) * limit;
query = query.skip(skip).limit(limit);
if(req.query.page) {
const numTours = await Tour.countDocuments() // 取得document數量
if(skip >= numTours) {
throw new Error(`page doesn't exist`)
}
}
```
呼叫時的query寫法是api/?page=x&limit=y
x是頁數 y是一頁要幾筆資料
### Aliasing (別名)
是一個middleware的用法,有些網站可能會有針對前幾名評價的商品做的功能,這些功能會用到的資料就適合使用Aliasing來抓
```javascript=
// controller.js
exports.aliasTopFive = (req, res, next) => {
req.query.limit = 5
req.query.sort = '-ratingsAverage,price'
req.query.fields = 'name,price,ratingsAverage,summary,difficulty'
next()
}
```
```javascript=
// router.js
router
.route('/top5-tour')
.get(tourController.aliasTopFive, tourController.getAllTours)
```
這會在使用者訪問/top5-tour的時候先經過aliasTopFive這個middleware幫他的req.query加上找前五評價會使用的參數再訪問getAllTours進行過濾直接抓前五名出來
### Model中的資料驗證
#### 內建
string可以在schema中設定最大和最小字數限制
```javascript=
// myModel.js
const mySchema = mongoose.new Schema({
name: {
type: String,
unique: true,
required: [true, 'name is required']
trim: true,
maxlength: [40, `can't set a name with 40+ characters`],
minlength: [10, `can't set a name with 10- characters`]
}
})
module.exports = mySchema
```
這些只會在創立資料時生效,若要在update時也生效
需要到update的程式碼設定option
```javascript=
// myController.js
const myDoc = require('../model/myModel')
exports.myUpdate = async(req, res) => {
try{
const newDoc = await myDoc(req.params.id, req.params.body, {
new: true,
runValidators: true // update時也執行Validators
})
}
}
```
string能設定接收指定字串
```javascript=
// myModel.js
const mySchema = mongoose.new Schema({
name: {
type: String,
unique: true,
required: [true, 'name is required']
trim: true,
maxlength: [40, `can't set a name with 40+ characters`],
minlength: [10, `can't set a name with 10- characters`]
},
difficulty: {
type: String,
enum: { // 加上enum屬性可以設定只接收特定字串的validator
values: ['easy', 'medium', 'difficult'],
message: `only accept 'easy', 'medium', 'difficult' as difficulty`
}
}
})
module.exports = mySchema
```
#### 自訂
有些值在驗證的時候可能會和其他的值有關聯,例如discount的金額不能超過原價,這種需要包含其他值一起做的驗證就要自己寫
```javascript=
// myModel.js
const mySchema = mongoose.new Schema({
price: {
type: Number,
required: true
},
discountPrice: {
type: Number,
validate: {
validator: function(val) {
// this points to currnet doc when creating
return val < this.price
},
message: `discount({VALUE}) shouldn't be greater than the original price`
} // {VALUE}是mongoose的模板語法,可以自動帶入validator中val的值
}
})
module.exports = mySchema
```
#### 第三方驗證
這邊以[validator.js](https://www.npmjs.com/package/validator)做示範
```javascript=
// myModel.js
const validator = require('validator')
const tourSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'A tour needs a name'],
unique: true,
trim: true,
maxlength: [40, 'Name should contain less or equal to 40 characters'],
minlength: [10, 'Name should contain more or equal to 10 characters'],
validate: [validator.isAlpha, 'can only contain characters']
}
}
)
```
## Geospatial data
mongoose提供的儲存地理資訊的方法,在schema中設定
```javascript=
// tourModel.js
//
//***other settings***
startLocation: {
// GeoJSON
type: {
type: String,
default: 'Point',
enum: ['Point']
},
coordinates: [Number],
address: String,
description: String
},
locations: [
{
type: {
type: String,
default: 'Point',
enum: ['Point']
},
coordinates: [Number],
address: String,
description: String
}
]
//***other settings***
```
### Geospatial queries
[官網文件](https://docs.mongodb.com/manual/reference/operator/query-geospatial/index.html)
## Referencing in mongoose
例如今天想在tour的資料中加入嚮導資料,最好的方法就是透過userID來連接該嚮導這時候可以在schema中這樣定義嚮導資料
```javascript=
// tourModel.js
//
//
//***other settings***
guides: [
{
type: mongoose.Schema.ObjectId, // 格式設定為schema物件的ID
ref: 'User'
// ref設定為User,會在後續操作時透過ID連接並加入該使用者在User collection中的資料
// (這邊的字串是看你該model是用什麼名字export出來的)
// 例如我在userModel寫const User = mongoose.model('User', userSchema)
// 這邊就是用'User'
}
]
//***other settings***
```
透過API將guides資料帶入使用者ID測試新增一個tour

確認ID有被正確存入guides
之後在get tour的地方測試ref:'User'的威力
```javascript=
// tourController.js
exports.getTour = catchAsync(async (req, res, next) => {
const aTour = await Tour.findById(req.params.id , (err) => {
if(err) return next(new ApiError('ID not found', 404))
}).populate('guides') // populated('guides') 可以依照guides中的ref將使用者資料帶入guides中
res.status(200).json({
status: 'success',
data: aTour
})
})
```
觸發get tour測試

看到確實有將該id的user資料帶入了
這邊同樣能夠帶入select來過濾要顯示的屬性
```javascript=
// tourController.js
exports.getTour = catchAsync(async (req, res, next) => {
const aTour = await Tour.findById(req.params.id , (err) => {
if(err) return next(new ApiError('ID not found', 404))
}).populate({
path: 'guides',
select: '-__v -passwordChangedAt'
}) // populated('guides') 可以依照guides中的ref將使用者資料帶入guides中
res.status(200).json({
status: 'success',
data: aTour
})
})
```
如果要在所有帶有userID的資料帶進使用者資料,可以直接在query middleware做populate的處理
```javascript=
// tourModel.js
tourSchema.pre(/^find/, function (next) {
this.populate({
path: 'guides',
select: '-__v -passwordChangedAt'
})
next()
})
```
這樣就不用在controller一個一個加了
## Virtual populate
一些網站中會看到在一個商品底下有很多相關的評價訊息,在資料庫中是如何處理這種訊息呢?
可能會想到用populate將評價透過ID帶入資料庫的作法,但這種做法會將評價的ID通通存在商品的document中,網站規模較小的話似乎是可行的辦法,但在規模較大的網站中,評價一多的情況下可能會讓這個document容量爆掉,這時候就需要用到virtual populate
### 做法
例如想在每個tour中帶入相關review
```javascript=
// tourModel.js
tourSchema.virtual('reviews', {
ref: 'Review', // 來源的Model
foreignField: 'tour', // tour id 連接到review中的field
localField: '_id' // tour id在tour model中的field
})
```
可以對照一下tour在reviewModel中的ref
```javascript=
// reviewModel.js
tour: {
type: mongoose.Schema.ObjectId,
ref: 'Tour',
required: [true, 'review must belong to a tour']
}
```
<font color='red'>一個reivew只對到一個tour,一個tour能對到多個review,為了預防tour爆炸可以選擇virtual populate來實現這種功能</font>
## Improve reading performance
### Index
現在tour中總共有9筆資料
現在下一個filter來抓price低於1000的tour

會看到result有三筆資料
在來將filter的query後面加一個explain()方法可以看到詳細資訊
```javascript=
exports.getAll = model => catchAsync(async (req, res, next) => {
// to allow nested get reviews on tour(small hack)
let filter = {}
if (req.params.tourId) filter = { tour: req.params.tourId }
// result
const features = new APIfeatures(model.find(), req.query)
.filter()
.sort()
.limit()
.paginate()
const doc = await features.query.explain()
// const allTours = await Tour.find().where('difficulty').equals('easy').where('price').gte(500)
res.status(200).json({
status: 'success',
results: doc.length,
data: doc
})
})
```
再抓一次資料看一下executionStats發現nReturned是3,totalDocsExamined是9
意思是執行返回了3筆資料,總共跑了9筆
代表filter的動作會把所有資料都跑一遍
這時候可以設定index來改善filter的效能
例如我想在tour中加入price的index就加上這個
```javascript=
// tourModel.js
tourSchema.index({price: 1})
```
這麼一來在對price進行filter的時候就會先將index進行排序再找,就不會將每一筆都跑完了
對price加上index後再發送一次測試

回傳三筆資料只跑了三次
index可以在compass的介面中看到

要刪除也是在這邊刪
<font color='red'>在schema中被設定為unique的資料會被自動加進index,如果有加錯的記得一定要來這裡刪掉,不然那個unique就會一直卡在這裡,可能會造成一些問題(例如註冊的時候一直說有key duplicate,但你的schema根本就沒那個key,這時候就要來這裡檢查一下是不是以前有動到unique的東西還卡在這)</font>
### Compound index
```javascript=
tourSchema.index({price: 1, reatingsAverage: 1})
```
### Index 使用與否
由於index會隨著資料庫的資料數量變多,比較好的用法是只設定在比較常被讀取的屬性,常被寫入的屬性其實不需要加入index
## Calculating average number
### 新增review時計算
在評論功能中會打分數,這裡要示範如何算出所有review的平均分數並存入該tour的document中
1. 在reviewModel中定義一個靜態方法使用aggregatie來算出平均值
```javascript=
// reviewModel.js
const Tour = require('./tourModel') // 引入Tour才能進行儲存
reviewSchema.statics.calcAverageRatings = async function (tourId) {
const stats = await this.aggregate([
{
$match: {
tour: tourId // select the tour to update
}
},
{
$group: {
_id: '$tour',
nRating: { $sum: 1 },
avgRating: { $avg: '$rating' }
}
}
])
console.log(stats) // 查看計算結果
await Tour.findByIdAndUpdate(tourId, { // 將結果存入Tour
ratingsQuantity: stats[0].nRating,
ratingsAverage: stats[0].avgRating
})
}
```
2. 定義一個middleware在review被存入後呼叫計算平均值的方法
```javascript=
reviewSchema.post('save', function () {
// this point to current review
this.constructor.calcAverageRatings(this.tour) // 使用this.constructor存取Review,因為在呼叫這個函數時 Review實體還沒建立,所以要用這個方法使用Review中的函數
})
```
### 在review被刪掉或被修改時計算
正常情況下findAndUpdate和findByAndDelete這類方法是不會觸發document中的middleware的,這時候需要做一下手腳
```javascript=
reviewSchema.pre(/^findOneAnd/, async function (next) {
this.r = await this.findOne() // 先將query存到this.r以便在下一個middleware中
next()
})
reviewSchema.post(/^findOneAnd/, async function () {
//this.r = await this.findOne() doesn't work here, cuz query has been executed
await this.r.constructor.calcAverageRatings(this.r.tour._id) // 將tourId帶入修改平均值和評論數
})
```
最後再回到calcAverageRatings對空stats的情況做處理(以防刪光出錯)
```javascript=
reviewSchema.statics.calcAverageRatings = async function (tourId) {
const stats = await this.aggregate([
{
$match: {
tour: tourId // select the tour to update
}
},
{
$group: {
_id: '$tour',
nRating: { $sum: 1 },
avgRating: { $avg: '$rating' }
}
}
])
console.log(stats)
// stats若為空陣列就使用預設值
if (stats.length > 0) {
await Tour.findByIdAndUpdate(tourId, {
ratingsQuantity: stats[0].nRating,
ratingsAverage: stats[0].avgRating
})
} else {
await Tour.findByIdAndUpdate(tourId, {
ratingsQuantity: 0,
ratingsAverage: 4.5
})
}
}
```
## Prevent duplicate reviews
防止同一個user對同一個tour發送複數review
使用compound index設定
```javascript=
reviewSchema.index({ tour: 1, user: 1 }, { unique: true })
//設定unique代表同個組合只能出現一次
```