• #SQLite
  • #リファクタリング
  • #スキーマ設計
  • #後方互換性
  • #Codex
  • #e2eテスト
  • #TypeScript
開発tax-assistantメモ

document_typesテーブルのスキーマリファクタリング

tax-assistantプロジェクトのdocument_typesテーブルについて、カラム名の意味を明確にし、ソート方式を改善するリファクタリングを実施した。

背景と課題

document_typesテーブルは帳票種別(レシート、売上伝票など)を管理するマスタテーブルとして機能している。当初の設計では以下の問題があった。

1. カラム名の曖昧さ

-- Before: 何を表すか分かりにくい
CREATE TABLE document_types (
  id INTEGER PRIMARY KEY,
  code TEXT NOT NULL UNIQUE,      -- 何のコード?
  name TEXT NOT NULL,             -- 内部名?表示名?
  display_order INTEGER DEFAULT 0 -- 手動管理が面倒
);
  • code: 「コード」という汎用的な名前で、用途が不明瞭
  • name: 内部識別子なのか表示用ラベルなのか分からない
  • display_order: 手動での順序管理が必要で、追加・削除時に再調整が必要

2. 仕訳ルールタブとの連携問題

仕訳ルール画面(JournalRulesTab.vue)ではURLクエリパラメータdocTypeで帳票種別を指定していたが、以下の問題があった。

// Before: パラメータ名とカラム名の不一致
const route = useRoute()
const docType = route.query.docType as string // 'receipt' など

// document_typesテーブルのcodeと照合
const type = documentTypes.find(t => t.code === docType)
  • docTypeという名前がcodeカラムを指すことが分かりにくい
  • 他の開発者が見たときに対応関係が不明

実装内容

1. カラム名の変更

SQLiteのスキーマを変更し、カラム名を明確にした。

-- After: 用途が明確
CREATE TABLE document_types (
  id INTEGER PRIMARY KEY,
  doc_category TEXT NOT NULL UNIQUE,  -- 帳票カテゴリ(識別子)
  display_name TEXT NOT NULL,         -- 表示用ラベル
  created_at TEXT DEFAULT (datetime('now'))
);
変更前変更後理由
codedoc_category「帳票カテゴリ」という用途が明確に
namedisplay_name表示用であることが明確に
display_order削除created_atでソートに変更

2. マイグレーションスクリプト

既存データを保持しながらスキーマを変更するマイグレーションを作成した。

-- migrations/005_rename_document_types_columns.sql

