【MongoDB】MongoDB AtlasとPyMongoの初歩的な備忘録

本記事は、MongoDB AtlasとPythonの接続方法をさらっと紹介し、PyMongoのよく使いそうな関数をまとめるといった備忘録になります。メモ書きを記事にしたといった方が簡潔な説明ですねw。
なお、MongoDB Atlasにログインするなどの工程はこちらでは紹介しません。

今回の記事を作成するにあたって、以下の文献を参考にしました。

* [MongoDB公式ドキュメント](https://www.mongodb.com/docs/)
* [PyMongo公式ドキュメント](https://pymongo.readthedocs.io/en/stable/tutorial.html)

# 1- MongoDB Atlasの操作

説明は[こちら](https://www.mongodb.com/docs/atlas/getting-started/)からになります。（コマンドラインツールの方法はいつか機会があれば。）

## 1-1 MongoDB Atlasとは

> MongoDB Atlas provides an easy way to host and manage your data in the cloud. This tutorial guides you through creating an Atlas cluster, connecting to it, and loading sample data.

→クラウド上でデータをホストおよび管理する方法を提供するサービス。

> You can get started with Atlas through the 
Atlas CLI or the Atlas User Interface.

→AtlasのUIでの方法を紹介。

## 1-2 MongoDB Atlasの接続情報取得まで

1. Atlasアカウントを作成し、登録。Atlasにログインする。
2. フリーのクラスターを作成し、デプロイする。
3. IPホワイトリストに自分のIPアドレスを追加する。
4. 手順2.で作成したクラスターにアクセスするためのユーザーを作成する。
5. 接続情報を取得する。 

上記箇条書きの内容について、以下補足。

手順2.について（初めてクラスターを作成する場合）：
* 「Data Services」タブの「DEPLOYMENT>Database」にアクセスし、画面右上の「+ Create」ボタンを押下。
* Cloud Providerを選択。（AWS、Google Cloud、Azureが選択可能）
* Regionを選択。（ASIA>TokyoまたはASIA>Osakaなど）
* Clusterの詳細設定を選択。（必要があれば。）
* 上記内容で設定し、問題なければ「Create Cluster」を押下。（無料枠であることを念のため確認。）
* クラスターの作成が始まる。

※無料枠のクラスターが作成済みの状態で手順2.を実施すると有料であることを目の当たりにするので注意。

手順3.について：
* 「Data Services」タブの「SECURITY>Network Access」にアクセスし、「IP Access List」の「+ ADD IP ADDRESS」ボタンを押下。
* モーダル画面が表示されるので、クラスターにアクセスして問題ないIPアドレスを入力し、「Confirm」ボタンを押下。
* IPリストの変更・削除は同タブ内で実施可能。

手順4.について：
* 「Data Services」タブの「SECURITY>Database Access」にアクセスし、「Database Users」タブの「+ ADD NEW DATABASE USER」ボタンを押下。
* 「Authentication Method」を選択。（Password、Certificate、AWS IAMが選択可能。本記事ではCertificateを選択した場合を想定し、紹介する。）
* 「Common Name」にユーザー名を入力する。
* 「Download certificate when user is added」を「クリック」する。（作成時にクリックしなくても後ほどファイルの取得は可能）
* 「Select the certificate expiration for this user」で「certificate」の有効期限を選択。（3カ月，6か月，12カ月，24カ月を選択可能。今回は3カ月を選択。）
* 「Database User Privileges>Built-in Role」からユーザーの権限を選択。（Atlas admin、Read and write to any database、Only read any databaseが選択可能。データベースの読み書きの権限が必要なのでRead and write to any databaseを選択。）
* 「Add User」ボタンを押下。
* 同タブ内で作成したユーザーが表示されていることを確認。（具体的にはAuthentication MethodとMongoDB Rolesを確認。）また、CertificateKeyが記されたファイルがダウンロードされていることも確認。（接続に必要な情報です。もし、ファイルのダウンロードができなかった場合は、同一ユーザーのファイル有効期限を再度設定し、ファイルをダウンロードしてください。）

手順5.について：
* 「Data Services」タブの「DEPLOYMENT>Database」にアクセスし、作成済みのクラスターを確認。
* 「Connect」ボタンを押下し、「Connect to your application>Drivers」を押下。（ほかにCompassやShellなどのアクセス方法がありますが、興味があればそちらを試してみるとよいかと思います。）
* DriverおよびVersionを選択する。（driverはPythonのほかにJava、Node.js、PHPなどが選択可能。versionは最新のものが推奨されている。）
* Install your driverでは最新のdriverを選択した場合`python -m pip install pymongo`と表示されていると思うので、これをコマンドラインで実行し、インストール。
* 接続情報にユーザーの検証情報を使用する。手順4.で検証方法を「Certificate」にした場合は、「X.509」にチェックをつける。（「Password」にした場合は、接続情報にユーザー名とパスワードを入力する必要があるので注意。）
* 接続情報が記された「mongodb+srv://....」で始まる文字列をメモする。

Atlas上で行う操作は以上になります。
なお、「Data Services」タブのDEPLOYMENT>Database>Browse Collections」でDatabaseおよびCollections、Documentの作成が可能なのでUI上で操作したい場合はこちらから試してみるのもありです。
ちなみに、MongoDBとRDB（MySQL、PostgreSQLなど）の概念の対応表は次の通り：

| MongoDB    | RDB      |
|:------------:|:------------:|
| *Database*   | *Database* |
| *Collection* | *Table*    |
| *Document*   | *Record*   |

# 2 MongoDB AtlasとPythonの接続

MongoDB Atlasでの作業後、Pythonにはすでに`pymongo`がインストールされていることを前提に説明します。インストールされていない場合は、以下コマンドを実行してください：

```python:pymongoのインストール
python -m pip install pymongo
```

## 2-1 接続テスト

工程1でダウンロードした`.pem`ファイルおよび`mongodb+srv://...`で始まる接続文字列が必要になります。この情報が無ければ以降の手順は実施できないので注意してください。

次のようなファイル構造を想定して紹介します：

```txt:ファイル構造
C:\TEST
├─ test.py (テスト用のpythonファイル)
└─ XXX.pem (ユーザー情報)
```

`test.py`を以下のように作成します：

```python: test.py
from pymongo import MongoClient

# 以下uriに格納する値が接続文字列。
uri = "mongodb+srv://<CONNECTION_STRINGS>"
client = MongoClient(uri,tls=True,tlsCertificateKeyFile='<PATH_TO_CERTIFICATE>')

db = client['testDB']
collection = db['testCol']
doc_count = collection.count_documents({})
print(doc_count)
```

上記コードを実行し、`0`という値が表示されれば接続成功です。エラーが生じた場合は

 * `pymongo`がインストール済みか
 * `uri`の値が正しいか
 * `<PATH_TO_CERTIFICATE>`の指定は正しいか

といったことを確認してください。
ちなみに上記コードを簡単に説明すると、`pymongo.MongoClient`でMongoDBへの接続を実施。`client`はMongoDBのクラスターに対してDatabaseの作成や削除といったアクションを行うことが可能です。
例えば、`client['testDB']`はクラスター内に`testDB`という名前でDatabaseを作成し、`db['testCol']`は`testDB`内に`testCol`という名前のCollectionを作成します。`collection.count_documents({})`はCollection内のDocumentの数を計上しており、現在、Documentはなにも追加していないはずなので、`doc_count=0`となります。

## 2-2 CertificateKeyFileについて

接続テストでは接続文字列をコードにべた書きしていますが、実際の運用を考えればあまり好ましい書き方ではありません。そこでPython-dotenvを紹介します。

https://pypi.org/project/python-dotenv/

このPythonモジュールは`.env`ファイルの値をkeyとvalueのペアで読み込むことができ、さらに環境変数として設定することも可能です。
このモジュールをインストールするには次のコードを実行します：

```python
pip install python-dotenv
```

まずは`.env`ファイルを配置します：

```txt:ファイル構造
C:\TEST
├─ test.py (テスト用のpythonファイル)
├─ XXX.pem (ユーザー情報)
└─ .env (環境ファイル)
```

そして、ファイルの中身を次のように設定します：

```.env（テストファイル）
PASSWORD='AIUEO'
USERNAME='TEST_USER'
MONGODB_URI='mongodb+srv://<CONNECTION_STRINGS>'
MONGODB_URI_PASSWORD='mongodb+srv://${USERNAME}:${PASSWORD}@<CONNECTION_STRINGS>'
```

ここで、`${KEY}`はプレースホルダーとしての役割を持ち、例えば、`KEY=VALUE`と定義すればこのファイルから値を読み込んだとき該当のプレースホルダーは`VALUE`に置換されます。

次に、上記`.env`ファイルを読み込むためには
```python: .envを読み込む
import os
from dotenv import dotenv_values

dirname = os.path.dirname(__file__)
path = os.path.join(dirname, '.env')
config = dotenv_values(dotenv_path=path)
print(config)
```

とし、このコードを実行すると、

```python
OrderedDict([('PASSWORD', 'AIUEO'), ('USERNAME', 'TEST_USER'), ('MONGODB_URI', 'mongodb+srv://<CONNECTION_STRINGS>'), ('MONGODB_URI_PASSWORD', 'mongodb+srv://TEST_USER:AIUEO@<CONNECTION_STRINGS>')])
```

が返されます。あとは、`config.get(KEY, 'DEFAULT_VALUE')`等で値にアクセスできます。例えば、

```python
config['USERNAME'] # TEST_USER
config.get('PASSWORD', '') # AIUEO
config.get('MONGODE_URI', '') # mongodb+srv://<CONNECTION_STRINGS>
```

といった感じです。

## 2-3 よく使いそうなAPI

※雑多にメモしているので徐々に整理していく予定。（並べる順番など）

#### ▼Making a Connection with MongoClient
MongoDBに接続する：

from pymongo import MongoClient
client = MongoClient("<MONGODB_URI>")

#### ▼Getting a Database
PyMongoでDBを作成するには属性にアクセスする形式で：

db = client.test_database
または
db = client["test_database"]

DBの一覧を取得するには次のようにする：

client.list_database_names()

#### ▼Getting a Collection
CollectionはMongoDB内に格納されたDocumentの集団で、RDBにおけるテーブルと等価な概念とみなされる。
Databaseにアクセスする方法と同じ形式で：

collection = db.test_collection
または
collection = db["test_collection"]

DB内のCollectionの一覧を取得するには次のようにする：

db.list_collection_names()

#### ▼Documents
MongoDBにおけるDataはJSON形式のドキュメントを使用して表現される。
例えば次のようなもの：

import datetime
post = {
    "author": "Mike",
    "text": "My first blog post!",
    "tags": ["mongodb", "python", "pymongo"],
    "date": datetime.datetime.now(tz=datetime.timezone.utc),
}

#### ▼Inserting a Document

Collectionの中にDocumentを挿入するため位は`insert_one()`メソッドを使用する：

posts = db.posts
post_id = posts.insert_one(post).inserted_id

※Documentが挿入された際に自動的に`_id`という特殊なキーが挿入される。
`_id`の値はCollection間でユニークでなければならない。

Database内のCollectionのリストを確認するためには次のようにする：

db.list_collection_names()

#### ▼Getting a Single Document With find_one()

`find_one()`メソッドは最も基本的なクエリの形式でクエリにマッチしたDocumentを１つ返す。マッチしない場合は`None`を返す。
例えば次の通り：

posts.find_one()
posts.find_one({"author": "Mike"})
posts.find_one({"author": "Eliot"}) <- return None

#### ▼Querying By ObjectId

`_id`の値はObjectIdであり、str型の値ではない。
例えば、

posts.find_one({"_id": post_id})

は値を返すが、

post_id_as_str = str(post_id)
posts.find_one({"_id": post_id_as_str})

は`None`を返す。そのため、必要に応じてstring型からObjectIdへの変換が必要な場合がある。
そのような場合は次のようにする：

from bson.objectId import ObjectId

def get(post_id):
    document = client.db.collection.find_one({"_id": ObjectId(post_id)})

#### ▼Bulk Inserts

リスト型にしたDocumentを挿入するためには`insert_many()`メソッドを使用する：

new_posts = [
    {
        "author": "Mike",
        "text": "Another post!",
        "tags": ["bulk", "insert"],
        "date": datetime.datetime(2009, 11, 12, 11, 14),
    },
    {
        "author": "Eliot",
        "title": "MongoDB is fun",
        "text": "and pretty easy too!",
        "date": datetime.datetime(2009, 11, 10, 10, 45),
    },
]
result = posts.insert_many(new_posts)
inserted_ids = result.inserted_ids

※new_posts内のスキーマの構造は異なることに注意。

#### ▼Querying for More Than One Document

１つ以上のDocumentを取得するためには`find()`メソッドを使用する。
`find()`は`Cursor`インスタンスを返す：

for post in posts.find():
    print(post)

for post in posts.find({"author": "Mike"}):
    print(post)

#### ▼Replace a Single Document Matching The Filter

Document：{'x': 1, '_id': ObjectId('54f4c5befba5220aa4d6dee7')}
の`'x':1`を`'y':1`に置換したい場合、次のようにする：

result = db.test_collection.replace_one({'x':1}, {'y':1})

第１引数がfilterにあたり、このfilterとマッチしたDocumentを第２引数で置換する。

さらに、`upsert`オプションをTrueにした場合、filterにマッチするDocumentがない場合新しくDocumentを追加する。

result = db.test_collection.replace_one({'z':1, {'z':1}}, True)

#### ▼Update a Single Document Matching The Filter

Document: {'x': 1, '_id': 0}
の'x'の値を更新したい場合、次のようにする：

result = db.test_collection.update_one({'x':1}, {'$inc': {'x':3}})

第１引数のfilterとマッチしたDocumentの値を第２引数の値に更新する。
上記例では、'x'の値に3追加した、{'x': 4, '_id': 0}にDocumentが更新される。

さらに、`upsert`オプションをTrueにした場合、filterにマッチするDocumentがない場合新しくDocumentを追加する。

result = db.test_collection.update_one({'x':-10}, {'$inc': {'x':3}}, True)

上記例では、第１引数にマッチするDocumentがないので、新しくDocumentが追加される：

{'_id': ObjectId('626a678eeaa80587d4bb3fb7'), 'x': -7}

#### ▼Update One or More Documents That Match The Filter

`update_many()`メソッドを使用することで、filterと合致したDocumentを第２引数で更新する。
例えば、Documentに{'x': 1, '_id': 0},{'x': 1, '_id': 1},{'x': 1, '_id': 2}があるとき、

result = db.test_collection.update_many({'x':1}, {'$inc': {'x':3}})

とすると、Documentは{'x': 4, '_id': 0},{'x': 4, '_id': 1},{'x': 4, '_id': 2}に更新される。

#### ▼Delete a Single Document Mathching The Filter

filterにマッチしたDocumentを１つ削除するには例えば、
Documentが{'x': 1, '_id': 0},{'x': 1, '_id': 1},{'x': 1, '_id': 2}のとき次のようにする：

result = db.test_collection.delete_one({'x': 1})
doc_count = db.test_collection.count_documents({'x': 1}) # 2

#### ▼Delete One or More Documents That Match The Filter

filterにマッチしたDocumentを１つまたは複数削除するには例えば、
Documentが{'x': 1, '_id': 0},{'x': 1, '_id': 1},{'x': 1, '_id': 2}のとき次のようにする：

result = db.test_collection.delete_many({'x': 1})
doc_count = db.test_collection.count_documents({'x': 1}) # 0

#### ▼Get a List of Distinct Values For Key Among All Documents in This Collection

result = db.test_collection.distinct('x', {'x': 1})

#### ▼Counting

クエリにマッチしたDocumentの数を知りたいとき、`count_documents()`メソッドを使用する。
以下は、Collection内のDocumentの数を取得する：

posts.count_documents({})
posts.count_documents({"author": "Mike"})

#### ▼Alias for drop_collection()

db.test_collection.drop()
OR
db.drop_collection("test_collection")

#### ▼Rename this Collection

db.test_collection.rename("sample_collection")

#### ▼Get The Options Set on This Collection

db.test_collection.options()

#### ▼Range Queries

MongoDBではadvanced queriesをサポートしている。（https://www.mongodb.com/docs/manual/reference/operator/）
例えば、ある日時よりも古いポストを取得し、"author"によって取得結果をソートするには

d = datetime.datetime(2009,11,12,12)
for post in posts.find({"date": {"$lt": d}}).sort("author):
    print(post)

ここで`$lt`はLess Thanを意味するクエリ。

#### ▼Indexing

インデックスを追加することはクエリの速度の向上やクエリやDocument格納機能を付与し得る。
例えば、unique indexを作成する：

result = db.profiles.create_index(["user_id", pymongo.ASCENDING], unique=True)
sorted(list(db.profiles.index_information())) # ['_id_', 'user_id_1']

インデックスは自動生成される`_id`と上記で作成したような`user_id`の２種類がある。
インデックスにより既にCollection内に存在している`user_id`を挿入することを防ぐことができる。
例えば、[{"user_id": 110, "name": "Luke"},{"user_id": 111, "name": "Ziltoid"},]がすでにCollection内に挿入済みであるとする
このとき、
{"user_id": 112, "name": "Drew"}は挿入可能であるが、{"user_id": 111, "name": "Tommy"}
は既にuser_idが存在しているので挿入ができない。

# 3- まとめ

MongoDB Atlasのクラスターで接続情報を取得し、`PyMongo`をインストールし、このモジュールを用いてPythonとクラスターを接続テストを行いました。
次は`FAST API`を使って、APIを作成していく予定です。