MongoDBにとってのmongooseにあたる、Couchbase Server用ODM(オブジェクトデータモデラー)Ottomanプログラミング入門

## ODMフレームワーク Ottoman.js

### Ottomanとは

Ottoman.jsは、Couchbase ServerとNode.jsのためのODM(オブジェクトデータモデラー)です。
Node.jsアプリケーション開発にCouchbase Serverを利用する際、Ottomanの利用は必ずしも必須ではありませんが、Ottomanは、開発者に様々な恩恵をもたらすことを目的として開発されています。

Ottomanは、MongoDBにおけるMongoose ODMに相当するものであると言えます。

### 機能概要

Couchbase Serverでは、RDBのようにデータに対してスキーマが強制されることはありません。
一方で、アプリケーション開発のためのデータモデル設計において、スキーマは依然として重要な役割を持ちます。

Ottomanは、アプリケーションサイドにおけるスキーマを実現し、スキーマによる検証(バリデーション)実装のフレームワークを提供します。
また、Ottomanは、Couchbase Server SDKのDataサービスAPIやQueryサービスAPIに対する抽象化レイヤーを提供します。Ottomanの提供するクラスやAPIを介して、透過的に、Couchbase Serverとデータのやり取りすることが可能になります。


## サンプルアプリケーション概要

アプリケーションの作成を通じて、Ottomanの機能を紹介します。

### 環境

- Node.js v12.14
- NPM 6.14.8

### データモデル

以下の例では、`Airline`(航空会社)を表すデータ（JSONドキュメント）を用います。

ドキュメントには、電話番号を格納できる配列があり、1つの航空会社に対して複数の電話番号を格納できます。



## 開発を始める

### プロジェクト初期化

Ottomanは、`npm`を使って、インストールすることができます。

以下のコマンドは、ディレクトリを作成し、プロジェクトを初期化し、Ottoman.jsをインストールして、VSCodeで開きます。


```shell
mkdir intro-ottoman && cd $_ && npm init -y && npm i ottoman && touch createAirline.js && code .
```

### Couchbaseへの接続
`createAirline.js`に以下のコードを追加します。


```js
const { Ottoman, model, Schema } = require('ottoman')

const ottoman = new Ottoman({collectionName: '_default'});
ottoman.connect({
    connectionString: 'couchbase://localhost',
    bucketName: 'travel',
    username: 'Administrator',
    password: 'password'
});
```

## スキーマとモデル

モデルは、スキーマ定義からコンパイルされたコンストラクターといえます。
モデルのインスタンスはドキュメントと呼ばれます。
モデルによって、Couchbaseデータベースのドキュメントを透過的に作成、読み取り、更新、および削除することができます。

モデルの作成は、いくつかの要素で構成されています。

### ドキュメントスキーマの定義

スキーマにおいて、キー名がコレクション内のプロパティ名に対応するオブジェクトを介してドキュメントプロパティを定義します。

```js
const airlineSchema = new Schema({
   callsign: String, 
   country: String, 
   name: String 
})
```

ここでは、スキーマ内に3つのプロパティ（コールサイン、国、名前）を定義しています。これらはすべてString型です。値のデータ型がString型でない場合はバリデーションに失敗することになります。

各プロパティにタイプを指定することによって、バリデーションが実現されます。Ottomanでは、次のタイプが利用できます。

- String
- Number
- Array 
- Boolean
- Date
- EmbedType
- MixedType
- ReferenceTypes

### ドキュメントモデルの定義

モデル(コンストラクター)に、名前とスキーマ定義への参照を渡します。


```js
const Airline = ottoman.model('Airline', airlineSchema)
```

`model（）`関数を呼び出すと、スキーマのコピーが作成され、モデルがコンパイルされます。


### カスタムバリデーター

airlineSchemaに電話番号プロパティを追加で指定します。

値が有効な電話番号であることを確認するバリデーション関数を追加します。


```js
const { addValidators } = require('ottoman');

addValidators({
  phone: (value) => {
    const regex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/;
    if (value && !value.match(regex)) {
      throw new Error(`Phone Number ${value} is not valid`)
    }
  },
});

const airlineSchema = new Schema({ 
  callsign: String, 
  country: String, 
  name: String,
  phone: [{ type: String, validator: 'phone'}]
})
```

上記の例は、カスタムバリデーターを作成する方法を示しています。
ここでは、実装として、バリデーターの内部で正規表現を適用しているだけです。
バリデーターの内部には任意のロジックを含めることができます。

正規表現を使用していることを考慮して、コードを削減するための方法を紹介します。

### ビルトインバリデーター

Ottomanに登録（ここでは`ottoman.addValidators()`メソッドで行いました）されたバリデーターは、ドキュメントのプロパティが配列の場合、配列のすべての要素に対して呼び出されます。プロパティが配列でなく、単一の値である場合、バリデーターは1回だけ実行されます。

