🦄

Amplifyで簡単にDB設計をしよう!~リレーション定義の巻~

2023/09/28に公開

前回は一番基盤となるテーブル定義について解説しました。
https://zenn.dev/maromero/articles/a726e14842f4a0
今回はよりDB設計の表現の幅を広げるリレーションの設定について解説していきます!

テーブル間のリレーション設定

Amplifyでは、@modelタイプ間で"1対1"、1対多"、"所属関係"、"多対多"といったリレーションを簡単に作成できます。
表にまとめてみました!

リレーション ディレクティブ 説明
一対一 @hasOne 二つのモデル間で一方向の一対一の関係を作成します。例:プロジェクトは一つのチームを"持っている"。
一対多 @hasMany 二つのモデル間で一方向の一対多の関係を作成します。例:投稿は多くのコメントを"持っている"。
所属関係 @belongsTo "has one"や"has many"の関係を双方向にするための関係。例:プロジェクトは一つのチームを"持っている"と同時に、チームはプロジェクトに"所属している"。
多対多 @manyToMany 二つのモデル間で多対多の関係を実現するための"結合テーブル"を設定。例:ブログは多くのタグを"持っている"と同時に、タグも多くのブログを"持っている"。

1対1の設定(@hasOne)

@hasOneディレクティブを使用して、二つのモデル間で一方向の一対一の関係を作成しましょう。

例として、以下のようにプロジェクトはチームを"持っている"関係を考えてみましょう。

type Project @model {
  id: ID!
  name: String
  team: Team @hasOne
}

type Team @model {
  id: ID!
  name: String!
}

Amplifyはこの定義を元に、関連するアイテムをテーブルから取得するためのクエリやミューテーションを自動的に生成してくれます!
以下のミューテーション定義と実行によって、新しいプロジェクトを作成できます。

mutation CreateProject {
  createProject(input: {projectTeamId: "team-id", name: "Some Name"}) {
    team {
      name
      id
    }
    name
    id
  }
}

このミューテーションでは、"projectTeamId"というフィールドがデフォルトで定義されています。

import { createProject } from './graphql/mutations';

const params = {
    input: { projectTeamId: 'team-id', name: 'Some Name' },
};

const result = await API.graphql(graphqlOperation(createProject, params));
const project = result.data.createProject;

これで、特定のチームIDとプロジェクト名を使って、新しいプロジェクトを作成し、その情報を取得できます!

カスタマイズ

リレーション情報を保存するフィールドをカスタマイズしたい場合は、fields配列引数を設定して、それをタイプのフィールドにマッチさせることができるんですよ✨

例として、以下のようにプロジェクトにteamIDフィールドを追加して、チームの識別子として使用します。

type Project @model {
  id: ID!
  name: String
  teamID: ID
  team: Team @hasOne(fields: ["teamID"])
}

type Team @model {
  id: ID!
  name: String!
}

この場合、@hasOneはteamIDを使って、関連するTeamオブジェクトを取得することができます。
以下のミューテーションを使って、新しいプロジェクトを作成してみましょう!

mutation CreateProject {
  createProject(input: { name: "New Project", teamID: "a-team-id"}) {
    id
    name
    team {
      id
      name
    }
  }
}

この
そして、以下のコードを使って、GraphQLミューテーションを実行します:

import { createProject } from './graphql/mutations';

const params = {
    input: { teamID: 'team-id', name: 'New Project' },
};

const result = await API.graphql(graphqlOperation(createProject, params));
const project = result.data.createProject;

ちなみに、@hasOneリレーションは、関連するモデルのプライマリキーを参照しています。デフォルトではidを使用しますが、@primaryKeyディレクティブで上書きすることもできるんですよ!🌟

1対多の設定(@hasMany)

さて、@hasManyディレクティブを使って、2つのモデル間で一方向の1対多の関係を作成しましょう!

type Post @model {
  id: ID!
  title: String!
  comments: [Comment] @hasMany
}

type Comment @model {
  id: ID!
  content: String!
}

これにより、関連するCommentレコードを元のPostレコードから取得するためのクエリやミューテーションが生成されます。

mutation CreatePost {
  createPost(input: {title: "Hello World!!"}) {
    title
    id
    comments {
      items {
        id
        content
      }
    }
  }
}

そして、以下のコードを使って、GraphQLミューテーションを実行します:

import { createPost } from './graphql/mutations';

const params = {
    input: { title: 'Hello World!!' },
};

const result = await API.graphql(graphqlOperation(createPost, params));
const post = result.data.createPost;
const comments = post.comments.items;

裏側では、@hasManyは関連するテーブル上にセカンダリインデックスを作ってくれています。これで、あるテーブルから関連するテーブルをクエリできるようにしています。

1対多関係のための特定のセカンダリインデックスをカスタマイズすしたい場合、関連するテーブルのフィールドに@indexディレクティブを作成して、セカンダリインデックスを割り当てれば大丈夫です。

@indexでは、インデックス名とsort keyを設定することができます。

type Post @model {
  id: ID!
  title: String!
  comments: [Comment] @hasMany(indexName: "byPost", fields: ["id"])
}

type Comment @model {
  id: ID!
  postID: ID! @index(name: "byPost", sortKeyFields: ["content"])
  content: String!
}

