MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける

    ファイルが数十件を超えると、一覧を上から眺めて探すのは現実的ではなくなります。「先月の見積書だけ見たい」「このタグが付いたものだけ」といった絞り込みと、1画面に出す件数を区切るページング(=ページ送り)は、PDF管理システムに欠かせない機能です。

    前回の第2回:ファイル情報をDBに持ち、一覧を表示するでは、PDFのファイル情報を SQLite に保存し、一覧画面を作りました。今回はその一覧に、ファイル名のキーワード検索・タグ・登録日の期間による絞り込みと、20件ずつのページングを追加します。

    「検索画面って、入力欄を並べてデータベースに問い合わせるだけでしょう?何か気を付けることがあるの?」

    結論から言うと、検索条件は「URLに残す」「サーバーで検証する」「SQLに直接埋め込まない」の3点を押さえると、使いやすく安全な検索になります。URLに条件が残っていれば、検索結果をブックマークしたり、同僚にURLで共有したりできます。この回では、Nuxt 4 と Drizzle ORM、入力検証ライブラリの zod(執筆時点で 4 系)を使って実装します。

    目次

    検索・絞り込みの設計:条件はURLのクエリ文字列に持つ

    今回できるようになること

    条件URLの例内容
    キーワード?q=見積ファイル名に「見積」を含む(部分一致)
    タグ?tag=請求書「請求書」タグが付いたファイル
    期間?from=2026-07-01&to=2026-07-31登録日が期間内(日本時間の日付で判定)
    ページ?page=220件ずつ区切った2ページ目

    条件は組み合わせられます(例:/files?q=見積&tag=2026年度&page=2)。

    なぜ条件をURLに持つのか

    検索条件を画面の中の変数だけで持つと、再読み込みで条件が消え、ブラウザの「戻る」で前の検索結果に戻れません。URLのクエリ文字列(? 以降の部分)を「検索条件の置き場所」にすると、次のことが自然にできるようになります。

    • 再読み込みしても同じ結果が出る
    • 「戻る」「進む」で検索結果を行き来できる
    • 検索結果のURLをブックマーク・共有できる
    • サーバー側で最初からその条件の結果を描画できる

    タグは別テーブルで持つ

    1つのファイルに複数のタグを付け、1つのタグを複数のファイルで使うため、「タグの一覧(tags)」と「ファイルとタグの対応(file_tags)」の2つのテーブルを追加します。ファイル情報の表に「タグ」列を作ってカンマ区切りで入れる方法もありますが、タグでの絞り込みや、後からのタグ名の変更がしにくくなります。

    タグ用のテーブルを追加する

    server/db/schema.ts(追加部分)

    export const files = sqliteTable('files', {
      // …(第2回の列定義は同じ)
    }, (table) => [
      // 一覧の並び順・期間での絞り込みに使う
      index('files_created_at_idx').on(table.createdAt),
    ])
    
    /** タグの一覧(名前は一意) */
    export const tags = sqliteTable('tags', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      name: text('name').notNull().unique(),
    })
    
    /** ファイルとタグの対応(多対多)。ファイルを消すと対応も消える */
    export const fileTags = sqliteTable('file_tags', {
      fileId: text('file_id').notNull().references(() => files.id, { onDelete: 'cascade' }),
      tagId: integer('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
    }, (table) => [
      primaryKey({ columns: [table.fileId, table.tagId] }),
      index('file_tags_tag_id_idx').on(table.tagId),
    ])

    登録日時の列には索引(インデックス=本の索引のように検索を速くする仕組み)を付けました。一覧は常に登録日時の新しい順に並べ、期間でも絞り込むためです。

    スキーマを変えたら、マイグレーションを生成して適用します。

    npm run db:generate -- --name tags   # server/db/migrations/0001_tags.sql ができる
    npm run db:migrate

    もう1つ、server/utils/db.ts に次の1行を追加しました。

    server/utils/db.ts(追加部分)

    // SQLite は既定で外部キー制約が無効なので有効にする(file_tags の cascade 削除に必要)
    sqlite.pragma('foreign_keys = ON')

    SQLite の外部キー制約(=「存在しないファイルへの対応は作れない」といったテーブル間のルール)は、互換性のため既定で無効になっており、接続ごとに有効にする必要があります。これを忘れると、ファイルを削除したときに対応するタグの行が残ってしまいます。

    📰 出典:SQLite 公式ドキュメント Foreign Key Support

    検索条件を zod で検証する

    クエリ文字列の検証ルール

    URLのクエリ文字列は、利用者が自由に書き換えられる「外から来た入力」です。page=-1 や page=abc、存在しない日付などが来ても安全に断れるよう、zod で検証ルールを定義します。

    server/utils/file-query.ts(抜粋)

    import { z } from 'zod'
    
    /** 1ページあたりの件数 */
    export const FILES_PER_PAGE = 20
    
    export const fileListQuerySchema = z.object({
      q: z.string().trim().max(100).optional(),
      tag: z.string().trim().min(1).max(30).optional(),
      from: z.iso.date().optional(),
      to: z.iso.date().optional(),
      page: z.coerce.number().int().min(1).max(10000).default(1),
    }).refine((v) => !v.from || !v.to || v.from <= v.to, {
      message: 'from は to 以前の日付にしてください',
    })
    
    /** LIKE 検索用に % と _ と \ をエスケープする(ESCAPE '\' と組み合わせて使う) */
    export function escapeLike(value: string): string {
      return value.replace(/[\\%_]/g, (c) => `\\${c}`)
    }
    
    /** 日本時間の日付(YYYY-MM-DD)の 0時0分 を Date にする */
    export function startOfDayJst(date: string): Date {
      return new Date(`${date}T00:00:00+09:00`)
    }
    • z.iso.date() は YYYY-MM-DD 形式の日付を検証します。試したところ、2026-02-30 のような存在しない日付も不正として扱われました。
    • クエリ文字列の値はすべて文字列で届くため、page は z.coerce.number() で数値に変換してから「1以上の整数」かを確認します。
    • .refine() で「開始日が終了日より後」という組み合わせの誤りも弾きます。

    📰 出典:Zod 公式ドキュメント Defining schemas

    同じファイルには、アップロード時のタグ入力(カンマ・読点区切り)を「重複なし・1つ30文字以内・10個まで」に整える parseTagInput も置いています(全体はサンプルコードを参照してください)。

    一覧APIに絞り込みとページングを追加する

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

    import { and, count, desc, eq, gte, inArray, lt, sql, type SQL } from 'drizzle-orm'
    import { fileTags, files, tags } from '../db/schema'
    
    export default defineEventHandler(async (event) => {
      // 1. クエリ文字列を zod で検証(不正なら 400)
      const parsed = await getValidatedQuery(event, (query) => fileListQuerySchema.safeParse(query))
      if (!parsed.success) {
        throw createError({ statusCode: 400, statusMessage: '検索条件が正しくありません' })
      }
      const { q, tag, from, to, page } = parsed.data
      const db = useDb()
    
      // 2. 条件を組み立てる(値はすべてプレースホルダーで渡され、SQLに直接埋め込まれない)
      const conditions: SQL[] = []
      if (q) {
        conditions.push(sql`${files.originalName} LIKE ${`%${escapeLike(q)}%`} ESCAPE '\\'`)
      }
      if (tag) {
        const taggedFileIds = db
          .select({ fileId: fileTags.fileId })
          .from(fileTags)
          .innerJoin(tags, eq(tags.id, fileTags.tagId))
          .where(eq(tags.name, tag))
        conditions.push(inArray(files.id, taggedFileIds))
      }
      if (from) {
        conditions.push(gte(files.createdAt, startOfDayJst(from)))
      }
      if (to) {
        // to の日の 23:59:59 までを含めるため「翌日0時より前」とする
        const next = startOfDayJst(to)
        next.setUTCDate(next.getUTCDate() + 1)
        conditions.push(lt(files.createdAt, next))
      }
      const where = conditions.length > 0 ? and(...conditions) : undefined
    
      // 3. 件数と、該当ページの行を取得
      const total = db.select({ value: count() }).from(files).where(where).get()?.value ?? 0
      const rows = db
        .select({ /* …第2回と同じ列 */ })
        .from(files)
        .where(where)
        .orderBy(desc(files.createdAt), desc(files.id))
        .limit(FILES_PER_PAGE)
        .offset((page - 1) * FILES_PER_PAGE)
        .all()
    
      // 4. 表示中の行のタグをまとめて取得(1行ごとに問い合わせない)
      // …(省略:rows の id で file_tags と tags を結合し、ファイルごとのタグ名配列を作る)
    
      return {
        items: rows.map((r) => ({ ...r, tags: tagsByFile.get(r.id) ?? [] })),
        total,
        page,
        perPage: FILES_PER_PAGE,
        totalPages: Math.max(1, Math.ceil(total / FILES_PER_PAGE)),
      }
    })

    実装のポイントは次のとおりです。

    • getValidatedQuery で検証する:h3 の関数で、クエリ文字列を取り出して検証関数に渡します。ここでは zod の safeParse を渡し、失敗したら「検索条件が正しくありません」とだけ返します。zod のエラー詳細をそのまま返すと内部の構造が見えてしまうためです。
    • SQLに値を直接つなげない:Drizzle の sql テンプレートや eq などに渡した値は、プレースホルダー(=SQL文とは別に値を渡す仕組み)として扱われます。文字列を連結してSQLを作ると、SQLインジェクション(=入力に仕込んだ命令でDBを不正に操作される攻撃)の原因になります。
    • % と _ をエスケープする:LIKE 検索では %(任意の文字列)と _(任意の1文字)が特別な意味を持ちます。「100%」で検索したら全件が出た、とならないよう、ESCAPE '\' を付けて文字として扱います。
    • 並び順を一意にする:登録日時が同じファイルがあると、ページをまたいで順番が入れ替わることがあります。id を2番目の並び順に加えて順序を固定しています。

    📰 出典:Drizzle ORM 公式ドキュメント Filters

    アップロード時にタグを登録する

    アップロードAPI(server/api/files.post.ts)では、任意の tags フィールドを受け取り、ファイル情報とタグを1つのトランザクション(=全部成功するか、全部取り消すかのまとまり)で登録します。

    server/api/files.post.ts(追加部分)

    // 任意の "tags" フィールド(カンマ区切り)
    const tagNames = parseTagInput(parts?.find((part) => part.name === 'tags')?.data.toString('utf8'))
    
    // …(検証・ストレージ保存は前回と同じ)
    
    // ファイル行とタグの登録を1つのトランザクションで行う(途中で失敗したら全部取り消し)
    useDb().transaction((tx) => {
      tx.insert(files).values(row).run()
      if (tagNames.length > 0) {
        tx.insert(tags).values(tagNames.map((name) => ({ name }))).onConflictDoNothing().run()
        const tagIds = tx.select({ id: tags.id }).from(tags).where(inArray(tags.name, tagNames)).all()
        tx.insert(fileTags).values(tagIds.map((t) => ({ fileId: id, tagId: t.id }))).run()
      }
    })

    すでにあるタグ名は onConflictDoNothing() で重複登録を避け、改めてIDを引き直して対応表に登録します。アップロード画面には「タグ(カンマ区切り・任意)」の入力欄を追加しました。

    一覧画面:検索フォームとページ送り

    app/composables/useFiles.ts:URLと検索条件をつなぐ

    画面側では、URLの読み書きとAPI呼び出しを composable(=画面の部品から使い回せる処理のまとまり。app/composables/ に置くと自動で使える)にまとめます。

    app/composables/useFiles.ts(抜粋)

    export function useFiles() {
      const route = useRoute()
      const router = useRouter()
    
      // APIに渡すクエリは URL のクエリそのもの(検証はサーバーの zod で行う)
      const query = computed(() => route.query)
      const { data, status, error, refresh } = useFetch('/api/files', { query })
    
      /** 現在の URL から、フォームの初期値を作る */
      function currentForm(): FileSearchForm {
        return {
          q: first(route.query.q),
          tag: first(route.query.tag),
          from: first(route.query.from),
          to: first(route.query.to),
        }
      }
    
      /** 検索条件を URL に反映する(空の条件は URL に出さない。検索し直したら1ページ目へ) */
      function search(form: FileSearchForm) {
        const next: Record<string, string> = {}
        for (const [key, value] of Object.entries(form)) {
          if (value.trim() !== '') next[key] = value.trim()
        }
        return router.push({ query: next })
      }
    
      /** ページを移動する(他の条件はそのまま) */
      function goToPage(page: number) {
        return router.push({ query: { ...route.query, page: String(page) } })
      }
    
      return { data, status, error, refresh, currentForm, search, goToPage }
    }

    useFetch の query に URL のクエリを computed で渡しているのがポイントです。URLが変わると自動でAPIを呼び直すため、「検索ボタンを押したらAPIを呼ぶ」処理を別に書く必要がありません。画面側はURLを変えるだけです。

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

    app/pages/files/index.vue:フォームと結果

    app/pages/files/index.vue(抜粋。表の列や書式の関数は前回とほぼ同じなので省略)

    <script setup lang="ts">
    // useFetch はサーバー描画時にも実行され、結果がHTMLに含まれる
    const { data, status, error, currentForm, search, goToPage } = useFiles()
    
    // フォームの入力値。URL が変わったら(戻る・進む等)フォームも合わせる
    const form = reactive(currentForm())
    const route = useRoute()
    watch(() => route.query, () => Object.assign(form, currentForm()))
    
    function onSubmit() {
      search({ ...form })
    }
    function clear() {
      search({ q: '', tag: '', from: '', to: '' })
    }
    </script>
    
    <template>
      <form class="search" @submit.prevent="onSubmit">
        <label>ファイル名 <input v-model="form.q" type="search" maxlength="100"></label>
        <label>タグ <input v-model="form.tag" type="text" maxlength="30"></label>
        <label>登録日 <input v-model="form.from" type="date"> 〜 <input v-model="form.to" type="date"></label>
        <button type="submit">検索</button>
        <button type="button" @click="clear">条件をクリア</button>
      </form>
    
      <p v-if="status === 'pending'">読み込み中…</p>
      <p v-else-if="error">
        {{ error.statusCode === 400 ? '検索条件が正しくありません。' : '一覧を取得できませんでした。' }}
      </p>
      <template v-else-if="data">
        <p>{{ data.total.toLocaleString() }} 件中 {{ data.page }} / {{ data.totalPages }} ページ</p>
        <!-- 表(タグ列はクリックするとそのタグで絞り込むリンク) -->
        <nav class="pager">
          <button type="button" :disabled="data.page <= 1" @click="goToPage(data.page - 1)">前へ</button>
          <span>{{ data.page }} / {{ data.totalPages }}</span>
          <button type="button" :disabled="data.page >= data.totalPages" @click="goToPage(data.page + 1)">次へ</button>
        </nav>
      </template>
    </template>

    登録日の入力には、ブラウザ標準の日付入力(type="date")を使っています。値は YYYY-MM-DD 形式になるので、サーバー側の検証ルールとそのまま対応します。

    動作確認の方法

    npm run db:migrate
    npm run build
    node .output/server/index.mjs

    筆者の環境では、3ページのPDFを「見積書_1.pdf〜見積書_25.pdf(タグ:見積書, 2026年度)」として25件、「請求書_1_100%.pdf」など3件(タグ:請求書、登録日時を2026年7月15日に書き換え)を登録し、APIと画面を確認しました。

    リクエスト結果
    /api/files28件中 1/2ページ、20件
    /api/files?page=28件
    /api/files?q=見積&page=225件中 2/2ページ、5件
    /api/files?q=%3件(% を含むファイル名だけ。全件にならない)
    /api/files?tag=請求書3件
    /api/files?from=2026-07-01&to=2026-07-153件(終了日当日の登録も含む)
    /api/files?to=2026-07-140件
    page=-1、page=abc、from=2026-02-30、開始日>終了日、page=1&page=2いずれも 400
    /files?q=見積&page=2 を直接開くサーバー描画のHTMLに「25 件中 2 / 2 ページ」と該当ファイルが出る
    /files?page=-1 を直接開く「検索条件が正しくありません。」と表示
    タグ11個でアップロード400

    ブラウザでフォームを操作して検索する流れ(ボタン操作・戻る/進む)は筆者の環境では実施していません。お手元で確認してください。

    つまずきやすい点

    • 日付の境界とタイムゾーン:「7月15日まで」をそのまま <= 2026-07-15 00:00 とすると、15日の登録が漏れます。「翌日0時より前」で比較し、日付は日本時間として解釈しています。サーバーのタイムゾーン設定に依存しない書き方にしておくと、クラウドに移したときにずれません。
    • OFFSET方式のページングの限界:LIMIT/OFFSET は分かりやすい反面、ページが深くなるほど遅くなり、閲覧中に新しいファイルが登録されると件がずれることがあります。数万件程度までは実用上問題になりにくいですが、それ以上なら別の方式(最後に見た行を基準に続きを取る方式)を検討します。
    • 部分一致検索の速さ:LIKE '%見積%' のような前後の部分一致は索引が効きにくく、件数が増えると遅くなります。大量のファイルで高速な検索が必要なら、SQLite の全文検索機能や専用の検索エンジンを検討します。
    • この時点ではまだ誰でも一覧を見られる:ログインは第4回、権限は第5回で追加します。

    発注者向けメモ:「検索」の範囲を要件で分けておく

    「PDFを検索できるようにしたい」という要望には、実は難しさの違う2種類が含まれています。

    種類例難しさ
    ファイル情報の検索ファイル名・タグ・登録日・登録者で探す今回の実装の範囲。DBの検索で対応できる
    本文の全文検索PDFの中に書かれた「〇〇株式会社」で探すPDFからの文字の取り出し、検索用の索引が別途必要。スキャンPDFは文字認識(OCR)も必要

    本文の全文検索は、今回の仕組みとは別の機能として見積もられるのが一般的です。要件定義の段階で、どちらを求めているのかを明確に分けてください。

    • どの項目で探すことが多いか:現場の人が普段どう探しているか(取引先名、案件番号、日付など)を聞き、それを一覧の項目やタグとして持たせます。
    • タグを誰が管理するか:自由入力にすると「見積書」「見積り」「見積」のような表記ゆれが起きます。あらかじめ決めた候補から選ぶ方式にするかを決めておきます。
    • 件数の見込み:数年分でどのくらいの件数になるかを伝えると、ページングや検索方式の設計が適切になります。

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

    • 「この検索は、ファイル名などの登録情報だけが対象ですか?PDFの本文も対象ですか?」
    • 「検索結果のURLを共有したり、ブックマークしたりできますか?」
    • 「タグの表記ゆれを防ぐ仕組みはありますか?タグ名を後から変更・統合できますか?」
    • 「5年後に件数が今の10倍になっても、検索の速さは保てますか?」

    まとめと次回予告

    第3回では、ファイル一覧に検索・絞り込み・ページングを追加しました。

    • 検索条件はURLのクエリ文字列に持ち、再読み込み・共有・戻る操作に強くする
    • クエリ文字列は zod で検証し、不正な値は 400 で断る
    • 値はプレースホルダーで渡し、LIKE の % と _ はエスケープする
    • タグは tags と file_tags の2テーブルで持ち、登録はトランザクションで行う

    次回は「ログイン機能を入れる」です。nuxt-auth-utils を使ってID・パスワードでのログインを実装し、未ログインでは一覧もAPIも使えないようにします。ファイル一覧の「登録者」列も、ここで埋まるようになります。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (1件)

      目次