正規表現が使用されている場合、ドキュメントのプロパティ値を検証する簡単な方法があります。
引数として正規表現とメッセージを取る`ValidatorOption`を使うことができます。


```js
const regx = /^(\([0-9]{3}\)|[0-9]{3}-)[0-9]{3}-[0-9]{4}$/
const airlineSchema = new Schema({
callsign: String,
country: String,
name: String,
phone: [{type: String, validator: {regexp: regx, message: 'phone invalid'}}]
})
```

カスタムバリデーターで行ったことと同じことを実行できます。


## 基本的なドキュメント操作

### ドキュメント作成

スキーマ、モデル、およびバリデーターを作成する、上記ですでに説明したコードを検討します。
モデルを保存してデータベースに永続化するのは非常に簡単です。Schemaを使用して新しいAirlineモデルを作成し、それをデータベースに保存/永続化してみましょう。

```js
// Constructing our document
const cbAirlines = new Airline({
  callsign: 'CBA',
  country: 'United States',
  name: 'Couchbase Airlines',
  phone: ['321-321-3210', '321-123-1234']
})

// Persist the Couchbase Airlines document to Couchbase Server
const saveDocument = async() => {
  try {
    const result = await cbAirlines.save()
    console.log(result)
  } catch (error) {
    throw error
  }
}

// Ensure that all indexes exist on the server
ottoman.start()
  // Next, let's save our document and print a success message 
  .then(async() => {
    saveDocument()
      .then(() => process.exit(0))
      .catch((error) => console.log(error))
  })
```

なぜ`saveDocument()`関数を単独で呼び出さないのか疑問に思われるかもしれません。
代わりに、`ottoman.start()`が終了した後にそれを呼び出します。この`start`メソッドは、`ensureCollections`と`ensureIndexes`を実行するためのショートカットです。

ここで知っておく必要があるのは、このメソッドによって、適切なインデックスがCouchbase Serverに作成されていることを確認している、ということです。

これは、後述する`find()`メソッドのような操作を利用できるようにするために重要です。

この時点で、記述したコードを実行すると、ドキュメントはデータベースに保存されます。


```shell
node createAirline.js
```


この操作の結果：

```json

_Model {
  callsign: 'CBA',
  country: 'United States',
  name: 'Couchbase Airlines',
  phone: [ '321-321-3210', '321-123-1234' ],
  id: '2384568f-f1e9-446e-97d1-cad697c40e76',
  _type: 'Airline'
}
```

コールサイン、国、名前のフィールドはすべて文字列であり、ドキュメントで持つことができる最も基本的な値です。
ここで、`id`フィールドはCouchbase Serverによって自動生成される、一意のキーです。ID値は、`findByID`や`removeByID`のような方法でドキュメントを扱う際に使用します。

電話番号フィールドは配列で表され、有効な電話番号が含まれています。
`_type`フィールドは、ドキュメントの種類を表します（Couchbase 7では、コレクションを使用することができます）。

### 検証エラー

無効な電話番号を入力してこのファイルを再度実行(`node createAirline.js`)すると、次のエラーメッセージが表示されます。

```shell
ValidationError: Phone Number 321-321-32xx is not valid
```

### パッケージ化

接続、スキーマ、およびモデルを別のファイルで定義し、エクスポートして、他のファイルで使用することができます。
`airline-schema-model.js`という名前の新しいファイルを作成し、スキーマとモデル定義をそのファイルに移動します。

```js
const { model, Schema } = require('ottoman')

const regx = /^(\([0-9]{3}\)|[0-9]{3}-)[0-9]{3}-[0-9]{4}$/
const airlineSchema = new Schema({ 
  callsign: String, 
  country: String, 
  name: String,
  phone: [{type: String, validator: {regexp: regx, message: 'phone invalid'}}]
})

// Compile our model using our schema
const Airline = model('Airline', airlineSchema)

exports.airlineSchema = airlineSchema;
exports.Airline = Airline;
```

下記のような、いくつかの新しいファイルを作成していきますが、その中では、次のように、上記のファイルを読み込みます。

- `findAirline.js`
- `updateAirline.js`
- `removeAirline.js`


```js
const { Ottoman } = require('ottoman')
const ottoman = new Ottoman({collectionName: '_default'});
ottoman.connect({
    connectionString: 'couchbase://localhost',
    bucketName: 'travel',
    username: 'Administrator',
    password: 'password'
});

const { Airline } = require('./airline-schema-and-model')
```

これにより、コードの一部を分離して、各ファイルで繰り返さないようにすることができます。各CRUD操作を確認するときに、各ファイルにコードを追加するだけで、スキーマとモデルがインポートされます。

### ドキュメントを探す

