Documentation Drizzle

概要

この記事では、Drizzle ORMを用いてAWSのData API経由でPostgreSQLデータベースに接続し、サーバレス環境でデータ操作を行う方法を詳しく解説します。Drizzle ORMは、JavaScriptやTypeScriptで型安全なデータベース操作を提供し、AWSのData APIを利用することで、サーバレスアーキテクチャでも簡単にPostgreSQLにアクセスできます。Data APIは、AWSのRDSにおいてクライアントライブラリを使用せずにHTTPリクエストを介してデータベース操作ができる仕組みです。

AWSのData APIとDrizzle ORMの概要

AWS Data APIとは

AWS Data APIは、RDS(Relational Database Service)上のPostgreSQLおよびAmazon Auroraサーバレスインスタンスに対して、HTTPリクエストを介してデータ操作を行うAPIです。これにより、以下のような利点が得られます:

  • サーバレス対応:クライアントライブラリなしでデータベースにアクセスでき、AWS Lambdaなどのサーバレス環境で利用しやすい。
  • セキュアなアクセス:IAM認証で安全にデータベースにアクセス。
  • スケーラビリティ:オンデマンドでデータベースにアクセスするため、高いスケーラビリティを実現。

Drizzle ORMとは

Drizzle ORMは、JavaScriptやTypeScriptで利用できる軽量で型安全なORMです。Drizzle ORMはSQLに似た直感的なクエリ構文を提供し、TypeScriptの型を活用して安全にデータベース操作を行うことができます。AWSのData APIと組み合わせることで、サーバレス環境でも効率的にデータ管理が可能です。

AWS Data APIとDrizzle ORMの接続手順

AWS環境の設定 - RDSインスタンスの作成とData APIの有効化

まず、AWS RDSでPostgreSQLインスタンスを作成し、Data APIを有効にする必要があります。

  1. AWS Management Consoleにログイン。
  2. RDSサービスから新規データベースを作成し、データベースエンジンとしてPostgreSQLを選択。
  3. 作成画面で「サーバレス」モードを選び、インスタンス設定を完了します。
  4. データベース作成後、Data APIを有効にします。 この設定により、AWSのData APIを使ってHTTPリクエストでデータベースにアクセスできるようになります。Data APIのエンドポイントURLやARN(Amazon Resource Name)を控えておきましょう。

必要なパッケージのインストール

Drizzle ORMとAWS SDKを利用してData APIに接続するため、drizzle-orm@aws-sdk/client-rds-dataをインストールします。

npm install drizzle-orm @aws-sdk/client-rds-data
  • drizzle-ormは型安全なORMで、クエリの記述を簡単に行えます。
  • @aws-sdk/client-rds-dataはAWS Data APIにアクセスするためのAWS SDKクライアントです。

Drizzle ORMとData APIの接続設定

Drizzle ORMを使用してAWS Data API経由でPostgreSQLに接続するために、必要な設定を行います。以下に、接続設定の例を示します。

// database.ts
import { drizzle } from 'drizzle-orm/aws-data-api/pg';
import { RDSDataClient } from '@aws-sdk/client-rds-data';

