はじめに

AWS Amplify Gen2は、フロントエンド開発者がTypeScriptを活用してフルスタックアプリケーションを構築するための新しいアプローチを提供しています。スキーマを定義する際の強力な仕組みがDerivedModelSchemaです。この強力な仕組みによって、型安全なデータモデリングが可能になり、開発者は生産性を高めることができます。

このブログでは、DerivedModelSchemaの基本的な概念から高度な使用方法まで詳細に解説します。

DerivedModelSchemaとは

DerivedModelSchemaは、Amplify Gen2のデータモデリングシステムの中核をなす型であり、TypeScriptベースのスキーマ定義DSL(Domain Specific Language)を使用して作成されたGraphQLスキーマを表します。文字列ベースのSDLではなく、型安全な方法でスキーマを定義できます。

基本的な使い方は次のとおりです。

import { a, defineData } from '@aws-amplify/backend';

// これがDerivedModelSchema
const schema = a.schema({
  Todo: a.model({
    content: a.string(),
    completed: a.boolean()
  })
});

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "apiKey",
    apiKeyAuthorizationMode: {
      expiresInDays: 30,
    },
  },
});

基本的なデータ型

DerivedModelSchemaでは、様々なデータ型を使用できます。

スカラー型

const schema = a.schema({
  Todo: a.model({
    id: a.id(),                 // ID型(デフォルトではUUID)
    name: a.string(),           // 文字列型
    count: a.int(),             // 整数型
    price: a.float(),           // 浮動小数点型
    isActive: a.boolean(),      // 真偽値型
    createdAt: a.datetime(),    // 日時型
    metadata: a.json(),         // JSON型
  })
});

カスタム型

複合的なデータ構造を表現するために、a.customType()メソッドを使用してカスタム型を定義できます。

const schema = a.schema({
  Todo: a.model({
    content: a.string(),
    location: a.customType({
      latitude: a.float().required(),
      longitude: a.float().required(),
      address: a.string()
    })
  })
});

列挙型(Enum)

特定の値のセットから選択するフィールドをa.enum()メソッドを使用して定義できます。

const schema = a.schema({
  Post: a.model({
    content: a.string(),
    status: a.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED'])
  })
});

クライアント側では、列挙型の値を取得することもできます。

const availableStatuses = client.enums.PostStatus.values();
// ["DRAFT", "PUBLISHED", "ARCHIVED"]

フィールド修飾子

各フィールドはメソッドチェーンによって様々な修飾子を追加できます。

必須フィールド

デフォルトでは、フィールドはオプショナルですが、.required()修飾子で必須にできます。

const schema = a.schema({
  Todo: a.model({
    content: a.string().required(),
  })
});

配列

.array()修飾子を使用して、任意のフィールドを配列に変換できます。

const schema = a.schema({
  Todo: a.model({
    tags: a.string().array(),
    relatedIds: a.id().array()
  })
});

デフォルト値

.default()修飾子でフィールドのデフォルト値を設定できます。

const schema = a.schema({
  Todo: a.model({
    content: a.string().default('新しいタスク'),
    isComplete: a.boolean().default(false),
    priority: a.int().default(1)
  })
});

モデル間のリレーションシップ

DerivedModelSchemaでは、モデル間の関係性を簡単に定義できます。

一対多(hasMany / belongsTo)

const schema = a.schema({
  Blog: a.model({
    name: a.string(),
    posts: a.hasMany('Post')  // Blogは複数のPostを持つ
  }),

  Post: a.model({
    title: a.string(),
    content: a.string(),
    blog: a.belongsTo('Blog')  // PostはBlogに属する
  })
});

多対多

多対多の関係をモデリングするには、中間テーブルを使用します。

const schema = a.schema({
  Post: a.model({
    title: a.string(),
    content: a.string(),
    tags: a.hasMany('PostTag', 'postID')
  }),

  Tag: a.model({
    name: a.string(),
    posts: a.hasMany('PostTag', 'tagID')
  }),

  PostTag: a.model({
    postID: a.id(),
    post: a.belongsTo('Post', 'postID'),
    tagID: a.id(),
    tag: a.belongsTo('Tag', 'tagID')
  }).identifier(['postID', 'tagID'])
});

一対一

const schema = a.schema({
  User: a.model({
    name: a.string(),
    profile: a.hasOne('Profile')
  }),

  Profile: a.model({
    bio: a.string(),
    user: a.belongsTo('User')
  })
});

識別子のカスタマイズ

モデルの主キーをカスタマイズすることができます。

デフォルトの識別子

デフォルトでは、各モデルには自動生成されるidフィールドがプライマリキーとして追加されます。

const schema = a.schema({
  Todo: a.model({
    content: a.string()
    // 自動的に `id: a.id().required()` が追加される
  })
});

単一フィールド識別子

