AppSync + RDSで複雑なクエリを実行する

## やりたいこと
- AppSyncで構築したGraphQL APIから、
- RDSに対して、
- JOIN句などを含む複雑なクエリを実行したい

AppSync, GraphQLの基本的な部分の説明は割愛します。

## 今回使用するGraphQLスキーマ
```schema.graphql
type User {
    id: ID
    name: String
    age: Int
    department: Department
}

type Department {
    id: ID
    name: String
}

type Query {
    listUsers: [User]
}
```

## RDSデータソースの限界
AppSyncのリゾルバーのデータソースとしてRDSを指定すると、AppSyncが用意しているユーティリティ関数で簡単にクエリを実行できます。

```
export function request(ctx) {

    // Generates statement:
    // SELECT "id", "name"
    // FROM "persons"
    // WHERE "name" = :NAME and "id" > :ID
    return createPgStatement(select({
        table: 'persons',
        columns: ['id', 'name'],
        where: {name: {eq: 'Stephane'}, id: {gt: 10}}
    }));
}
```
引用元: [JavaScript Amazon RDS の リゾルバー関数リファレンス - AWS AppSync](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-reference-rds-js.html)

データ構造がシンプルな場合はかなり便利なのですが、JOIN句などを含むような複雑なクエリを実行したい場合、ユーティリティ関数では実現できません。
わざわざ（Dynamoではなく）RDSを選んでいるということは、リレーションが重要なデータを扱っているということであり、JOINを使って複数テーブルからデータを取得したいケースが多いでしょう。

### 別解
JOINを使わずにリレーション先のデータを取得する方法として、フィールドにリゾルバーを設定する方法もあります。
今回の例であれば、UserのDepartmentフィールドにリゾルバーを設定して、そこでDepartmentテーブルから1件取得する方法です。
この方法だと、listUsersでUserが10件取れた場合、Departmentを取得するクエリが10回実行されます。いわゆるN+1問題ですね。
データ量が少なかったり、リレーションが複雑でない場合はこの方法もアリですが、今回は1回のクエリで取りたいデータを全て取得するのを目指します。

## Lambdaでクエリを実行する
複雑なクエリを実行したい場合、RDSデータソース用のユーティリティ関数では機能不足なので、データソースとしてLambdaを設定し、Lambdaからクエリを実行する必要があります。

### リゾルバーのコード
```list-users-resolver.js
import { util } from "@aws-appsync/utils";

export function request(ctx) {
  console.log(ctx);
  const { source, args } = ctx;
  args.resolverProps = {
    query: `
    SELECT
        users.*,
        departments.id as department__id,
        departments.name as department__name
    FROM users
        LEFT JOIN departments ON users.department_id = departments.id
    `,
  };
  return {
    operation: "Invoke",
    payload: { field: ctx.info.fieldName, arguments: args, source },
  };
}

export function response(ctx) {
  const { error, result } = ctx;
  if (error) {
    return util.Error(error.message, error.type, result);
  }
  if (result.error) {
    return util.Error(result.error.message, result.error.type, result);
  }
  return result;
}

```
Lambdaデータソースの場合のリゾルバーテンプレートをベースに、実行するSQLクエリをLambdaに渡すように変更しています。
Lambdaで適切にレスポンスを整形するために、`as`句で列に別名をつけています。