// AWS Data API クライアントの設定
const rdsClient = new RDSDataClient({
  region: process.env.AWS_REGION || 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

// Drizzle ORM の初期化(PostgreSQL用)
export const db = drizzle(rdsClient, {
  database: process.env.DATABASE_NAME || 'postgres',
  resourceArn: process.env.DB_RESOURCE_ARN!,
  secretArn: process.env.DB_SECRET_ARN!,
});

// 環境変数の例(.env ファイル)
// AWS_REGION=us-east-1
// AWS_ACCESS_KEY_ID=your-access-key
// AWS_SECRET_ACCESS_KEY=your-secret-key
// DATABASE_NAME=employees_db
// DB_RESOURCE_ARN=arn:aws:rds:us-east-1:123456789012:cluster:my-aurora-cluster
// DB_SECRET_ARN=arn:aws:secretsmanager:us-east-1:123456789012:secret:rds-db-credentials-AbCdEf

接続情報の詳細:

  • region: PostgreSQLデータベースのAWSリージョン(例: us-east-1, ap-northeast-1
  • database: PostgreSQLのデータベース名
  • resourceArn: Aurora PostgreSQLクラスターのARN(AWS RDSコンソールから取得)
  • secretArn: データベース認証情報が保存されているAWS Secrets ManagerのARN

重要: AWS Data APIは主にAurora ServerlessおよびAurora Serverless v2で利用できます。通常のRDS PostgreSQLインスタンスでは使用できません。

これにより、Drizzle ORM経由でData APIを使用してPostgreSQLに接続できます。

テーブルのスキーマ定義

Drizzle ORMを使用して、テーブルのスキーマを定義します。以下は、employeesというテーブルのスキーマ例です。

// schema.ts
import { pgTable, serial, varchar, integer, timestamp, boolean, text } from 'drizzle-orm/pg-core';

// employees テーブルの定義
export const employees = pgTable('employees', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  position: varchar('position', { length: 50 }).notNull(),
  salary: integer('salary'),
  department: varchar('department', { length: 50 }),
  is_active: boolean('is_active').default(true).notNull(),
  bio: text('bio'),
  hire_date: timestamp('hire_date', { mode: 'date' }).defaultNow(),
  created_at: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
  updated_at: timestamp('updated_at', { mode: 'date', withTimezone: true }).defaultNow().$onUpdate(() => new Date())
});

// 型定義の自動生成(TypeScript)
export type Employee = typeof employees.$inferSelect;
export type NewEmployee = typeof employees.$inferInsert;

このスキーマ定義では、以下の機能を実装しています:

  • serial('id'): 自動インクリメントの主キー(PostgreSQLのSERIAL型)
  • varchar(): 可変長文字列型(長さ指定)
  • integer(): 整数型(給与)
  • boolean(): 真偽値型(アクティブ状態)
  • text(): 長いテキスト型(自己紹介)
  • timestamp(): タイムスタンプ型(タイムゾーン付き、自動更新機能付き)
  • 型推論による型安全性($inferSelect, $inferInsert

データ操作 - 挿入、取得、更新、削除

Drizzle ORMを使ってData API経由でPostgreSQLにデータ操作を行います。以下に、基本的なCRUD操作の例を示します。

データの挿入

import { db } from './database';
import { employees, type NewEmployee } from './schema';

// 単一レコードの挿入
async function insertEmployee(data: NewEmployee) {
  const result = await db.insert(employees).values(data).returning();
  return result[0];
}

// 使用例
const newEmployee = await insertEmployee({
  name: '山田太郎',
  email: 'yamada@example.com',
  position: 'Software Engineer',
  salary: 6000000,
  department: 'Engineering'
});
console.log('Created employee:', newEmployee);

// 複数レコードの一括挿入
async function insertMultipleEmployees(employeeList: NewEmployee[]) {
  const result = await db.insert(employees).values(employeeList).returning();
  return result;
}

await insertMultipleEmployees([
  { name: '佐藤花子', email: 'sato@example.com', position: 'Designer', salary: 5500000 },
  { name: '鈴木一郎', email: 'suzuki@example.com', position: 'Product Manager', salary: 7000000 }
]);

データの取得

import { eq, and, gte, like, desc, sql } from 'drizzle-orm';

// 全件取得
async function getAllEmployees() {
  return await db.select().from(employees);
}

// 条件付き取得
async function getEmployeeById(id: number) {
  const result = await db
    .select()
    .from(employees)
    .where(eq(employees.id, id))
    .limit(1);
  return result[0];
}

// 複雑な条件での検索
async function searchEmployees(department: string, minSalary: number) {
  return await db
    .select()
    .from(employees)
    .where(
      and(
        eq(employees.department, department),
        gte(employees.salary, minSalary),
        eq(employees.is_active, true)
      )
    )
    .orderBy(desc(employees.salary));
}

// LIKE検索とページネーション
async function findEmployeesByName(namePart: string, page: number = 1, pageSize: number = 10) {
  const offset = (page - 1) * pageSize;

  return await db
    .select()
    .from(employees)
    .where(like(employees.name, `%${namePart}%`))
    .limit(pageSize)
    .offset(offset);
}

// 集計クエリ
async function getEmployeeStats() {
  const result = await db
    .select({
      department: employees.department,
      count: sql<number>`count(*)`,
      avgSalary: sql<number>`avg(${employees.salary})`,
      maxSalary: sql<number>`max(${employees.salary})`
    })
    .from(employees)
    .where(eq(employees.is_active, true))
    .groupBy(employees.department);

  return result;
}

データの更新

// 単一レコードの更新
async function updateEmployeePosition(id: number, newPosition: string) {
  const result = await db
    .update(employees)
    .set({ position: newPosition })
    .where(eq(employees.id, id))
    .returning();
  return result[0];
}

// 複数フィールドの更新
async function updateEmployee(id: number, data: Partial<NewEmployee>) {
  const result = await db
    .update(employees)
    .set({
      ...data,
      updated_at: new Date()
    })
    .where(eq(employees.id, id))
    .returning();
  return result[0];
}

// 使用例
await updateEmployee(1, {
  position: 'Senior Engineer',
  salary: 7500000,
  department: 'Platform Engineering'
});

// 条件付き一括更新(昇給)
async function giveRaiseByDepartment(department: string, raisePercent: number) {
  await db
    .update(employees)
    .set({
      salary: sql`${employees.salary} * ${1 + raisePercent / 100}`,
      updated_at: new Date()
    })
    .where(
      and(
        eq(employees.department, department),
        eq(employees.is_active, true)
      )
    );
}

// 全従業員に5%昇給
await giveRaiseByDepartment('Engineering', 5);

データの削除

// 単一レコードの削除
async function deleteEmployee(id: number) {
  const result = await db
    .delete(employees)
    .where(eq(employees.id, id))
    .returning();
  return result[0];
}

// 論理削除(is_activeをfalseに設定)
async function deactivateEmployee(id: number) {
  return await db
    .update(employees)
    .set({ is_active: false, updated_at: new Date() })
    .where(eq(employees.id, id))
    .returning();
}

// 条件付き削除
async function deleteInactiveEmployees(daysInactive: number = 90) {
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - daysInactive);

  await db
    .delete(employees)
    .where(
      and(
        eq(employees.is_active, false),
        sql`${employees.updated_at} < ${cutoffDate}`
      )
    );
}

AWS LambdaやAPI Gatewayと統合する

AWSのData APIは、サーバレス環境に適しているため、Lambda関数でこれらのデータ操作を実行することが推奨されます。以下は、Lambda関数を利用したRESTful APIの実装例です。

// handlers/employees.ts
import { APIGatewayProxyHandler } from 'aws-lambda';
import { db } from '../database';
import { employees, type NewEmployee } from '../schema';
import { eq, like } from 'drizzle-orm';

// GET /employees - 全従業員取得(検索・ページネーション付き)
export const listEmployees: APIGatewayProxyHandler = async (event) => {
  try {
    const params = event.queryStringParameters || {};
    const page = parseInt(params.page || '1');
    const limit = parseInt(params.limit || '20');
    const search = params.search;
    const department = params.department;

    let query = db.select().from(employees);

    // 検索フィルタ
    if (search) {
      query = query.where(like(employees.name, `%${search}%`));
    }
    if (department) {
      query = query.where(eq(employees.department, department));
    }

    // ページネーション
    const offset = (page - 1) * limit;
    const results = await query.limit(limit).offset(offset);

    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        success: true,
        data: results,
        pagination: {
          page,
          limit,
          count: results.length
        }
      }),
    };
  } catch (error) {
    console.error('Error listing employees:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({
        success: false,
        error: 'Failed to fetch employees'
      }),
    };
  }
};

