BioErrorLog Tech Blog

試行錯誤の記録

Amplify & GraphQLでのデータモデル設計事例集

AWS Amplify & GraphQLでのデータモデル (スキーマ) 設計例をまとめます。


はじめに

こんにちは、@bioerrorlogです。

最近、AWS Amplifyに注目してします。
Amplifyはフルスタックなサーバレスアプリを素早く作ることが出来るプラットフォームで、プロダクト開発の生産性を高めることが出来ます。

AmplifyプロジェクトのAPIを GraphQL (AppSync)で構築するときには、データモデルをスキーマschema.graphqlに定義する流れになります。 このGraphQLのスキーマ設計はリレーショナルデータベースでのデータモデル設計とはやり方が異なるため、最初は慣れが必要です。

今回は、Amplifyのdeveloper advocateであるNader Dabit氏による以下の記事を参考に、GraphQL & Amplifyでのスキーマ設計例を学んでいきます。

GraphQL Recipes (V2) - Building APIs with GraphQL Transform - DEV Community 👩‍💻👨‍💻

以下の順にスキーマ例を見ていきます。

  1. Todoアプリ
  2. イベントアプリ
  3. チャットアプリ
  4. Eコマースアプリ
  5. WhatsAppクローン
  6. Redditクローン
  7. マルチユーザーチャットアプリ
  8. インスタグラムクローン
  9. カンファレンスアプリ


スキーマ設計例

Todoアプリ

type Todo @model {
  id: ID!
  name: String!
  description: String
}

Todoアプリにおけるユーザーの行動:

  • 全TodoをList取得できる
  • TodoをCreate/Update/Deleteできる

その実現に必要な要件:

  • Todo type
  • データベースの作成
  • GraphQL mutations(create/update/delete)の定義
  • GraphQL queries(listTodos)の定義
  • 各処理のためのGraphQL resolversの作成


まずはシンプルなTodoアプリの例です。 上記のスキーマから、GraphQL API(AppSync)やデータベース(DynamoDB)の作成、GraphQL resolversの作成、GraphQL queries/mutations/subscriptionの定義がデプロイできます。


イベントアプリ

