CRUD操作の移行
このページでは、すべてのDataStore CRUD操作とpredicate/filterパターンをApollo Clientに移行する方法について説明します。DataStoreはcreateとupdateを単一のsave()メソッドに統合し、_versionを内部的に処理します。Apollo Clientでは、各操作に対して個別のmutationを使用し、_versionを明示的に管理します。
このページで使用するGraphQL操作(CREATE_POST、UPDATE_POST、DELETE_POST、GET_POST、LIST_POSTS、およびPOST_DETAILS_FRAGMENTフラグメント)は、Apollo Clientのセットアップページで定義されています。必要に応じてインポートします:
import { apolloClient } from './apolloClient';import { CREATE_POST, UPDATE_POST, DELETE_POST, GET_POST, LIST_POSTS,} from './graphql/operations';Create(新しいレコードを保存)
DataStoreはnew Model()とDataStore.save()を使用してレコードを作成します。Apollo ClientはCREATE_POST mutationを使用します。
DataStore(移行前):
const newPost = await DataStore.save( new Post({ title: 'My First Post', content: 'Hello world', status: 'PUBLISHED', rating: 5, }));Apollo Client(移行後)-- 命令型:
const { data } = await apolloClient.mutate({ mutation: CREATE_POST, variables: { input: { title: 'My First Post', content: 'Hello world', status: 'PUBLISHED', rating: 5, }, },});const newPost = data.createPost;// newPost._version is 1 (set by AppSync automatically)主な違い:
- createでは
_versionは不要。 AppSyncは新しいレコードで_versionを自動的に1に設定します。 - **
refetchQueries**はcreate後にリストビューを更新します。DataStoreはローカルストアを通じてこれを自動的に処理しました。Apollo Clientでは明示的なキャッシュ管理が必要です。
Update(既存レコードを変更)
DataStoreはModel.copyOf()をimmerベースのドラフトで不変更新に使用します。Apollo ClientはUPDATE_POST mutationをプレーンオブジェクトで使用します。変更されたフィールドのみをinputに含める必要があります。
DataStore(移行前):
const original = await DataStore.query(Post, '123');const updated = await DataStore.save( Post.copyOf(original, (draft) => { draft.title = 'Updated Title'; draft.rating = 4; }));Apollo Client(移行後)-- 命令型:
// Step 1: Query the current record to get _versionconst { data: queryData } = await apolloClient.query({ query: GET_POST, variables: { id: '123' },});const post = queryData.getPost;
// Step 2: Mutate with _version from query resultconst { data } = await apolloClient.mutate({ mutation: UPDATE_POST, variables: { input: { id: '123', title: 'Updated Title', rating: 4, _version: post._version, // REQUIRED }, },});主な違い:
copyOf()またはimmerパターンはありません。 Apolloはプレーンオブジェクトを使用します -- 変更したいフィールドのみを渡します。- 変更されたフィールド、id、_versionのみが必要です。 レコード全体を送信する必要はありません。
- 2段階のプロセス: 最初にクエリ(
_versionを取得)してからmutateします。DataStoreはこれを内部的に処理しました。
Delete(単一レコード)
deleteでは_versionが必須です。 IDをすでに持っていても、現在の_versionを取得するため、最初にレコードをクエリする必要があります。
DataStore(移行前):
const post = await DataStore.query(Post, '123');await DataStore.delete(post);Apollo Client(移行後)-- 命令型:
// Step 1: Query to get current _versionconst { data: queryData } = await apolloClient.query({ query: GET_POST, variables: { id: '123' },});
// Step 2: Delete with _versionawait apolloClient.mutate({ mutation: DELETE_POST, variables: { input: { id: '123', _version: queryData.getPost._version, }, }, refetchQueries: [{ query: LIST_POSTS }],});主な違い:
- ID削除のショートハンドはありません。 Apolloはmutation入力オブジェクトで常に
idと_versionの両方を必要とします。 - deleteはソフトデリートです(競合解決が有効になっている場合)。レコードの
_deletedフィールドはDynamoDBでtrueに設定されますが、レコードは物理的には削除されません。
IDでクエリ
DataStore(移行前):
const post = await DataStore.query(Post, '123');if (post) { console.log(post.title);}Apollo Client(移行後):
const { data } = await apolloClient.query({ query: GET_POST, variables: { id: '123' },});const post = data.getPost;// Returns null instead of undefined when not foundif (post) { console.log(post.title);}すべてのレコードをリスト
DataStore(移行前):
const posts = await DataStore.query(Post);Apollo Client(移行後):
const { data } = await apolloClient.query({ query: LIST_POSTS });const posts = data.listPosts.items.filter((post) => !post._deleted);バッチ削除(predicateベース)
DataStoreはpredicateで複数のレコードを削除することをサポートしていました。Apollo Clientに相当するものはありません -- 最初に一致するレコードをクエリしてから、各レコードを個別に削除する必要があります。
DataStore(移行前):
await DataStore.delete(Post, (p) => p.status.eq('DRAFT'));Apollo Client(移行後):
// Step 1: Query posts matching the filterconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { status: { eq: 'DRAFT' } } },});const drafts = data.listPosts.items.filter((post) => !post._deleted);
// Step 2: Delete each record individuallyconst results = await Promise.allSettled( drafts.map((post) => apolloClient.mutate({ mutation: DELETE_POST, variables: { input: { id: post.id, _version: post._version }, }, }) ));
// Step 3: Check for partial failuresconst failures = results.filter((r) => r.status === 'rejected');if (failures.length > 0) { console.error(`${failures.length} of ${drafts.length} deletes failed`);}
// Refresh the listawait apolloClient.refetchQueries({ include: [LIST_POSTS] });CRUD クイックリファレンス
| DataStoreメソッド | Apollo Client相当 | 主な違い |
|---|---|---|
DataStore.save(new Model({...})) | apolloClient.mutate({ mutation: CREATE, variables: { input: {...} } }) | createでは_versionは不要 |
Model.copyOf(original, draft => {...}) + DataStore.save() | apolloClient.mutate({ mutation: UPDATE, variables: { input: { id, _version, ...changes } } }) | _versionを渡す必要があります。immerドラフトの代わりにプレーンオブジェクト |
DataStore.delete(instance) | apolloClient.mutate({ mutation: DELETE, variables: { input: { id, _version } } }) | _versionを取得するため最初にクエリが必要 |
DataStore.query(Model, id) | apolloClient.query({ query: GET, variables: { id } }) | 見つからない場合、undefinedの代わりにnullを返す |
DataStore.query(Model) | apolloClient.query({ query: LIST }) | 結果から_deletedレコードをフィルタリングする必要があります |
DataStore.delete(Model, predicate) | フィルタでクエリ + 個別に削除 | atomicity がありません。Promise.allSettledを使用 |
フィルタ演算子マッピング
DataStoreはコールバックベースのpredicateを使用します。Apollo ClientとAppSyncはクエリ変数として渡されたJSONフィルタオブジェクトを使用します。
| 演算子 | DataStore構文 | GraphQL構文 | メモ |
|---|---|---|---|
eq | p.field.eq(value) | { field: { eq: value } } | 完全一致 |
ne | p.field.ne(value) | { field: { ne: value } } | 不一致 |
gt | p.field.gt(value) | { field: { gt: value } } | より大きい |
ge | p.field.ge(value) | { field: { ge: value } } | 以上 |
lt | p.field.lt(value) | { field: { lt: value } } | より小さい |
le | p.field.le(value) | { field: { le: value } } | 以下 |
contains | p.field.contains(value) | { field: { contains: value } } | 部分文字列一致 |
notContains | p.field.notContains(value) | { field: { notContains: value } } | 部分文字列が不存在 |
beginsWith | p.field.beginsWith(value) | { field: { beginsWith: value } } | 文字列プレフィックス一致 |
between | p.field.between(lo, hi) | { field: { between: [lo, hi] } } | 範囲(両端含む) |
in | p.field.in([v1, v2]) | 利用不可 | or + eqを使用 |
notIn | p.field.notIn([v1, v2]) | 利用不可 | and + neを使用 |
フィルタ例
eq -- 完全一致:
// DataStoreconst published = await DataStore.query(Post, (p) => p.status.eq('PUBLISHED'));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { status: { eq: 'PUBLISHED' } } },});const published = data.listPosts.items.filter((p) => !p._deleted);contains -- 部分文字列一致:
// DataStoreconst reactPosts = await DataStore.query(Post, (p) => p.title.contains('React'));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { title: { contains: 'React' } } },});const reactPosts = data.listPosts.items.filter((p) => !p._deleted);between -- 範囲(両端含む):
// DataStoreconst midRated = await DataStore.query(Post, (p) => p.rating.between(2, 4));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { rating: { between: [2, 4] } } },});const midRated = data.listPosts.items.filter((p) => !p._deleted);andで条件を組み合わせる:
// DataStoreconst posts = await DataStore.query(Post, (p) => p.and((p) => [p.rating.gt(4), p.status.eq('PUBLISHED')]));
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { and: [{ rating: { gt: 4 } }, { status: { eq: 'PUBLISHED' } }], }, },});orで条件を組み合わせる:
const { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { or: [ { title: { contains: 'React' } }, { title: { contains: 'Apollo' } }, ], }, },});notで否定する:
const { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { not: { status: { eq: 'DRAFT' } } }, },});inとnotInの回避策
inをor + eqで置き換える:
// DataStore: p.status.in(['PUBLISHED', 'DRAFT'])// Apollo: combine multiple eq conditions with orconst { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: { or: [{ status: { eq: 'PUBLISHED' } }, { status: { eq: 'DRAFT' } }], }, },});inとnotInのヘルパー関数
function buildInFilter(field: string, values: string[]) { return { or: values.map((value) => ({ [field]: { eq: value } })), };}
function buildNotInFilter(field: string, values: string[]) { return { and: values.map((value) => ({ [field]: { ne: value } })), };}
// Usage:const { data } = await apolloClient.query({ query: LIST_POSTS, variables: { filter: buildInFilter('status', ['PUBLISHED', 'DRAFT']) },});ページネーション移行
DataStoreはページベースのページネーション(ゼロインデックスpage番号 + limit)を使用します。AppSyncはカーソルベースのページネーション(nextToken + limit)を使用します。これは名前変更ではなく -- 根本的なセマンティック変更です。
| 側面 | DataStore(ページベース) | Apollo/AppSync(カーソルベース) |
|---|---|---|
| ナビゲーション | ランダムアクセス -- 任意のページにジャンプ | 順序のみ -- ページを順番に走査する必要があります |
| パラメータ | { page: 0, limit: 10 } | { limit: 10, nextToken: '...' } |
| 最初のページ | page: 0 | nextTokenを省略(またはnullを渡す) |
| 次のページ | page: page + 1 | 前の応答からnextTokenを使用 |
| 終了検出 | items.length < limit | nextToken === null |
Apollo Clientカーソルベースページネーション:
// Page 1 (first 10 items) -- no nextToken neededconst { data: page1Data } = await apolloClient.query({ query: LIST_POSTS, variables: { limit: 10 },});const page1Items = page1Data.listPosts.items.filter((p) => !p._deleted);const nextToken = page1Data.listPosts.nextToken;
// Page 2 -- use nextToken from previous responseif (nextToken) { const { data: page2Data } = await apolloClient.query({ query: LIST_POSTS, variables: { limit: 10, nextToken }, });}ソート移行
DataStoreはSortDirection.ASCENDINGとSortDirection.DESCENDINGをサポートしています。AppSyncの基本的なlistModelsクエリにはデフォルトでは**sortDirection引数がありません**。
クライアント側のソート(推奨)
ほとんどの場合、結果をフェッチしてJavaScriptでソートします:
// DataStoreconst posts = await DataStore.query(Post, Predicates.ALL, { sort: (s) => s.createdAt(SortDirection.DESCENDING),});
// Apollo Clientconst { data } = await apolloClient.query({ query: LIST_POSTS });const posts = [...data.listPosts.items] .filter((p) => !p._deleted) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() );サーバー側のソート(@indexディレクティブが必須)
モデルに@indexディレクティブで定義されたグローバルセカンダリインデックス(GSI)がある場合、AppSyncはsortDirectionサポートを備えたクエリを生成します:
type Post @model { id: ID! title: String! status: String! @index(name: "byStatus", sortKeyFields: ["createdAt"]) createdAt: AWSDateTime!}これはsortDirectionを受け入れるpostsByStatusクエリを生成します:
const LIST_POSTS_BY_STATUS = gql` query PostsByStatus( $status: String! $sortDirection: ModelSortDirection $limit: Int $nextToken: String ) { postsByStatus( status: $status sortDirection: $sortDirection limit: $limit nextToken: $nextToken ) { items { ...PostDetails } nextToken } }`;
const { data } = await apolloClient.query({ query: LIST_POSTS_BY_STATUS, variables: { status: 'PUBLISHED', sortDirection: 'DESC', limit: 10 },});サーバー側のソートはバックエンドスキーマの変更が必要で、インデックスのパーティションキーでクエリする場合のみ機能します。汎用のソートにはクライアント側のソートを使用します。
一般的なミス
一般的なCRUD移行ミス
1. updateまたはdelete mutationで_versionを忘れる
最も頻繁な移行エラー。DataStoreは_versionを内部的に処理しました。Apolloでは自分で含める必要があります。
2. updateにCREATE mutationを使用する
DataStoreのsave()はcreateとupdateの両方を処理しました。Apolloでは、正しいmutationを呼び出す必要があります。
3. リスト結果から_deletedレコードをフィルタリングしない
DataStoreはソフトデリートされたレコードを自動的に非表示にしました。Apolloはソフトデリートされたレコードを含むすべてのレコードを返します。常にリストクエリ結果に対して.filter(item => !item._deleted)を使用します。
4. mutation後に refetchQueriesを使用しない
DataStoreのローカルストアはmutation後にクエリを自動的に更新しました。Apolloのキャッシュはリストクエリを自動的に更新しないことがあります。リストビューに影響するmutationにrefetchQueries: [{ query: LIST_POSTS }]を追加します。
5. 古い_versionの値を使用する
レコードの_versionをキャッシュして別のユーザーまたはプロセスがレコードを更新した場合、mutationは失敗します。新鮮さが重要な場合は、mutate前にfetchPolicy: 'network-only'で再クエリします。