// GET /employees/{id} - 特定従業員取得
export const getEmployee: APIGatewayProxyHandler = async (event) => {
  try {
    const id = parseInt(event.pathParameters?.id || '0');

    if (!id) {
      return {
        statusCode: 400,
        body: JSON.stringify({ success: false, error: 'Invalid employee ID' }),
      };
    }

    const employee = await db
      .select()
      .from(employees)
      .where(eq(employees.id, id))
      .limit(1);

    if (employee.length === 0) {
      return {
        statusCode: 404,
        body: JSON.stringify({ success: false, error: 'Employee not found' }),
      };
    }

    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ success: true, data: employee[0] }),
    };
  } catch (error) {
    console.error('Error fetching employee:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ success: false, error: 'Internal server error' }),
    };
  }
};

// POST /employees - 新規従業員作成
export const createEmployee: APIGatewayProxyHandler = async (event) => {
  try {
    if (!event.body) {
      return {
        statusCode: 400,
        body: JSON.stringify({ success: false, error: 'Request body is required' }),
      };
    }

    const data: NewEmployee = JSON.parse(event.body);

    // バリデーション
    if (!data.name || !data.email || !data.position) {
      return {
        statusCode: 400,
        body: JSON.stringify({
          success: false,
          error: 'Name, email, and position are required'
        }),
      };
    }

    const result = await db
      .insert(employees)
      .values(data)
      .returning();

    return {
      statusCode: 201,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        success: true,
        message: 'Employee created successfully',
        data: result[0]
      }),
    };
  } catch (error: any) {
    console.error('Error creating employee:', error);

    // ユニーク制約違反のエラーハンドリング
    if (error.code === '23505') {
      return {
        statusCode: 409,
        body: JSON.stringify({
          success: false,
          error: 'Email already exists'
        }),
      };
    }

    return {
      statusCode: 500,
      body: JSON.stringify({ success: false, error: 'Failed to create employee' }),
    };
  }
};