### Lambdaのコード
RDSのエンジンはAurora PostgreSQLで、Data APIを有効にしてあります。LambdaからはこのData APIを使ってRDSにアクセスします。
Data APIについての詳細は以下を参照ください。
[RDS Data API の使用 - Amazon Aurora](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/data-api.html)
```index.py
import json
import os

import boto3
from typing import Any, Dict

db_secret_arn = os.environ["DB_SECRET_ARN"]
db_cluster_arn = os.environ["RDS_CLUSTER_ARN"]
db_name = os.environ["RDS_DB_NAME"]

rds_data_client = boto3.client("rds-data")


def handler(event, context):
    try:
        params = {
            "secretArn": db_secret_arn,
            "resourceArn": db_cluster_arn,
            "sql": event["arguments"].get("resolverProps").get("query"),
            "database": db_name,
            "includeResultMetadata": False,
            "formatRecordsAs": "JSON",
        }

        # クエリ実行
        result = rds_data_client.execute_statement(**params)
        items = json.loads(result["formattedRecords"])

        # レスポンスを整形
        for i in range(len(items)):
            items[i] = nest_keys(items[i])

        return {"items": items}

    except Exception as e:
        print("ERROR:", e)
        return {"error": {"message": str(e), "type": type(e).__name__}}


def nest_keys(data: Dict[str, Any]) -> Dict[str, Any]:
    """
    フラットな辞書をネストした辞書に変換する
    "__"を区切り文字としてネストを構築
    """
    nested_data = {}

    for key, value in data.items():
        if "__" in key:
            base_key, nested_key = key.split("__", 1)
            if base_key not in nested_data:
                nested_data[base_key] = {}
            nested_data[base_key][nested_key] = value
        else:
            nested_data[key] = value

    # 再帰的にネストを構築
    for key, value in nested_data.items():
        if isinstance(value, dict):
            nested_data[key] = nest_keys(value)

    return nested_data

```

#### 解説
このLambda自体は、他のリゾルバーのデータソースとしても使えるように、リゾルバーから渡されたクエリを実行するだけの汎用的な作りになっています。

肝は、フラットなSQLの結果を、JSONの階層構造に変換している部分です。
```python
result = rds_data_client.execute_statement(**params)
items = json.loads(result["formattedRecords"])
```
↑この時点でのitemsは↓のようになっています。

```
[
    {
        'id': '1',
        'name': 'Alice',
        'age': '20',
        'department_id': '1',
        'department__id': '1',
        'department__name': '営業部'
    },
    {
        'id': '2',
        'name': 'Bob',
        'age': '30',
        'department_id': '2',
        'department__id': '2',
        'department__name': '製品開発部'
    }
]
```

GraphQLのスキーマでは、UserがDepartmentを持ち、その中にidとnameがある構造を定義しているので、このままレスポンスを返却することはできません。
スキーマに合わせるために、適切に辞書をネストするのが、`nest_keys()`関数です。
区切り文字（今回は2つのアンダースコア`__`）が含まれるフィールドをネストする関数です。
↑のitemsを`nest_keys()`で処理すると以下のようになります。

```
[
    {
        'id': '1',
        'name': 'Alice',
        'age': '20',
        'department_id': '1',
        'department': {
            'id': '1',
            'name': '営業部'
        }
    },
    {
        'id': '2',
        'name': 'Bob',
        'age': '30',
        'department_id': '2',
        'department': {
            'id': '2',
            'name': '製品開発部'
        }
    }
]
```

`nest_keys()`は再帰関数のため、3階層以上の構造にも対応できます。


```
{
    'id': '1',
    'name': 'Alice',
    'age': '20',
    'department_id': '1',
    'department__id': '1',
    'department__name': '営業部',
    'department__company__id': '1',
    'department__company__name': 'ABC'
},

```
↑これが
↓こうなる
```
{
    'id': '1',
    'name': 'Alice',
    'age': '20',
    'department_id': '1',
    'department': {
        'id': '1',
        'name': '営業部',
        'company': {
            'id': '1',
            'name': 'ABC'
        }
    }
}
```

### 動作確認

リクエスト
```graphql
query MyQuery {
  listUsers {
    items {
      id
      name
      department {
        id
        name
      }
    }
  }
}
```

レスポンス
```json
{
  "data": {
    "listUsers": {
      "items": [
        {
          "id": "1",
          "name": "Alice",
          "department": {
            "id": "1",
            "name": "営業部"
          }
        },
        {
          "id": "2",
          "name": "Bob",
          "department": {
            "id": "2",
            "name": "製品開発部"
          }
        }
      ]
    }
  }
}
```

## おわりに
書いてから気づきましたが、AppSyncとRDSというよりは、SQLの結果からJSONへの変換についてが大半を占めてしまいました。