この場合、CommentタイプにはPostレコードの参照を保存するためのpostIDフィールドが追加されています。@hasManyは、このpostIDを使って、Commentテーブルのセカンダリインデックス"byPost"をクエリして、関連するCommentオブジェクトを取得できます。

mutation CreatePost {
  createPost(input: {title: "Hello world!"}) {
    comments {
      items {
        postID
        content
        id
      }
    }
    title
    id
  }
}

そして、以下のコードを使って、GraphQLミューテーションを実行します:

import { createPost, createComment } from './graphql/mutations';
import { getPost } from './graphql/mutations';

// create post
const postParams = {
    input: { title: 'Hello World!!' },
};

const result = await API.graphql(graphqlOperation(createPost, postParams));
const post = result.data.createPost;

// create comment
const commentParams = {
    input: { content: 'Hi!', postID: post.id },
};

await API.graphql(graphqlOperation(createComment, commentParams));

// get post
const result = await API.graphql(graphqlOperation(getPost, { id: post.id }));
const postWithComments = result.data.createPost;
const postComments = postWithComments.comments.items; // access comments 

このコードは、特定のタイトルで投稿を作成し、その投稿にコメントを追加できます。

所属関係の設定(@belongsTo)

「@belongsTo」ディレクティブを使うと、「has one」や「has many」の関係を双方向にすることができます!

type Project @model {
  id: ID!
  name: String
  team: Team @hasOne
}

type Team @model {
  id: ID!
  name: String!
  project: Project @belongsTo
}

これにより、ProjectからTeamを、そして逆にTeamからProjectをクエリできるようになります!

mutation CreateProject {
  createProject(input: { name: "New Project", teamID: "a-team-id"}) {
    id
    name
    team { # ProjectからTeamをクエリ
      id
      name
      project { # そして、TeamからProjectをクエリ
        id
        name
      }
    }
  }
}
import { createProject, createTeam, updateTeam } from './graphql/mutations';

// Teamを作成
const teamParams = {
    input: { name: 'New Team' },
};
const result = await API.graphql(graphqlOperation(createTeam, teamParams));
const team = result.data.createTeam;

// Projectを作成
const projectParams = {
    input: { name: 'New Project', projectTeamId: team.id },
};
const result = await API.graphql(graphqlOperation(createProject, projectParams));
const project = result.data.createProject;

// Teamを更新
const updateParams = {
    input: { id: team.id, teamProjectId: project.id },
};
const updateTeamResult = await API.graphql(graphqlOperation(updateTeam, updateParams));

ちなみに、@belongsToはfields引数なしで使うこともできます。その場合、親の主キーを参照するフィールドが自動的に生成されます!

もちろん、カスタムフィールドを設定して親オブジェクトの参照を保存することもできます。以下はその例です。

type Post @model {
  id: ID!
  title: String!
  comments: [Comment] @hasMany(indexName: "byPost", fields: ["id"])
}

type Comment @model {
  id: ID!
  postID: ID! @index(name: "byPost", sortKeyFields: ["content"])
  content: String!
  post: Post @belongsTo(fields: ["postID"])
}

多対多の設定(@manyToMany)

「@manyToMany」ディレクティブを使うと、2つのモデル間で多対多の関係を作成できるんです!両方のモデルに共通のrelationNameを提供して、多対多の関係を結ぶ魔法のような繋がりを作りましょう!

type Post @model {
  id: ID!
  title: String!
  content: String
  tags: [Tag] @manyToMany(relationName: "PostTags")
}

type Tag @model {
  id: ID!
  label: String!
  posts: [Post] @manyToMany(relationName: "PostTags")
}

裏側では、@manyToManyディレクティブがrelationNameとして名付けられた結合テーブルを作成して、多対多の関係をサポートしています!

mutation CreatePost {
  createPost(input: {title: "Hello World!!"}) {
    id
    title
    content
    tags { # "結合テーブル" PostTagsをクエリ
      items {
        tag { # Postから関連するTagレコード
          id
          label
          posts { # "結合テーブル" PostTagsを再度クエリ
            items {
              post { # Tagから関連するPostレコード
                id
                title
                content
              }
            }
          }
        }
      }
    }
  }
}
import { createPost, createTag, createPostTags } from './graphql/mutations';
import { listPosts } from './graphql/queries';

// Postを作成
const postParams = {
    input: { title: 'Hello World' },
};
const result = await API.graphql(graphqlOperation(createPost, postParams));
const post = result.data.createPost;

// Tagを作成
const tagParams = {
    input: { label: 'My Tag' },
};
const tagResult = await API.graphql(graphqlOperation(createTag, tagParams));
const tag = tagResult.data.createTag;

// PostとTagを繋げる
const postTagParams = {
    input: { postId: post.id, tagId: tag.id },
};
await API.graphql(graphqlOperation(createPostTags, postTagParams));

// Postを取得
const listPostsResult = await API.graphql(graphqlOperation(listPosts));
const posts = listPostsResult.data.listPosts;
const postTags = posts[0].tags; // PostからTagを取得

まとめ

結構作ってみると明示的でないフィールドを気を遣って作ってくれているのがAmplifyという印象ですかね。
それが良い時もあるが、見えないところが潜在的な実装漏れなどを引き起こしそうという懸念もありますね。

Discussion