// PUT /employees/{id} - 従業員情報更新
export const updateEmployee: APIGatewayProxyHandler = async (event) => {
  try {
    const id = parseInt(event.pathParameters?.id || '0');

    if (!id || !event.body) {
      return {
        statusCode: 400,
        body: JSON.stringify({ success: false, error: 'Invalid request' }),
      };
    }

    const data: Partial<NewEmployee> = JSON.parse(event.body);

    const result = await db
      .update(employees)
      .set({
        ...data,
        updated_at: new Date()
      })
      .where(eq(employees.id, id))
      .returning();

    if (result.length === 0) {
      return {
        statusCode: 404,
        body: JSON.stringify({ success: false, error: 'Employee not found' }),
      };
    }

    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        message: 'Employee updated successfully',
        data: result[0]
      }),
    };
  } catch (error) {
    console.error('Error updating employee:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ success: false, error: 'Failed to update employee' }),
    };
  }
};

// DELETE /employees/{id} - 従業員削除(論理削除)
export const deleteEmployee: APIGatewayProxyHandler = async (event) => {
  try {
    const id = parseInt(event.pathParameters?.id || '0');

    if (!id) {
      return {
        statusCode: 400,
        body: JSON.stringify({ success: false, error: 'Invalid ID' }),
      };
    }

    // 論理削除(is_activeをfalseに設定)
    const result = await db
      .update(employees)
      .set({ is_active: false, updated_at: new Date() })
      .where(eq(employees.id, id))
      .returning();

    if (result.length === 0) {
      return {
        statusCode: 404,
        body: JSON.stringify({ success: false, error: 'Employee not found' }),
      };
    }

    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        message: 'Employee deleted successfully'
      }),
    };
  } catch (error) {
    console.error('Error deleting employee:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ success: false, error: 'Failed to delete employee' }),
    };
  }
};

このコードをデプロイすれば、Lambda経由でAPI GatewayからHTTPリクエストに応じてPostgreSQLにアクセスできるRESTful APIエンドポイントが構築されます。

IAMポリシーの設定

AWS Data APIを使用するには、Lambda関数に適切なIAMロールとポリシーを設定する必要があります。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "rds-data:ExecuteStatement",
        "rds-data:BatchExecuteStatement",
        "rds-data:BeginTransaction",
        "rds-data:CommitTransaction",
        "rds-data:RollbackTransaction"
      ],
      "Resource": "arn:aws:rds:us-east-1:123456789012:cluster:my-aurora-cluster"
    },
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:rds-db-credentials-*"
    }
  ]
}

トランザクションの使用例

PostgreSQLでの複雑な操作にはトランザクションを使用します。

import { db } from './database';
import { employees } from './schema';

async function transferEmployee(
  employeeId: number,
  newDepartment: string,
  newSalary: number
) {
  try {
    // トランザクション開始
    await db.transaction(async (tx) => {
      // 従業員情報を更新
      await tx
        .update(employees)
        .set({
          department: newDepartment,
          salary: newSalary,
          updated_at: new Date()
        })
        .where(eq(employees.id, employeeId));

      // ログテーブルに記録(例)
      // await tx.insert(employeeLog).values({ ... });

      // すべての操作が成功したらコミット
    });

    return { success: true, message: 'Transfer completed' };
  } catch (error) {
    // エラーが発生したら自動的にロールバック
    console.error('Transaction failed:', error);
    return { success: false, error: 'Transfer failed' };
  }
}

エラー対処と注意点

1. Aurora Serverlessの制限

重要: AWS Data APIは以下のデータベースでのみ利用可能です:

  • Amazon Aurora PostgreSQL Serverless v1
  • Amazon Aurora PostgreSQL Serverless v2
  • Amazon Aurora PostgreSQL Provisioned(Data API有効化が必要)