先ほどデータベースに保存したレコードを取得してみましょう。モデルクラスは、データベースで操作を実行するためのいくつかの静的メソッドとインスタンスメソッドを公開しています。
ここで、`find`メソッドを使用して以前に作成したレコードを検索し、検索語としてコールサインを渡します。
`findAirline.js`という名前で、新しいファイルを作成して次のコードを追加します。

```js
Find Airline Document by CallsignJavaScript
// Find the Couchbase Airline document by Callsign from Couchbase Server
const findDocument = async() => {
  try {
    Airline.find({ callsign: { $like: 'CBA' } })
    .then((result) => console.log(result.rows));
  } catch (error) {
    throw error
  }
}

ottoman.start()
  .then(async() => {
    findDocument()
      .then(() => process.exit(0))
      .catch((error) => console.log(error))
  })
```

結果。

```json
Query Result:  [
  _Model {
    _type: 'Airline',
    callsign: 'CBA',
    country: 'United States',
    name: 'Couchbase Airlines',
    phone: ['321-321-3210','321-123-1234'],
    id: '971045ac-39d8-4e72-8c93-fdaac69aae31',
  }
```

### ドキュメントの更新

コールサインを使用して上記のレコードを検索してから、変更してみます。コールサインはデータ内の一意のフィールドであると想定します。1回の操作でドキュメントをすべて更新できます。



```js
// Update the Couchbase Airline document by Callsign from Couchbase Server
const findDocumentAndUpdate = async() => {
  const newDocument = {
    callsign: 'CBSA',
    country: 'United States',
    name: 'Couchbase Airways',
    phone: ['321-321-3210','321-123-1234']
  }
  try {
    let result = await Airline.findOneAndUpdate(
      { callsign: { $like: 'CBA' } }, newDocument, { new: true }
    )
    console.log(result)
  } catch (error) {
    throw error
  }
}

ottoman.start()
  .then(async() => {
    findDocumentAndUpdate()
      .then(() => process.exit(0))
      .catch((error) => console.log(error))
  })

```

結果。


```json
_Model {
  _type: 'Airline',
  callsign: 'CBSA',
  country: 'United States',
  id: '971045ac-39d8-4e72-8c93-fdaac69aae31',
  name: 'Couchbase Airways',
  phone: [ '321-321-3210', '321-123-1234' ]
}
```

### ドキュメントの削除

Ottomanには、ドキュメントの削除を処理するいくつかのメソッドがあります。`remove`、`removeById`、`removeMany`です。ここでは、find（）メソッドを使用して既に見つかったドキュメントを削除する方法を示す簡単な例を示します。



```js
// Remove the Couchbase Airline document by ID from Couchbase Server
const removeDocument = async() => {
  try {
    await Airline.removeById('60e3f517-6a2a-41fe-be45-97081181d675')
      .then((result) => console.log(result))
  } catch (error) {
    throw error
  }
}
```

ドキュメントの削除結果は単純なcas値であり、Couchbaseドキュメントの変更を追跡するために使用されます。


```json
{ cas: CbCas { '0': <Buffer 00 00 2e 30 62 db 6c 16> } }
1
{ cas: CbCas { '0': <Buffer 00 00 2e 30 62 db 6c 16> } }
```

## ミドルウェア

ここで、「ミドルウェア」という新しい概念が登場しますが、ミドルウェアの動作はすでに見ています。
最初に作成したバリデーターは、非同期関数の実行中に制御を渡すパイプラインの特定の段階で実行される関数を介してミドルウェアを利用できます。

利用可能なフックとして以下があります。

- `validate`
- `save`
- `update`
- `remove`

#### プリフックとポストフック

フックを使って、ドキュメントの作成（保存）の前後にコンソールでログを生成する例を試してみます。`createWithHooks.js`という名前の新しいファイルを作成します。ほとんどのコードは変わっていませんが、保存前のドキュメント名と保存後のドキュメントIDを出力するフックを含みます。


```js
const { Ottoman } = require('ottoman')
const ottoman = new Ottoman({collectionName: '_default'});
ottoman.connect({
    connectionString: 'couchbase://localhost',
    bucketName: 'travel',
    username: 'Administrator',
    password: 'password'
});

const { Airline, airlineSchema } = require('./airline-schema-and-model')

// Plugins and Hooks are middleware, think lifecycle hooks!
const pluginLog = (airlineSchema) => {
  airlineSchema.pre('save', (doc) => 
    console.log(`Doc: ${doc.name} about to be saved`)
  )
  airlineSchema.post('save', (doc) => 
    console.log(`Doc: ${doc.id} has been saved`)
  )
};

// Our plugin must be registered before the model creation
airlineSchema.plugin(pluginLog)

// Constructing our document
const cbAirlines = new Airline({
  callsign: 'UNITED',
  country: 'United States',
  name: 'United Airlines',
  phone: ['321-321-3210', '321-123-1234']
})

const saveDocument = async() => {
  try {
    // pre and post hooks will run
    const result = await cbAirlines.save()
    console.log(result)
  } catch (error) {
    throw error
  }
}

ottoman.start()
  .then(async() => {
    saveDocument()
      .then(() => process.exit(0))
      .catch((error) => console.log(error))
  })

```