type Event @model
  @key(name: "itemType", fields: ["itemType", "time"], queryField: "eventsByDate")
  @auth(rules: [
    { allow: groups, groups: ["Admin"] },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
    id: ID!
    name: String!
    description: String
    time: String!
    itemType: String!
    comments: [Comment] @connection #optional comments field
}

# Optional Comment type
type Comment @model
  @auth(rules: [
    { allow: owner, ownerField: "author" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  message: String!
  author: String
}

イベントアプリにおけるユーザーの行動:

  • 日付順でイベントをList取得できる
  • 個々のイベントを確認できる
  • イベントに付けられたコメントを確認できる
  • サインインしたユーザーはコメントを作成できる
  • 管理者はイベントを作成できる
  • 管理者はイベントを更新/削除できる


directives(@key, @auth, @connection )を活用して、上記の要求を満たすスキーマが設計されています。

@keyによる定義によって、日付順のイベントList取得を可能にしています。
@keyの使い方はこちら

@connectionを用いたデータモデル間関係の定義によって、イベントに付けられたコメントの確認を可能にしています。
@connection の使い方はこちら

@authを用いた権限設定によって、ユーザー毎に可能な操作を制限しています。
@auth の使い方はこちら


イベントを作成するときは、以下のようなcreate mutationを発行します。

mutation createEvent {
  createEvent(input: {
    name: "Rap battle of the ages"
    description: "You don't want to miss this!"
    time: "2018-07-13T16:00:00Z"
    itemType: "Event"
  }) {
    id 
    name 
    description 
    time
  } 
}

itemTypeに一定の値"Event"を格納しておくことで、以下のように日付順にソートしたイベントのListが取得出来ます。

query listEvents {
  eventsByDate(itemType: "Event") {
    items {
      id
      name
      description
      time
    }
  }
}


イベントに紐づいたコメントを作成するには、以下のようなcreate mutationを発行します。

mutation createComment {
  createComment(input: {
    eventCommentsId: "7f0d82f5-b57e-4417-b515-ce04475675a2"
    message:"Amazing!"
  }) {
    id
    message
  }
}

eventCommentsIdを指定することで、コメントを紐づけるイベントを指定することが出来ます。


チャットアプリ

ここではシンプルなチャットアプリについて書きます。 より現実的なチャットアプリについては、後述のマルチユーザーチャットアプリの章にて記述します。

type Conversation @model {
  id: ID!
  name: String
  messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"])
  createdAt: String
  updatedAt: String
}

type Message
  @model(subscriptions: null, queries: null)
  @key(name: "messagesByConversationId", fields: ["conversationId"]) {
  id: ID!
  conversationId: ID!
  content: String!
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
}

チャットアプリにおけるユーザーの行動:

  • 会話を作成する
  • 会話でメッセージを送る
  • 全ての会話とメッセージを閲覧する
  • 新規メッセージと会話をリアルタイムに確認(Subscribe)する


会話の作成、会話に対するメッセージの作成、全ての会話とメッセージの取得のためには、以下のquery / mutationを発行します。

# 会話の作成
mutation createConversation {
  createConversation(input: {
    name: "my first conversation"
  }) {
    name
    id
  }
}

# 会話に対するメッセージの作成
mutation createMessage {
  createMessage(input: {
    conversationId: "your-conversation-id"
    content: "Hello world"
  }) {
    id
    content
  }
}

# 全ての会話とメッセージの取得
query listConversations {
  listConversations {
    items {
      name
      messages {
        items {
          content
        }
      }
    }
  }
}


Eコマースアプリ

type Customer @model(subscriptions: null)
  @auth(rules: [
    { allow: owner },
    { allow: groups, groups: ["Admin"] }
  ]) {
  id: ID!
  name: String!
  email: String!
  address: String
  orders: [Order] @connection(keyName: "byCustomerId", fields: ["id"])
}

type Product @model(subscriptions: null)
  @auth(rules: [
    { allow: groups, groups: ["Admin"] },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  name: String!
  description: String
  price: Float!
  image: String
}

type LineItem @model(subscriptions: null)
  @key(name: "byOrderId", fields: ["orderId"])
  @auth(rules: [
   { allow: owner },
   { allow: groups, groups: ["Admin"] }
  ]) {
  id: ID!
  orderId: ID!
  productId: ID!
  qty: Int
  order: Order @connection(fields: ["orderId"])
  product: Product @connection(fields: ["productId"])
  description: String
  price: Float
  total: Float
}

type Order @model(subscriptions: null)
  @key(name: "byCustomerId", fields: ["customerId", "createdAt"], queryField: "ordersByCustomerId")
  @auth(rules: [
   { allow: owner },
   { allow: groups, groups: ["Admin"] }
  ]) {
  id: ID!
  customerId: ID!
  total: Float
  subtotal: Float
  tax: Float
  createdAt: String!
  customer: Customer @connection(fields: ["customerId"])
  lineItems: [LineItem] @connection(keyName: "byOrderId", fields: ["id"])
}

Eコマースアプリにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーは商品を閲覧できる
  • ユーザーは注文を作成できる
  • ユーザーは購入商品 (line item) を注文に追加できる
  • ユーザーは自身のアカウントとそれに紐づいた注文・商品を閲覧できる
  • 管理者は商品/注文/ユーザーを作成/更新/削除できる
  • 管理者は商品/注文/ユーザーを取得できる
  • 管理者はユーザーに紐づいた注文を取得できる


各操作には、以下のGraphQL mutations / queriesを発行します。

mutation createProduct {
  createProduct(input: {
    name: "Yeezys"
    description: "Best shoes ever"
    price: 200.00
  }) {
    id
    name
    description
    price
  }
}

mutation createCustomer {
  createCustomer(input: {
    name: "John Doe"
    email: "johndoe@myemail.com"
    address: "555 Hwy 88"
  }) {
    id
    email
    name
    address
  }
}

mutation createOrder {
  createOrder(input: {
    subtotal: 250.00
    total: 275.00
    tax: 25.00
    customerId: "some-customer-id"
  }) {
    id
    subtotal
    tax
    total
    customer {
      name
    }
  }
}

mutation createLineItem {
  createLineItem(input: {
    qty: 1
    productId: "some-product-id"
    orderId: "some-order-id"
    price: 250.00
    total: 250.00
  }) {
    id
    qty
  }
}

query getCustomer {
  getCustomer(id: "some-customer-id") {
    id
    name
    address
    orders {
      items {
        id
        lineItems {
          items {
            description
            price
            total
            qty
            product {
              id
              name
              description
            }
          }
        }
      }
    }
  }
}

query ordersByCustomerId {
  ordersByCustomerId(
    customerId: "some-customer-id"
  ) {
    items {
      id
      lineItems {
        items {
          id
          price
          total
        }
      }
      total
      subtotal
      tax
    }
  }
}

query listOrders {
  listOrders {
    items {
      id
      total
      subtotal
      tax
      lineItems {
        items {
          id
          price
          product {
            id
            price
            description
          }
        }
      }
    }
  }
}


WhatsAppクローン

type User
  @key(fields: ["userId"])
  @model(subscriptions: null)
  @auth(rules: [
    { allow: owner, ownerField: "userId" }
  ]) {
  userId: ID!
  avatar: String
  conversations: [ConvoLink] @connection(keyName: "conversationsByUserId", fields: ["userId"])
  messages: [Message] @connection(keyName: "messagesByUserId", fields: ["userId"])
  createdAt: String
  updatedAt: String
}

type Conversation
  @model(subscriptions: null)
  @auth(rules: [{ allow: owner, ownerField: "members" }]) {
  id: ID!
  messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"])
  associated: [ConvoLink] @connection(keyName: "convoLinksByConversationId", fields: ["id"])
  members: [String!]!
  createdAt: String
  updatedAt: String
}

type Message
  @key(name: "messagesByConversationId", fields: ["conversationId"])
  @key(name: "messagesByUserId", fields: ["userId"])
  @model(subscriptions: null, queries: null) {
  id: ID!
  userId: ID!
  conversationId: ID!
  author: User @connection(fields: ["userId"])
  content: String!
  image: String
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type ConvoLink
  @key(name: "convoLinksByConversationId", fields: ["conversationId"])
  @key(name: "conversationsByUserId", fields: ["userId"])
  @model(
    mutations: { create: "createConvoLink", update: "updateConvoLink" }
    queries: null
    subscriptions: null
  ) {
  id: ID!
  userId: ID!
  conversationId: ID!
  user: User @connection(fields: ["userId"])
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type Subscription {
  onCreateConvoLink(userId: ID): ConvoLink
    @aws_subscribe(mutations: ["createConvoLink"])
  onCreateMessage(conversationId: ID): Message
    @aws_subscribe(mutations: ["createMessage"])
}

WhatsAppクローンにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーはプロフィールのアバター画像を更新できる
  • ユーザーは会話を作成できる
  • ユーザーは会話でメッセージを送れる


Redditクローン

type User @model(subscriptions: null)
  @key(fields: ["userId"])
  @auth(rules: [
    { allow: owner, ownerField: "userId" }
  ]) {
  userId: ID!
  posts: [Post] @connection(keyName: "postByUser", fields: ["userId"])
  createdAt: String
  updatedAt: String
}

type Post @model
  @key(name: "postByUser", fields: ["authorId", "createdAt"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  authorId: ID!
  author: User @connection(fields: ["authorId"])
  postContent: String
  postImage: String
  comments: [Comment] @connection(keyName: "commentsByPostId", fields: ["id"])
  votes: [PostVote] @connection(keyName: "votesByPostId", fields: ["id"])
  createdAt: String
  voteCount: Int
}

type Comment @model
  @key(name: "commentsByPostId", fields: ["postId"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  authorId: ID!
  postId: ID!
  text: String!
  author: User @connection(fields: ["authorId"])
  votes: [CommentVote] @connection(keyName: "votesByCommentId", fields: ["id"])
  post: Post @connection(fields: ["postId"])
  voteCount: Int
}

type PostVote @model
  @auth(rules: [
    { allow: owner, ownerField: "userId"},
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "votesByPostId", fields: ["postId"]) {
  id: ID!
  postId: ID!
  userId: ID!
  post: Post @connection(fields: ["postId"])
  createdAt: String!
  vote: VoteType
}

type CommentVote @model
  @auth(rules: [
    { allow: owner, ownerField: "userId"},
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "votesByCommentId", fields: ["commentId"]) {
  id: ID!
  userId: ID!
  commentId: ID!
  comment: Comment @connection(fields: ["commentId"])
  createdAt: String!
  vote: VoteType
}

input VoteInput {
  type: VoteType!
  id: ID!
}

enum VoteType {
  up
  down
}

Redditクローンにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーはポスト(テキストor画像)を作成できる
  • ユーザーはポストにコメントできる
  • ユーザーはポストをVoteできる
  • ユーザーはコメントをVoteできる


ここで、カスタムresolverを実装してVoteのIdpostIduserIdの組み合わせにすることで、ユーザーが一つのポストに複数回Voteするのを防ぐことが出来ます。

カスタムresolverの追加実装例は以下です。

#set($itemId = "$context.identity.username#$context.args.postId")
$util.qr($context.args.input.put("id", $util.defaultIfNull($ctx.args.input.id, $itemId)))

そしてVoteの上書きを回避するための以下のコードがあれば、削除orコメントアウトします。

#set( $condition = {
  "expression": "attribute_not_exists(#id)",
  "expressionNames": {
      "#id": "id"
  }
} )


また、Voteカウントを算出したければ、DynamoDBトリガーのLambda関数を作成し、カスタムロジックを実装する必要があります。
Lambda実装例はこちら


マルチユーザーチャットアプリ

type User
  @key(fields: ["userId"])
  @model(subscriptions: null)
  @auth(rules: [
    { allow: owner, ownerField: "userId" }
  ]) {
  userId: ID!
  conversations: [ConvoLink] @connection(keyName: "conversationsByUserId", fields: ["userId"])
  messages: [Message] @connection(keyName: "messagesByUserId", fields: ["userId"])
  createdAt: String
  updatedAt: String
}

type Conversation
  @model(subscriptions: null)
  @auth(rules: [{ allow: owner, ownerField: "members" }]) {
  id: ID!
  messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"])
  associated: [ConvoLink] @connection(keyName: "convoLinksByConversationId", fields: ["id"])
  members: [String!]!
  createdAt: String
  updatedAt: String
}

type Message
  @key(name: "messagesByConversationId", fields: ["conversationId"])
  @key(name: "messagesByUserId", fields: ["userId"])
  @model(subscriptions: null, queries: null) {
  id: ID!
  userId: ID!
  conversationId: ID!
  author: User @connection(fields: ["userId"])
  content: String!
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type ConvoLink
  @key(name: "convoLinksByConversationId", fields: ["conversationId"])
  @key(name: "conversationsByUserId", fields: ["userId"])
  @model(
    mutations: { create: "createConvoLink", update: "updateConvoLink" }
    queries: null
    subscriptions: null
  ) {
  id: ID!
  userId: ID!
  conversationId: ID!
  user: User @connection(fields: ["userId"])
  conversation: Conversation @connection(fields: ["conversationId"])
  createdAt: String
  updatedAt: String
}

type Subscription {
  onCreateConvoLink(userId: ID): ConvoLink
    @aws_subscribe(mutations: ["createConvoLink"])
  onCreateMessage(conversationId: ID): Message
    @aws_subscribe(mutations: ["createMessage"])
}

マルチユーザーチャットアプリにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーは会話を作成できる
  • ユーザーは会話でメッセージを送れる
  • ユーザーは会話のリストを閲覧できる
  • ユーザーはほかのユーザーとの会話を作成できる


インスタグラムクローン

type User @model(subscriptions: null)
  @key(fields: ["userId"])
  @auth(rules: [
    { allow: owner, ownerField: "userId" },
    { allow: private, operations: [read] }
    ]) {
  userId: ID!
  posts: [Post] @connection(keyName: "postsByUserId", fields: ["userId"])
  createdAt: String
  updatedAt: String
  following: [Following] @connection(keyName: "followingByUserId", fields: ["userId"])
}

type Post @model
  @key(name: "postsByUserId", fields: ["authorId"])
  @auth(rules: [
    { allow: owner ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  authorId: ID!
  content: String!
  postImage: String
  author: User @connection(fields: ["authorId"])
  comments: [Comment] @connection(keyName: "commentsByPostId", fields: ["id"])
  likes: [PostLike] @connection(keyName: "postLikesByPostId", fields: ["id"])
}

type Comment @model
  @key(name: "commentsByPostId", fields: ["postId"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  postId: ID!
  authorId: ID!
  text: String!
  likes: [CommentLike] @connection(keyName: "commentLikesByCommentId", fields: ["id"])
  author: User @connection(fields: ["authorId"])
  post: Post @connection(fields: ["postId"])
}

type PostLike @model
  @auth(rules: [
    { allow: owner, ownerField: "userId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "postLikesByPostId", fields: ["postId"])
  @key(name: "postLikesByUser", fields: ["userId", "createdAt"], queryField: "likesByUser") {
  id: ID!
  postId: ID!
  userId: ID!
  user: User @connection(fields: ["userId"])
  post: Post @connection(fields: ["postId"])
  createdAt: String!
}

type CommentLike @model
  @auth(rules: [
    { allow: owner, ownerField: "userId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "commentLikesByCommentId", fields: ["commentId"])
  @key(name: "commentLikesByUser", fields: ["userId", "createdAt"], queryField: "likesByUser") {
  id: ID!
  userId: ID!
  postId: ID!
  commentId: ID!
  user: User @connection(fields: ["userId"])
  post: Post @connection(fields: ["postId"])
  createdAt: String!
}

type Following @model
  @auth(rules: [
    { allow: owner, ownerField: "followerId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
  @key(name: "followingByUserId", fields: ["followerId"]) {
  id: ID
  followerId: ID!
  followingId: ID!
  follower: User @connection(fields: ["followerId"])
  following: User @connection(fields: ["followingId"])
  createdAt: String!
}

インスタグラムクローンにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーはポストを作成できる
  • ユーザーはポストにコメントを付けれる
  • ユーザーは他のユーザーをフォロー/フォロー解除できる
  • ユーザーはポスト/コメントに"いいね"を付けれる

"いいね"機能の実装については、Redditクローンと同様、カスタムresolverを実装する必要があります。
詳しくはRedditクローンの章を参照。


カンファレンスアプリ

type Talk @model
  @auth(rules: [
    { allow: groups, groups: ["Admin"] },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ]) {
  id: ID!
  name: String!
  speakerName: String!
  speakerBio: String!
  time: String
  timeStamp: String
  date: String
  location: String
  summary: String!
  twitter: String
  github: String
  speakerAvatar: String
  comments: [Comment] @connection(keyName: "commentsByTalkId", fields: ["id"])
}

type Comment @model
  @key(name: "commentsByTalkId", fields: ["talkId"])
  @auth(rules: [
    { allow: owner, ownerField: "authorId" },
    { allow: public, operations: [read] },
    { allow: private, operations: [read] }
  ])
{
  id: ID!
  talkId: ID!
  talk: Talk @connection(fields: ["talkId"])
  message: String
  createdAt: String
  authorId: ID!
  deviceId: ID
}

type Report @model
  @auth(rules: [
    { allow: owner, operations: [create, update, delete] },
    { allow: groups, groups: ["Admin"] }
  ])
  {
  id: ID!
  commentId: ID!
  comment: String!
  talkTitle: String!
  deviceId: ID
}

type ModelCommentConnection {
  items: [Comment]
  nextToken: String
}

type Query {
  listCommentsByTalkId(talkId: ID!): ModelCommentConnection
}

type Subscription {
  onCreateCommentWithId(talkId: ID!): Comment
        @aws_subscribe(mutations: ["createComment"])
}

カンファレンスアプリにおけるユーザーの行動:

  • ユーザーはアカウントを作成できる
  • ユーザーは講演のリストを閲覧できる
  • ユーザーは個々の講演を閲覧できる
  • ユーザーは講演にコメントを付けれる
  • (オプション)ユーザーはコメントを報告できる

なお、追加で定義しているsubscriptiononCreateCommentWithIdによって、閲覧中の講演に対するコメントのみをsubscribeすることが出来ます。

React Nativeで構築したカンファレンスアプリの例はこちら


おわりに

以上、各アプリを題材にしたGraphQLスキーマの設計例を見てきました。

GraphQLに馴染みのないままスキーマを設計するのは難しい作業です。 今回は、習うより慣れよ、の精神で大量のスキーマ設計を読み込みました。

個人的には大いに勉強になりましたが、これがどなたかの役にも立てれば幸いです。


関連記事

www.bioerrorlog.work

www.bioerrorlog.work


参考

GraphQL Recipes (V2) - Building APIs with GraphQL Transform - DEV Community 👩‍💻👨‍💻

API (GraphQL) - Directives | Amplify Docs

dynamodb-triggers/counter.js at master · dabit3/dynamodb-triggers · GitHub

GitHub - full-stack-serverless/conference-app-in-a-box: Full stack & cross platform app customizable & themeable for any event or conference.