通常のRDS PostgreSQLインスタンスでは使用できません。

2. Data API接続エラー

問題: ResourceNotFoundExceptionBadRequestException が発生する

対処法:

// 接続情報のデバッグ
console.log('Configuration:', {
  region: process.env.AWS_REGION,
  database: process.env.DATABASE_NAME,
  resourceArn: process.env.DB_RESOURCE_ARN?.substring(0, 50) + '...',
  secretArn: process.env.DB_SECRET_ARN?.substring(0, 50) + '...'
});

// エラーハンドリング
try {
  const result = await db.select().from(employees);
} catch (error: any) {
  if (error.name === 'ResourceNotFoundException') {
    console.error('Database cluster or secret not found');
  } else if (error.name === 'BadRequestException') {
    console.error('Invalid SQL or parameters');
  }
  throw error;
}

3. IAMアクセス権限エラー

問題: AccessDeniedException が発生する

対処法:

  • Lambda実行ロールに以下の権限を確認:
    • rds-data:ExecuteStatement
    • secretsmanager:GetSecretValue
  • ポリシーのリソースARNが正しいか確認
  • Secrets ManagerのシークレットがData API用に適切に設定されているか確認

4. PostgreSQL固有のエラー

ユニーク制約違反(エラーコード: 23505):

try {
  await db.insert(employees).values({ email: 'duplicate@example.com', ... });
} catch (error: any) {
  if (error.code === '23505') {
    // ユニーク制約違反の処理
    return { error: 'Email already exists' };
  }
}

外部キー制約違反(エラーコード: 23503):

if (error.code === '23503') {
  return { error: 'Referenced record does not exist' };
}

5. データ型とnullエラー

問題: NOT NULL制約違反や型不一致

対処法:

import { z } from 'zod';

// Zodスキーマでバリデーション
const employeeSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email().max(255),
  position: z.string().min(1).max(50),
  salary: z.number().int().positive().optional(),
  department: z.string().max(50).optional()
});

// Lambda関数内でバリデーション
try {
  const validated = employeeSchema.parse(JSON.parse(event.body || '{}'));
  await db.insert(employees).values(validated);
} catch (error) {
  if (error instanceof z.ZodError) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        error: 'Validation failed',
        details: error.errors
      })
    };
  }
}

6. タイムアウトとパフォーマンス

注意点:

  • Data APIのタイムアウトはデフォルトで10秒
  • Lambda関数のタイムアウトは適切に設定(推奨: 30秒以上)
  • 大量データの処理にはバッチ処理を検討
// バッチ処理の例
async function batchInsertEmployees(employeeList: NewEmployee[]) {
  const batchSize = 100;

  for (let i = 0; i < employeeList.length; i += batchSize) {
    const batch = employeeList.slice(i, i + batchSize);
    await db.insert(employees).values(batch);
    console.log(`Processed ${i + batch.length}/${employeeList.length}`);
  }
}

7. ベストプラクティス

  • 環境変数: 機密情報は環境変数で管理
  • コネクション: データベース接続はLambda関数の外部で初期化
  • エラーログ: CloudWatch Logsで詳細なエラー情報を記録
  • 型安全性: TypeScriptの型推論を最大限活用
  • テスト: ローカル開発にはAurora Serverless v2のローカルエンドポイントを使用

まとめ

Drizzle ORMとAWSのData APIを使うことで、サーバレス環境からPostgreSQLへの安全でスケーラブルなデータアクセスが可能です。Data APIはHTTPリクエストでデータベース操作ができるため、AWS LambdaやAPI Gatewayとの統合も簡単で、サーバレスアーキテクチャを最大限に活用したデータベース管理が実現します。Drizzle ORMの型安全な操作とAWSの高度なインフラを組み合わせることで、高パフォーマンスかつセキュアなデータ管理が可能です。

主な特徴:

  • 型安全性: TypeScriptの完全な型推論により、コンパイル時にエラーを検出
  • サーバレス最適化: コネクションプール不要で、Lambda関数に最適
  • Aurora Serverless対応: オンデマンドスケーリングによる柔軟な容量管理
  • IAM認証: Secrets Managerと統合したセキュアな認証
  • トランザクションサポート: ACID特性を保証する堅牢なトランザクション処理
  • PostgreSQL互換: 標準的なPostgreSQL機能をサポート

参考文献

円