特定のフィールドをプライマリキーとして指定できます。

const schema = a.schema({
  Product: a.model({
    sku: a.string().required(),
    name: a.string(),
    price: a.float()
  }).identifier(['sku'])
});

複合識別子

複数のフィールドを組み合わせた複合キーも定義できます。

const schema = a.schema({
  Enrollment: a.model({
    studentID: a.id().required(),
    courseID: a.id().required(),
    enrollmentDate: a.datetime()
  }).identifier(['studentID', 'courseID'])
});

セカンダリインデックス

クエリのパフォーマンスを最適化するために、セカンダリインデックスを定義できます。

const schema = a.schema({
  Order: a.model({
    id: a.id(),
    customerID: a.string().required(),
    orderDate: a.datetime().required(),
    status: a.string()
  }).secondaryIndexes((index) => [
    // customerIDに基づくインデックス
    index('customerID'),

    // orderDateに基づくソート可能なインデックス
    index('status').sortKey('orderDate')
  ])
});

操作の無効化

特定のモデル操作を無効にすることもできます。

const schema = a.schema({
  AuditLog: a.model({
    action: a.string().required(),
    timestamp: a.datetime().required(),
    userId: a.string()
  }).disableOperations([
    'update',   // 更新操作を無効化
    'delete'    // 削除操作を無効化
  ])
});

モデル認可ルール

データモデルに対する認可ルールを定義できます。

const schema = a.schema({
  PublicPost: a.model({
    title: a.string(),
    content: a.string()
  }).authorization((allow) => [
    allow.public().read(),   // 読み取りは公開
    allow.owner().write()    // 書き込みは所有者のみ
  ]),

  PrivateNote: a.model({
    content: a.string()
  }).authorization((allow) => [
    allow.owner()  // 所有者のみアクセス可能
  ])
});

クライアント側での型の活用

DerivedModelSchemaの大きな利点は、フロントエンドとバックエンドで型情報を共有できることです。

// amplify/data/resource.ts
import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

const schema = a.schema({
  Todo: a.model({
    content: a.string(),
    isComplete: a.boolean().default(false)
  })
});

// クライアント側で使用するための型を生成
export type Schema = ClientSchema<typeof schema>;

export const data = defineData({ schema });

// src/app.tsx
import { type Schema } from '../amplify/data/resource';
import { generateClient } from 'aws-amplify/data';

// 型安全なクライアントを生成
const client = generateClient<Schema>();

// TypeScriptの型チェックが有効
await client.models.Todo.create({
  content: '型安全なTodo' 
  // isCompleteはデフォルト値があるため省略可能
});

ZodやYupとの類似性

DerivedModelSchemaの記法は、人気のTypeScript用バリデーションライブラリであるZodやYupと非常に似ています。これは偶然ではなく、開発者体験を向上させるための設計上の選択です。

共通する設計パターン

// Amplify DerivedModelSchema
const todoSchema = a.schema({
  Todo: a.model({
    title: a.string().required(),
    description: a.string()
  })
});

// Zod
const zodTodoSchema = z.object({
  title: z.string().min(1),
  description: z.string().optional()
});

// Yup
const yupTodoSchema = yup.object({
  title: yup.string().required(),
  description: yup.string()
});

メソッドチェーンを使った宣言的なAPIスタイルは次のような利点があります。

  1. 可読性の高さ: スキーマの構造が一目で理解できます
  2. 優れたDX(開発者体験): IDEの自動補完が活用できます
  3. コンパクトな記述: 少ないコード量で多くの意味を表現できます
  4. 柔軟な拡張性: 新しい機能や制約を追加しやすい設計です

Amplify特有の強み

一方で、ZodやYupが主にデータ検証に焦点を当てているのに対し、DerivedModelSchemaはより幅広い役割を担っています。

  1. データ検証だけでなく、GraphQLスキーマの生成
  2. DynamoDBテーブルとインデックスの自動作成
  3. リゾルバーとCRUD操作の生成
  4. 認可ルールの統合
  5. クライアントとサーバー間での型の共有

このように、Amplify Gen2のDerivedModelSchemaは、現代的なTypeScriptライブラリの優れた設計パターンを採用しながら、クラウドインフラストラクチャの構築という独自の強みを持ち合わせています。

まとめ

DerivedModelSchemaは、Amplify Gen2において、型安全なデータモデリングを実現するための強力な仕組みです。スキーマ定義、関係性のモデリング、認可ルールの設定など、多様な機能を提供しています。

このアプローチにより、開発者はIDEの補完機能を活用でき、型エラーを早期に検出できるため、より堅牢なアプリケーションを効率的に構築できます。

Gen1のGraphQL SDLと比較すると、Amplify Gen2のTypeScriptベースのスキーマ定義はより直感的ですね!

参考リンク