-- 1. 一時テーブルを作成(新しいスキーマ)
CREATE TABLE document_types_new (
  id INTEGER PRIMARY KEY,
  doc_category TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

-- 2. データを移行(display_orderの順序でcreated_atを設定)
INSERT INTO document_types_new (id, doc_category, display_name, created_at)
SELECT
  id,
  code,
  name,
  datetime('2026-01-01 00:00:00', '+' || display_order || ' seconds')
FROM document_types
ORDER BY display_order;

-- 3. 旧テーブルを削除
DROP TABLE document_types;

-- 4. 新テーブルをリネーム
ALTER TABLE document_types_new RENAME TO document_types;

-- 5. インデックスを再作成
CREATE UNIQUE INDEX idx_document_types_category ON document_types(doc_category);

ポイント:

  • display_orderの値を秒数としてcreated_atに反映し、既存の並び順を維持
  • UNIQUE制約とインデックスをdoc_categoryに設定

3. TypeScript型定義の更新

バックエンドとフロントエンドの型定義を更新した。

// types/document-type.ts

// Before
interface DocumentType {
  id: number
  code: string
  name: string
  display_order: number
}

// After
interface DocumentType {
  id: number
  doc_category: string  // 'receipt' | 'sales_slip' | ...
  display_name: string  // 'レシート' | '売上伝票' | ...
  created_at: string
}

4. APIエンドポイントの更新

GET /api/document-typesのレスポンス形式を更新した。

// server/api/document-types.get.ts

export default defineEventHandler(async (event) => {
  const db = getDb()

  // Before
  const types = db.prepare(`
    SELECT id, code, name, display_order
    FROM document_types
    ORDER BY display_order
  `).all()

  // After
  const types = db.prepare(`
    SELECT id, doc_category, display_name, created_at
    FROM document_types
    ORDER BY created_at
  `).all()

  return types
})

5. 仕訳ルールタブとの連携改善

URLクエリパラメータをdocTypeからdocTypeIdに変更し、ID参照方式に統一した。

// composables/useJournalRules.ts

// Before: codeで検索(曖昧)
const route = useRoute()
const docType = route.query.docType as string
const currentType = documentTypes.value.find(t => t.code === docType)

// After: IDで検索(明確)
const docTypeId = Number(route.query.docTypeId)
const currentType = documentTypes.value.find(t => t.id === docTypeId)
<!-- JournalRulesTab.vue -->

<!-- Before -->
<router-link
  v-for="type in documentTypes"
  :key="type.id"
  :to="{ query: { docType: type.code } }"
>
  {{ type.name }}
</router-link>

<!-- After -->
<router-link
  v-for="type in documentTypes"
  :key="type.id"
  :to="{ query: { docTypeId: type.id } }"
>
  {{ type.display_name }}
</router-link>

6. 後方互換性の実装

既存のブックマークやリンクが壊れないよう、古いパラメータ形式をリダイレクトする処理を追加した。

// middleware/legacy-params.ts

export default defineNuxtRouteMiddleware((to, from) => {
  // 古い docType パラメータが使われている場合
  if (to.query.docType && !to.query.docTypeId) {
    const docType = to.query.docType as string

    // doc_category から id を解決
    const typeMap: Record<string, number> = {
      'receipt': 1,
      'sales_slip': 2,
      // 必要に応じて追加
    }

    const docTypeId = typeMap[docType]
    if (docTypeId) {
      // 新しいパラメータ形式にリダイレクト
      const newQuery = { ...to.query, docTypeId }
      delete (newQuery as Record<string, unknown>).docType

      return navigateTo({
        path: to.path,
        query: newQuery
      }, { replace: true })
    }
  }
})

ミドルウェアの適用:

// pages/journal-rules.vue
definePageMeta({
  middleware: ['legacy-params']
})

Codexレビューの活用

今回のリファクタリングでは、Codex CLI(GPT-5.2)によるレビューを2回実施した。

第1回レビュー: スキーマ設計

マイグレーションスクリプトのドラフトをレビュー依頼した。

指摘事項:

指摘対応
created_atのデフォルト値で順序を維持する方法が不明瞭display_orderを秒数として加算する方式を採用
トランザクション境界がないBEGIN/COMMITで囲むよう修正
ロールバック手順がないバックアップテーブル作成のオプションを追加

修正後のマイグレーション:

-- 安全なマイグレーション(トランザクション付き)
BEGIN TRANSACTION;

-- バックアップ(ロールバック用)
CREATE TABLE document_types_backup AS SELECT * FROM document_types;

-- ... マイグレーション処理 ...

COMMIT;

-- 問題があれば: DROP TABLE document_types; ALTER TABLE document_types_backup RENAME TO document_types;

第2回レビュー: 後方互換性

ミドルウェアによるリダイレクト処理をレビュー依頼した。

指摘事項:

指摘対応
ハードコードされたtypeMapはメンテナンスが困難APIから動的に取得する方式を検討(今回は見送り)
リダイレクトループの可能性docTypeIdが既に存在する場合はスキップする条件を追加
ログ出力がない開発環境でのみ警告ログを出力するよう追加
// 改善版ミドルウェア
export default defineNuxtRouteMiddleware((to, from) => {
  if (to.query.docType && !to.query.docTypeId) {
    // 開発環境でのみ警告
    if (import.meta.dev) {
      console.warn(
        `[legacy-params] Deprecated parameter 'docType' used. ` +
        `Please use 'docTypeId' instead.`
      )
    }

    // ... リダイレクト処理 ...
  }
})

e2eテストの追加

Playwrightを使用したe2eテストを追加し、リファクタリング後の動作を検証した。

テストケース

// tests/e2e/journal-rules.spec.ts

import { test, expect } from '@playwright/test'

test.describe('仕訳ルールタブ', () => {
  test('帳票種別の切り替えが正常に動作する', async ({ page }) => {
    await page.goto('/journal-rules')

    // デフォルトで最初の帳票種別が選択されている
    const firstTab = page.locator('[data-testid="doc-type-tab"]').first()
    await expect(firstTab).toHaveClass(/active/)

    // 2番目のタブをクリック
    const secondTab = page.locator('[data-testid="doc-type-tab"]').nth(1)
    await secondTab.click()

    // URLパラメータが更新される
    await expect(page).toHaveURL(/docTypeId=2/)

    // タブのアクティブ状態が切り替わる
    await expect(secondTab).toHaveClass(/active/)
    await expect(firstTab).not.toHaveClass(/active/)
  })

  test('後方互換性: 旧パラメータがリダイレクトされる', async ({ page }) => {
    // 旧パラメータ形式でアクセス
    await page.goto('/journal-rules?docType=receipt')

    // 新パラメータ形式にリダイレクトされる
    await expect(page).toHaveURL(/docTypeId=1/)
    await expect(page).not.toHaveURL(/docType=/)

    // 正しい帳票種別が選択されている
    const receiptTab = page.locator('[data-testid="doc-type-tab"]').first()
    await expect(receiptTab).toHaveClass(/active/)
  })

  test('存在しない帳票種別IDでエラーにならない', async ({ page }) => {
    await page.goto('/journal-rules?docTypeId=999')

    // エラーページにならない
    await expect(page.locator('h1')).not.toContainText('Error')

    // フォールバックで最初の帳票種別が選択される
    const firstTab = page.locator('[data-testid="doc-type-tab"]').first()
    await expect(firstTab).toHaveClass(/active/)
  })
})

テストデータのセットアップ

// tests/e2e/fixtures/document-types.ts

export const testDocumentTypes = [
  { id: 1, doc_category: 'receipt', display_name: 'レシート' },
  { id: 2, doc_category: 'sales_slip', display_name: '売上伝票' },
]

// tests/e2e/global-setup.ts
import { testDocumentTypes } from './fixtures/document-types'

export default async function globalSetup() {
  // テスト用DBにシードデータを投入
  const db = getTestDb()

  db.exec('DELETE FROM document_types')

  const stmt = db.prepare(`
    INSERT INTO document_types (id, doc_category, display_name, created_at)
    VALUES (?, ?, ?, datetime('now'))
  `)

  for (const type of testDocumentTypes) {
    stmt.run(type.id, type.doc_category, type.display_name)
  }
}

CI統合

GitHub Actionsでe2eテストを実行するワークフローを追加した。

# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    paths:
      - 'apps/web/**'
      - 'tests/e2e/**'

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: pnpm install

      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps chromium

      - name: Run E2E tests
        run: pnpm test:e2e

      - name: Upload test results
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

フロントエンドの修正箇所

以下のファイルでカラム名の参照を更新した。

コンポーネント

// BatchDocumentTypeList.vue
interface Props {
  documentTypes: DocumentType[]
  selectedType: string  // doc_category を受け取る
}

// 表示名の参照
<li v-for="type in documentTypes" :key="type.id">
  {{ type.display_name }}  <!-- namedisplay_name -->
</li>

Composables

// composables/useDocumentTypes.ts

export const useDocumentTypes = () => {
  const documentTypes = ref<DocumentType[]>([])

  const fetchTypes = async () => {
    const response = await $fetch('/api/document-types')
    documentTypes.value = response
  }

  // カテゴリからIDを取得
  const getIdByCategory = (category: string): number | undefined => {
    return documentTypes.value.find(t => t.doc_category === category)?.id
  }

  // IDから表示名を取得
  const getDisplayName = (id: number): string => {
    return documentTypes.value.find(t => t.id === id)?.display_name ?? ''
  }

  return {
    documentTypes,
    fetchTypes,
    getIdByCategory,
    getDisplayName
  }
}

ソート方式の変更

display_orderを削除し、created_atによるソートに変更した理由と実装。

理由

  1. メンテナンスコストの削減: 新しい帳票種別を追加する際、既存のdisplay_orderを調整する必要がなくなった
  2. 自然な順序: 登録順=表示順という直感的な並び
  3. 競合の回避: 複数人で同時に追加しても順序が衝突しない

実装

-- 新しい帳票種別の追加(display_orderの指定不要)
INSERT INTO document_types (doc_category, display_name)
VALUES ('invoice', '請求書');
-- created_atは自動的に現在時刻が設定される
// 取得時のソート
const types = db.prepare(`
  SELECT id, doc_category, display_name, created_at
  FROM document_types
  ORDER BY created_at ASC
`).all()

カスタム順序が必要な場合

将来的にカスタム順序が必要になった場合は、以下のアプローチを検討する。

// フロントエンドでの並べ替え
const sortOrder = ['receipt', 'sales_slip', 'invoice', 'expense']

const sortedTypes = computed(() => {
  return [...documentTypes.value].sort((a, b) => {
    const indexA = sortOrder.indexOf(a.doc_category)
    const indexB = sortOrder.indexOf(b.doc_category)
    return indexA - indexB
  })
})

まとめ

今回のリファクタリングで、document_typesテーブルの設計が改善され、コードの可読性とメンテナンス性が向上した。

改善点:

項目BeforeAfter
カラム名code, namedoc_category, display_name
ソート方式display_order(手動管理)created_at(自動)
URLパラメータdocType(文字列)docTypeId(ID)
後方互換性なしミドルウェアでリダイレクト
テストなしe2eテストを追加

Codexレビューで得られた知見:

  • マイグレーションにはトランザクション境界を明示する
  • ロールバック手順を用意しておく
  • リダイレクトループの防止条件を入れる
  • 開発環境でのみ警告ログを出力する

今後の展望:

  • 帳票種別の追加UIの実装
  • 帳票種別ごとのアイコン設定
  • 帳票種別の論理削除対応