5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-06-07

やりたいこと

  • 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

データ構造がシンプルな場合はかなり便利なのですが、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

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の階層構造に変換している部分です。

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'
        }
    }
}

動作確認

リクエスト

query MyQuery {
  listUsers {
    items {
      id
      name
      department {
        id
        name
      }
    }
  }
}

レスポンス

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

おわりに

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

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?