MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する

    PDFを保存できるようになったら、次に必要なのは「何が保存されているかを一覧で見る」機能です。フォルダの中身を並べるだけでも一覧は作れますが、業務システムとして検索や権限管理まで見据えるなら、ファイルの情報はデータベース(DB)に持たせるのが基本です。

    前回の第1回:PDFをアップロードして安全に保存するでは、アップロードされたファイルをサーバー側で検証し、UUIDのファイル名で保存しました。今回は、そのファイルの情報(元のファイル名・サイズ・ページ数・登録日時など)を SQLite に保存し、一覧画面を作ります。

    「保存フォルダの中身をそのまま一覧に出せばいいのでは?なぜわざわざデータベースが必要なの?」

    結論から言うと、PDF本体は「ストレージ」、ファイルの情報は「DB」と分けて持つのが、検索・権限・版管理を後から足せる作り方です。保存名はUUIDなので、フォルダを見ても元のファイル名は分かりません。この回では、Nuxt 4 のサーバーAPIから Drizzle ORM と SQLite を使ってファイル情報を登録・取得し、アップロード後に一覧へ表示されるところまでを実装します。

    目次

    PDFのファイル情報をDBで管理する理由

    本体とファイル情報を分けると何がうれしいか

    ファイル管理の仕組みを「本棚(ストレージ)」と「貸出台帳(DB)」に例えると分かりやすくなります。

    置き場所持つもの得意なこと
    ストレージ(今は uploads/、本番はS3)PDF本体(バイナリ)大きなデータを安く・確実に置く
    DB(SQLite)元のファイル名、サイズ、ページ数、登録者、登録日時など並べ替え・絞り込み・件数の集計、権限の判定

    一覧や検索のたびにPDFを開いてページ数を数えていたら、ファイルが増えるほど遅くなります。登録時に一度だけ調べてDBに書いておけば、一覧は表を読むだけで済みます。次回の検索・絞り込み、第4回以降のログイン・権限、第7回の「版」の管理も、すべてこのDBの上に積み上げます。

    SQLite と Drizzle ORM を選んだ理由

    • SQLite:DBサーバーを別に立てず、1つのファイル(今回は data/app.db)にデータを保存できるDBです。社内の小〜中規模の利用なら十分に動き、開発環境の準備も簡単です。Node.js から使うドライバは better-sqlite3(執筆時点で 13 系)を使います。
    • Drizzle ORM:ORM(=SQLを直接書かずに、プログラムの関数でDBを操作できる仕組み)の1つです。テーブルの定義を TypeScript で書くと、取得結果にも型が付くため、列名の打ち間違いなどをビルド前に気付けます。

    Drizzle の公式ドキュメントでは、執筆時点で 1.0 の候補版(RC)のインストールが案内されていますが、npm の標準(latest)で配布されているのは 0 系の安定版です。この連載では安定版を固定して使います。

    📰 出典:Drizzle ORM 公式ドキュメント Get Started with SQLite

    ファイル情報のテーブルを定義する

    インストールするパッケージ

    npm install --save-exact drizzle-orm better-sqlite3 pdf-lib
    npm install -D --save-exact drizzle-kit @types/better-sqlite3

    pdf-lib は、アップロード時にページ数を数えるために使います(第7回以降の編集機能でも使う予定のライブラリです)。サンプルでは実際に入ったバージョンを package.json に固定しています。筆者の環境(Node.js 24、WSL2 の Linux)では、better-sqlite3 は配布済みのビルド済みバイナリで動き、C++ のコンパイル環境は不要でした。

    server/db/schema.ts:files テーブル

    server/db/schema.ts

    import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
    
    /** アップロードされたPDFのメタデータ(PDF本体はストレージ側に保存) */
    export const files = sqliteTable('files', {
      /** UUID。URLやAPIで使うID */
      id: text('id').primaryKey(),
      /** 利用者がアップロードしたときの元のファイル名(表示・ダウンロード名用) */
      originalName: text('original_name').notNull(),
      /** ストレージ上の保存キー(UUID.pdf)。利用者には見せない */
      storageKey: text('storage_key').notNull().unique(),
      /** バイト数 */
      size: integer('size').notNull(),
      /** ページ数 */
      pageCount: integer('page_count').notNull(),
      /** 暗号化(パスワード付き)PDFかどうか。編集機能の可否判定に使う */
      isEncrypted: integer('is_encrypted', { mode: 'boolean' }).notNull().default(false),
      /** 登録者。ログイン機能(第4回)までは null */
      uploadedBy: text('uploaded_by'),
      /** 登録日時 */
      createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
    })
    
    export type FileRow = typeof files.$inferSelect
    export type NewFileRow = typeof files.$inferInsert

    ポイントは次の3つです。

    • 元のファイル名と保存キーを別の列にする:画面に出すのは originalName、ストレージの読み書きに使うのは storageKey です。
    • 暗号化の有無を記録する:pdf-lib はパスワード付きPDFの編集に対応していないため、後の編集機能で「このファイルは編集できません」と判定できるようにしておきます。
    • 登録者の列を先に用意する:ログイン機能は第4回で追加するので、今は空(null)のままにしておきます。

    SQLite には日付型がないため、createdAt は mode: 'timestamp' で「数値として保存し、読み出すと Date になる」形にしています。この形式は秒単位で保存されるため、ミリ秒は切り捨てられます。

    drizzle.config.ts とマイグレーション

    テーブルの定義から、実際にDBにテーブルを作るSQL(マイグレーション=DBの構造変更の手順書)を生成するのが drizzle-kit です。

    drizzle.config.ts

    import { mkdirSync } from 'node:fs'
    import { dirname } from 'node:path'
    import { defineConfig } from 'drizzle-kit'
    
    // drizzle-kit(マイグレーションの生成・適用)用の設定。アプリ本体は runtimeConfig.databasePath を使う
    const databasePath = process.env.NUXT_DATABASE_PATH ?? './data/app.db'
    // better-sqlite3 はフォルダまでは作らないため、先に作っておく
    mkdirSync(dirname(databasePath), { recursive: true })
    
    export default defineConfig({
      dialect: 'sqlite',
      schema: './server/db/schema.ts',
      out: './server/db/migrations',
      dbCredentials: {
        url: databasePath,
      },
    })

    package.json に次の2つのスクリプトを追加しました。

    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"

    npm run db:generate を実行すると server/db/migrations/0000_init.sql が作られ、npm run db:migrate でDBに適用されます。生成されたSQLは Git にコミットし、どの環境でも同じ手順でテーブルを作れるようにします。なお、data/ フォルダ(DBファイル)は .gitignore に追加しています。

    サーバーからDBを使う

    server/utils/db.ts:接続を1つだけ作る

    server/utils/db.ts

    import { mkdirSync } from 'node:fs'
    import { dirname, resolve } from 'node:path'
    import Database from 'better-sqlite3'
    import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
    import * as schema from '../db/schema'
    
    let db: BetterSQLite3Database<typeof schema> | undefined
    
    /** Drizzle のDBインスタンスを返す(プロセス内で1つだけ作る) */
    export function useDb(): BetterSQLite3Database<typeof schema> {
      if (!db) {
        const path = resolve(useRuntimeConfig().databasePath)
        mkdirSync(dirname(path), { recursive: true })
        const sqlite = new Database(path)
        // 読み書きの同時実行に強い WAL モードにする
        sqlite.pragma('journal_mode = WAL')
        db = drizzle({ client: sqlite, schema })
      }
      return db
    }

    DBファイルの場所は nuxt.config.ts の runtimeConfig.databasePath(既定 ./data/app.db、環境変数 NUXT_DATABASE_PATH で上書き)から読みます。server/utils/ に置いたので、APIのファイルからは import なしで useDb() を呼べます。

    server/utils/pdf/inspect.ts:ページ数を数える

    server/utils/pdf/inspect.ts

    import { PDFDocument } from 'pdf-lib'
    
    export interface PdfInfo {
      pageCount: number
      isEncrypted: boolean
    }
    
    export async function inspectPdf(data: Uint8Array): Promise<PdfInfo> {
      try {
        // 暗号化PDFは既定では読み込みエラーになるため、情報取得だけの目的で ignoreEncryption を付ける
        // updateMetadata: false … 読み込み時に Producer 等のメタデータを書き換えない
        const doc = await PDFDocument.load(data, { ignoreEncryption: true, updateMetadata: false })
        return { pageCount: doc.getPageCount(), isEncrypted: doc.isEncrypted }
      } catch {
        throw createError({ statusCode: 400, statusMessage: 'PDFとして読み込めませんでした(ファイルが壊れている可能性があります)' })
      }
    }

    第1回の「先頭が %PDF- か」のチェックに加えて、pdf-lib で実際に読み込めるかを確認することになるため、先頭だけ正しい壊れたファイルもここで弾けます。

    📰 出典:pdf-lib 公式ドキュメント PDFDocument

    アップロードAPIにDB登録を追加する

    第1回の server/api/files.post.ts に、ページ数の確認とDB登録を追加します(前半の検証部分は第1回と同じなので省略)。

    server/api/files.post.ts(抜粋)

    import { randomUUID } from 'node:crypto'
    import { files } from '../db/schema'
    
    export default defineEventHandler(async (event) => {
      // …(1〜2. Content-Length の確認と multipart の読み取りは第1回と同じ)
    
      // 3. サイズ・拡張子・MIME・先頭バイトを検証し、pdf-lib で読めるか(ページ数)を確認
      assertPdfFile(file, maxUploadBytes)
      const info = await inspectPdf(file.data)
    
      // 4. 保存名はUUID。ユーザーが送ったファイル名はパスに使わない
      const id = randomUUID()
      const storageKey = `${id}.pdf`
      const storage = useFileStorage()
      await storage.put(storageKey, file.data, 'application/pdf')
    
      // 5. メタデータをDBに登録。失敗したら保存済みの本体を消して不整合を残さない
      const row = {
        id,
        originalName: sanitizeFileName(file.filename ?? ''),
        storageKey,
        size: file.data.length,
        pageCount: info.pageCount,
        isEncrypted: info.isEncrypted,
        uploadedBy: null,
        createdAt: new Date(),
      }
      try {
        useDb().insert(files).values(row).run()
      } catch (err) {
        await storage.delete(storageKey)
        throw err
      }
    
      setResponseStatus(event, 201)
      return {
        id: row.id,
        originalName: row.originalName,
        size: row.size,
        pageCount: row.pageCount,
      }
    })

    「ストレージに保存したのにDB登録に失敗した」場合、どこからも参照されないファイルがストレージに残ってしまいます。そのため、DB登録に失敗したら保存したファイルを消すようにしています。

    server/api/files.get.ts:一覧API

    server/api/files.get.ts

    import { desc } from 'drizzle-orm'
    import { files } from '../db/schema'
    
    /** ファイル一覧(新しい順)。検索・ページングは第3回で追加する */
    export default defineEventHandler(() => {
      return useDb()
        .select({
          id: files.id,
          originalName: files.originalName,
          size: files.size,
          pageCount: files.pageCount,
          isEncrypted: files.isEncrypted,
          uploadedBy: files.uploadedBy,
          createdAt: files.createdAt,
        })
        .from(files)
        .orderBy(desc(files.createdAt))
        .limit(100)
        .all()
    })

    返す列を明示しているのがポイントです。storageKey(保存先の内部的な名前)は画面に不要なので返しません。「必要な情報だけを返す」を最初から習慣にしておくと、情報の出しすぎを防げます。件数は仮に100件までにしており、次回ページングに置き換えます。

    一覧画面を作る

    app/pages/files/index.vue(スタイルは省略)

    <script setup lang="ts">
    useHead({ title: 'ファイル一覧 | PDF管理システム' })
    
    // サーバーAPIの戻り値の型は Nuxt が推論する(createdAt は JSON で文字列になる)
    const { data: rows, status, error, refresh } = await useFetch('/api/files')
    
    function formatSize(bytes: number): string {
      if (bytes < 1024) return `${bytes} B`
      if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
      return `${(bytes / 1024 / 1024).toFixed(1)} MB`
    }
    
    const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
      dateStyle: 'medium',
      timeStyle: 'short',
      timeZone: 'Asia/Tokyo',
    })
    function formatDate(value: string): string {
      return dateFormatter.format(new Date(value))
    }
    </script>
    
    <template>
      <section>
        <h1>ファイル一覧</h1>
        <p>
          <NuxtLink to="/files/upload">PDFをアップロード</NuxtLink>
          / <button type="button" @click="() => refresh()">再読み込み</button>
        </p>
        <p v-if="status === 'pending'">読み込み中…</p>
        <p v-else-if="error">一覧を取得できませんでした。</p>
        <p v-else-if="!rows || rows.length === 0">まだファイルがありません。</p>
        <table v-else class="files">
          <thead>
            <tr>
              <th>ファイル名</th>
              <th>サイズ</th>
              <th>ページ数</th>
              <th>登録者</th>
              <th>登録日時</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="row in rows" :key="row.id">
              <td>
                {{ row.originalName }}
                <small v-if="row.isEncrypted">(パスワード付き)</small>
              </td>
              <td class="num">{{ formatSize(row.size) }}</td>
              <td class="num">{{ row.pageCount }}</td>
              <td>{{ row.uploadedBy ?? '—' }}</td>
              <td>{{ formatDate(row.createdAt) }}</td>
            </tr>
          </tbody>
        </table>
      </section>
    </template>

    useFetch は、ページを表示するときにサーバー側でAPIを呼び、結果をHTMLに埋め込んでから返してくれる Nuxt の関数です。/api/files のように自分のAPIを指定すると、戻り値の型も自動で推論されます。ただし、サーバーで Date だった createdAt は JSON を通るので文字列になっている点に注意してください(型もそのように推論されます)。日時の表示は、サーバーとブラウザで結果がずれないよう、タイムゾーンを Asia/Tokyo に固定しています。

    📰 出典:Nuxt 公式ドキュメント useFetch

    ほかに、アップロード画面の結果表示にページ数と「ファイル一覧へ」のリンクを、共通レイアウトのナビに「ファイル一覧」を追加しています。

    動作確認の方法

    npm install
    npm run db:migrate        # data/app.db に files テーブルを作る
    npm run build
    node .output/server/index.mjs
    curl -F "file=@sample.pdf;filename=見積書_2026.pdf" http://localhost:3000/api/files
    curl http://localhost:3000/api/files

    筆者の環境では次のとおり確認しました。

    確認内容結果
    3ページのPDFを日本語のファイル名でアップロード201。pageCount: 3 が返り、一覧APIにも同じ内容が出る
    /files をサーバーで表示元のファイル名・サイズ・ページ数・登録日時の表が出る
    サーバーを再起動してから一覧を取得同じデータが残っている(SQLiteに保存されている)
    先頭だけ %PDF- で中身が壊れたファイル400(PDFとして読み込めない)
    パスワード付きPDF201。ページ数が取得でき、isEncrypted: true になる
    開発サーバー(npm run dev)での一覧API本番ビルドと同じ結果

    ブラウザでの画面操作(ファイルを選んでアップロードし、一覧に移動する流れ)は筆者の環境では実施していません。お手元で確認してください。

    つまずきやすい点

    • npm run db:migrate を忘れる:テーブルがないまま起動すると、一覧APIがエラーになります。新しい環境では最初に実行してください。
    • DBファイルとアップロード先をバックアップの対象にする:SQLiteはファイル1つですが、WALモードでは app.db-wal などの付属ファイルもできます。運用中のバックアップは、ファイルのコピーではなくSQLiteのバックアップ機能を使うのが安全です(第11回で扱います)。
    • drizzle-kit の脆弱性警告:執筆時点では、npm audit で drizzle-kit が依存するパッケージについて「moderate」の警告が出ます。drizzle-kit は開発時にマイグレーションを作るための道具で、本番のサーバーには含まれませんが、更新状況は定期的に確認してください。
    • 複数台のサーバーで動かす場合:SQLite は1台のサーバーで使う前提のDBです。サーバーを複数台に増やす構成なら、PostgreSQL などへの移行を検討します。

    発注者向けメモ:「一覧に何を出すか」は早めに決める

    一覧画面は「とりあえず作って後で直す」がしやすく見えますが、表示する項目によっては、DBに保存する情報そのものを増やす必要があります。後から列を増やすと、DBの構造変更や過去データの扱い(空欄のままにするか、さかのぼって埋めるか)が発生します。

    • 一覧に出したい項目を現場に聞く:ファイル名、登録者、登録日時のほかに、取引先名・案件番号・書類の種類など、業務で探すときの手がかりを洗い出します。
    • ファイル名のルールがあるか確認する:「日付_取引先_書類名.pdf」のような社内ルールがあれば、それを項目として分けて持つかを検討します。
    • 件数の見込み:数千件と数十万件では、一覧の表示方法や検索の作りが変わります。
    • パスワード付きPDFの扱い:保存はできても編集できない、という仕様でよいかを確認します。

    打ち合わせでは、次のように聞いてみてください。

    • 「一覧に表示する項目を後から追加する場合、どのくらいの作業になりますか?過去に登録したデータはどうなりますか?」
    • 「データベースとPDF本体のバックアップは、それぞれどのように取る予定ですか?」
    • 「利用者が増えたり、サーバーを増やしたりする場合、今のデータベースのままで対応できますか?」

    まとめと次回予告

    第2回では、PDFのファイル情報をDBに保存し、一覧を表示できるようにしました。

    • PDF本体はストレージ、ファイル情報はDB(SQLite)に分けて持つ
    • Drizzle ORM でテーブルを TypeScript で定義し、drizzle-kit でマイグレーションを生成・適用する
    • アップロード時に pdf-lib でページ数と暗号化の有無を調べ、DBに登録する
    • 一覧APIは必要な列だけを返し、useFetch で画面に表示する

    次回は「検索・絞り込み・ページングをつける」です。ファイル名のキーワード、タグ、期間での絞り込みと、ページ送りを実装します。検索条件は URL に残し、zod で入力を検証します。

    この連載の記事一覧

    この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次