結果。


```json
Doc: United Airlines about to be saved
Doc: 1316488a-98ba-4dbb-b0d7-ea6001a0bf57 has been saved
_Model {
  callsign: 'UNITED',
  country: 'United States',
  name: 'United Airlines',
  phone: [ '321-321-3210', '321-123-1234' ],
  id: '1316488a-98ba-4dbb-b0d7-ea6001a0bf57',
  _type: 'Airline'
}
```

保存の前後にメッセージが出力されました。

## クエリ

Ottomanには、N1QLでサポートされている多くの複雑な操作を処理する豊富なAPIがあります。

`QueryBuilder`はN1QLステートメントを作成します。`QueryBuilder`を使用する場合、3つのオプションがあります。 

- パラメータ
- アクセス機能
- パラメータとアクセス機能を使用

次の3つの例では、3つの異なるモード（パラメーター、アクセス関数、および混合モード）を使用して同じことを行います。


`findWithQueryBuilder.js`という名前の新しいファイルを作成し、次のコードを追加します。



```js
const { Ottoman, Query } = require('ottoman')
const ottoman = new Ottoman({collectionName: '_default'});

ottoman.connect({
    connectionString: 'couchbase://localhost',
    bucketName: 'travel',
    username: 'Administrator',
    password: 'password'
});

/* Replace with QueryBuilder Example */

const executeQuery = async(query) => {
  try {
    const result = await ottoman.query(query)
    console.log('Query Result: ' , result)
  } catch (error) {
    throw error
  }
}

generateQuery()
  .then((query) => {
    executeQuery(query)
      .then(() => process.exit(0))
  })
  .catch((error) => console.log(error))
```


このファイルの中央には、「Replace with QueryBuilder Example」というコメントがあります。上記のオプションを試すには、次の例のいずれかをファイルのその領域にコピーします。

### パラメーター


```js
const generateQuery = async() => {
  try {
    const params = {
      select : [
        { $field: 'name' }, 
        { $field: 'country'}
      ],
      where: { $and: [
        { country: {$eq: 'United States'}},
        { _type: {$eq: 'Airline'}}
      ] },
      limit: 10
    }
    const query = new Query(params, '`travel`').build()
    console.log('Query Generated: ', query)
    return query
  } catch (error) {
    throw error
  }
}
```


### アクセス機能


```js
const generateQuery = async() => {
  try {
    const query = new Query({}, '`travel`')
      .select([
        { $field: 'name' }, 
        { $field: 'country'}
      ])
      .where({ $and: [
        { country: {$eq: 'United States'}},
        { _type: {$eq: 'Airline'}}
      ]})
      .limit(10)
      .build()
      console.log('Query Generated: ', query)
      return query
  } catch (error) {
    throw error
  }
}
```

### ミックスモード


```js
const generateQuery = async() => {
  try {
    const where =  { $and: [
      { country: {$eq: 'United States'}},
      { _type: {$eq: 'Airline'}}
    ] }
    // pass in our query as a condition expression
    const query = new Query({ where }, '`travel`')
      .select([
        { $field: 'name' }, 
        { $field: 'country' }
      ])
      .limit(10)
      .build()
      console.log('Query Generated: ', query)
      return query
  } catch (error) {
    throw error
  }
}

```

上記の3つのモードに共通する結果：


```sql
SELECT name,country FROM default:`travel` WHERE (country="United States" AND _type="Airline") LIMIT 10
```

```json
Query Result:  {
  meta: {
    requestId: '1514fa20-755e-49b3-bbfa-4ed75a1a40ee',
    clientContextId: '0334862c79e727f8',
    status: 'success',
    signature: { country: 'json', name: 'json' },
    profile: undefined,
    metrics: {
      elapsedTime: 6.219,
      executionTime: 5.9619,
      sortCount: undefined,
      resultCount: 2,
      resultSize: 106,
      mutationCount: undefined,
      errorCount: undefined,
      warningCount: undefined
    }
  },
  rows: [
    { country: 'United States', name: 'United Airlines' },
    { country: 'United States', name: 'Jet Blue Airlines' }
  ]
}
```


### 参考情報


[Couchbaseブログ Introduction to Ottoman With Couchbase](https://blog.couchbase.com/introduction-to-ottoman-with-couchbase/)


https://github.com/couchbaselabs/try-ottoman/blob/master/src/hotels/hotels.model.js

https://ottomanjs.com/#installation

https://ottomanjs.com/guides/schema.html

https://github.com/couchbaselabs/node-ottoman