---
# System prepended metadata

title: Mongoose
tags: [MongoDB]

---

# Mongoose
###### tags: `MongoDB`
Mongoose對MongoDB的關係就相當於Express對nodejs
前者可以讓我們更方便使用後者的功能
![](https://i.imgur.com/X8eIrWU.png)
[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上也會顯示這些參數
![](https://i.imgur.com/zH4kV5K.png)
在server端console.log(req.query)的結果會像這樣
```
{ a: '123', b: '546', c: '789' }
```
這個能夠搭配find()來查詢資料庫中特定條件的資料
例如我這樣post
![](https://i.imgur.com/I7ixUdJ.png)
```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
![](https://i.imgur.com/MaHqQu4.png)
確認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測試
![](https://i.imgur.com/ay7UHz4.png)
看到確實有將該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
![](https://i.imgur.com/ntUU1GR.png)
會看到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後再發送一次測試
![](https://i.imgur.com/Ze3Y5Q6.png)
回傳三筆資料只跑了三次

index可以在compass的介面中看到
![](https://i.imgur.com/WceBFDB.png)
要刪除也是在這邊刪
<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代表同個組合只能出現一次
```