2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ユニークビジョン株式会社Advent Calendar 2024

Day 9

テーブル定義をもとにWebアプリケーションを自動生成する

Last updated at Posted at 2024-12-14

概要

Web アプリケーションのプロトタイプを容易に作ることができる環境整備を個人的に進めています。この記事では、その計画の途中経過を共有します。

下記のようなマークダウン形式のテーブル定義をもとに、シンプルな Web アプリケーションを自動生成できる環境を構築しました。

erd.md
# Tables
## Users
- id: number
- name: string
- email: string
- created_at: Date
- updated_at: Date

## Tasks
- id: number
- name: string
- description: string
- due_date: Date
- priority: number
- created_at: Date
- updated_at: Date

用意する必要があるのは erd.md だけで、スクリプトを実行するとローカル環境でこのようなページが立ち上がります。

画面左側のサイドバーで操作したいテーブルを選択すると、登録されているデータの一覧が表示され、新規登録ボタンからデータを追加したり、削除ボタンでデータを削除したりできます。データの更新は今後実装する予定です。見た目も整えていきたいです。

Screenshot 2024-12-15 at 0.03.04.png

目的

自動生成による Web アプリケーション開発環境を整備しているのは、何かのプロトタイプを作りたくなった時に簡単に実現できるようにすることが目的です。

このようなプロトタイプ開発には Claude など LLM を利用していきたいと考えています。以前 Claude を用いてプロトタイプ開発を行ったのですが、(安直な方法で進めると) システムが大きくなったときに以下のような問題に遭遇しました。

  • システム全体のコードをプロンプトに含めるとトークン数の消費が激しい
  • プロンプトに含めるコードを減らすと他のファイルとの整合性を保つことが難しい

その解決策として、開発タスクを疎結合に分解しそれぞれを独立したプロンプトとして LLM を利用する方法を考えました。その方法を試してみると確かに上記の課題の解消に繋がったのですが、各システム特有のコードだけではなく単純な CRUD の API など同じようなコードも毎回 LLM で生成する点が気になりました。

(割とうまく行きますが) 毎回同じようなコードにも関わらずデバッグが必要になることがあるのは面倒だと感じたので、ルールベースで生成できる部分はテンプレートエンジンを用いて自動生成したいと考え、この記事に書いたような環境整備を進めています。

自動生成でアプリケーションのベースを作成できるようになったら、そこにシステム特有の仕様を LLM で開発してマージすることによって、快適でスケーラブルなプロトタイプ開発環境を実現できるのではないかと考えています。

主な使用技術

  • バックエンド: Ruby on Rails, SQLite
  • フロントエンド: Vue, Typescript
  • 自動生成スクリプト: Python

自動生成

erd.md から自動生成は Python の Jinja2 というテンプレートエンジンを利用しています。マークダウンをパースしてテーブル名・カラム名・型を取得し、テンプレートをもとにファイルを生成します。テーブル名とカラム名は単数形・複数形や snake_case, PascalCase, camelCase に対応するものを作成し、型は Typescript, Ruby, SQLite のものを作成します。

テンプレートファイルの例
controller.rb
module Api
  module V1
    class {{ table.plural_name | pascalcase }}Controller < ApplicationController
      before_action :set_{{ table.name | lower }}, only: [:show, :update, :destroy]

      # GET /api/v1/{{ table.plural_name | lower }}
      def index
        {{ table.plural_name | lower }} = {{ table.name | pascalcase }}.all
        render json: {
          status: 'success',
          items: {{ table.plural_name | lower }},
          count: {{ table.plural_name | lower }}.count
        }
      end

      # GET /api/v1/{{ table.plural_name | lower }}/:id
      def show
        render json: {
          status: 'success',
          data: @{{ table.name | lower }}
        }
      end

      # POST /api/v1/{{ table.plural_name | lower }}
      def create
        {{ table.name | lower }} = {{ table.name | pascalcase }}.new({{ table.name | lower }}_params)
        if {{ table.name | lower }}.save
          render json: {
            status: 'success',
            data: {{ table.name | lower }}
          }, status: :created
        else
          render json: {
            status: 'error',
            message: {{ table.name | lower }}.errors.full_messages
          }, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /api/v1/{{ table.plural_name | lower }}/:id
      def update
        if @{{ table.name | lower }}.update({{ table.name | lower }}_params)
          render json: {
            status: 'success',
            data: @{{ table.name | lower }}
          }
        else
          render json: {
            status: 'error',
            message: @{{ table.name | lower }}.errors.full_messages
          }, status: :unprocessable_entity
        end
      end

      # DELETE /api/v1/{{ table.plural_name | lower }}/:id
      def destroy
        @{{ table.name | lower }}.destroy
        render json: {
          status: 'success',
          message: '{{ table.name | pascalcase }} was successfully deleted'
        }
      end

      private

      def set_{{ table.name | lower }}
        @{{ table.name | lower }} = {{ table.name | pascalcase }}.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: {
          status: 'error',
          message: '{{ table.name | pascalcase }} not found'
        }, status: :not_found
      end

      def {{ table.name | lower }}_params
        params.require(:{{ table.name | lower }}).permit(:name, :email)
      end
    end
  end
end

erd.md から自動生成されるファイル一覧

自動生成されるファイルの一覧は Users 関連だと以下の通りです。自分がローカル環境で動かす前提なので、今のところエラーハンドリングなどはほとんど実装しない方針です。

バックエンド

routes.rb
config/routes.rb
Rails.application.routes.draw do
    namespace :api do
      namespace :v1 do
        get 'health_check', to: 'health#check'
        resources :users
        resources :tasks
      end
    end
  end
user.rb
app/models/user.rb
class User < ApplicationRecord
end
users_controller.rb
app/api/v1/users_controller.rb
module Api
  module V1
    class UsersController < ApplicationController
      before_action :set_user, only: [:show, :update, :destroy]

      # GET /api/v1/users
      def index
        users = User.all
        render json: {
          status: 'success',
          items: users,
          count: users.count
        }
      end

      # GET /api/v1/users/:id
      def show
        render json: {
          status: 'success',
          data: @user
        }
      end

      # POST /api/v1/users
      def create
        user = User.new(user_params)
        if user.save
          render json: {
            status: 'success',
            data: user
          }, status: :created
        else
          render json: {
            status: 'error',
            message: user.errors.full_messages
          }, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /api/v1/users/:id
      def update
        if @user.update(user_params)
          render json: {
            status: 'success',
            data: @user
          }
        else
          render json: {
            status: 'error',
            message: @user.errors.full_messages
          }, status: :unprocessable_entity
        end
      end

      # DELETE /api/v1/users/:id
      def destroy
        @user.destroy
        render json: {
          status: 'success',
          message: 'User was successfully deleted'
        }
      end

      private

      def set_user
        @user = User.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: {
          status: 'error',
          message: 'User not found'
        }, status: :not_found
      end

      def user_params
        params.require(:user).permit(:name, :email)
      end
    end
  end
end
{timestamp}_create_users.rb
db/migrate/{timestamp}_create_users.rb
class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email

      t.timestamps
    end
  end
end

フロントエンド

database.ts
src/router/routes/database.ts
import { RouteRecordRaw } from 'vue-router'
import BaseDataView from '@/views/BaseDataView.vue'
import UsersDataView from '@/views/UsersDataView.vue'
import TasksDataView from '@/views/TasksDataView.vue'

export const databaseRoutes: RouteRecordRaw = {
    path: '/database',
    component: BaseDataView,
    children: [
        {
            path: 'users',
            name: 'users',
            component: UsersDataView
        },
        {
            path: 'tasks',
            name: 'tasks',
            component: TasksDataView
        },
    ]
}
user.ts
src/models/user.ts
export class User { 
    constructor(init?: Partial<User>) {
        Object.assign(this, init);
    }

    id?: number;
    name?: string;
    email?: string;
    createdAt?: Date;
    updatedAt?: Date;
}
api.ts
src/api/api.ts
import apiClient from './client';
import { User } from '../models/user';
import { Task } from '../models/task';

export class Api {
    // User API methods
    async getUser(id: number): Promise<User> {
        const response = await apiClient.get<User>(`/users/${id}`);
        return new User(response.data);
    }

    async getUsers(): Promise<User[]> {
        const response = await apiClient.get<User[]>('/users');
        return response.items.map(item => new User(item));
    }

    async updateUser(id: number, user: Partial<User>): Promise<User> {
        const response = await apiClient.put<User>(`/users/${id}`, user);
        return new User(response.data);
    }

    async createUser(user: Omit<User, 'id'>): Promise<User> {
        const response = await apiClient.post<User>('/users', user);
        return new User(response.data);
    }

    async deleteUser(id: number): Promise<void> {
        await apiClient.delete(`/users/${id}`);
    }
    // Task API methods
    async getTask(id: number): Promise<Task> {
        const response = await apiClient.get<Task>(`/tasks/${id}`);
        return new Task(response.data);
    }

    async getTasks(): Promise<Task[]> {
        const response = await apiClient.get<Task[]>('/tasks');
        return response.items.map(item => new Task(item));
    }

    async updateTask(id: number, task: Partial<Task>): Promise<Task> {
        const response = await apiClient.put<Task>(`/tasks/${id}`, task);
        return new Task(response.data);
    }

    async createTask(task: Omit<Task, 'id'>): Promise<Task> {
        const response = await apiClient.post<Task>('/tasks', task);
        return new Task(response.data);
    }

    async deleteTask(id: number): Promise<void> {
        await apiClient.delete(`/tasks/${id}`);
    }
}

export default Api;
UsersDataView.vue
src/views/UsersDataView.vue
<script setup lang="ts">
import { useApi } from '@/composables/use_api';
import { User } from '@/models/user';
import { onMounted, ref } from 'vue';
import DataTable from '@/components/DataTable.vue';
import DataForm from '@/components/DataForm.vue';

const api = useApi();
const users = ref<User[] | null>(null);
const showForm = ref(false);

const onGetUsers = async () => {
  users.value = await api.getUsers();
};

const onCreateUser = async (user: User) => {
  await api.createUser(user);
  showForm.value = false;
  users.value = await api.getUsers();
}

const onDeleteUser = async (id: number) => {
  await api.deleteUser(id);
  users.value = await api.getUsers();
}

onMounted(async () => {
  await onGetUsers();
});
</script>

<template>
  <div class="space-y-6">
    <div class="flex justify-between items-center">
      <h1 class="text-2xl font-bold text-gray-900">Users</h1>
      <button
        @click="showForm = !showForm"
        class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >

        {{ showForm ? '新規作成をキャンセル' : '新規作成' }}
      </button>
    </div>

    <DataForm
      v-if="showForm"
      :data="new User()"
      :submit="onCreateUser"
    />

    <DataTable
      v-if="users"
      :data="users"
      :delete="onDeleteUser"
    />
  </div>
</template>

コンポーネントの共通化

フロントのデータ一覧テーブルやデータ登録フォームは、スキーマごとに生成するのではなく、抽象化してオブジェクトを受け取ることができるコンポーネントを作成しました。静的ファイルにすることでスキーマ修正のたびに差分が生まれるのを防いでいます。

やや複雑なコンポーネントですが、Claude に大部分を生成させて結構楽をすることができました。

DataTable.vue
src/components/DataTable.vue
<script setup lang="ts">
import { computed, ref } from 'vue'

interface TableHeader {
  key: string
  label: string
  sortable?: boolean
}

const props = defineProps<{
  data: Record<string, any>[],
  headers?: TableHeader[],
  delete?: (id: any) => Promise<void>,
}>();

// ヘッダー情報の自動生成
const generatedHeaders = computed<TableHeader[]>(() => {
  if (props.headers) return [...props.headers, { key: 'actions', label: 'アクション', sortable: false }]
  if (props.data.length === 0) return []

  // オブジェクトの最初の要素からキーを取得
  const firstItem = props.data[0]
  return [
    ...Object.keys(firstItem).map(key => ({
      key,
      label: formatLabel(key),
      sortable: true
    })),
    { key: 'actions', label: 'アクション', sortable: false }  // アクション列を追加
  ]
})

// キャメルケースやスネークケースを人が読みやすい形式に変換
const formatLabel = (key: string): string => {
  return key
    .replace(/[_-]/g, ' ')
    .replace(/([A-Z])/g, str => ' ' + str)
    .replace(/^./, str => str.toUpperCase())
    .trim()
}

// ソート関連の状態管理
const sortColumn = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')

// ソート関数
const sortBy = (key: string) => {
  if (key === 'actions') return  // アクション列はソート不可
  if (sortColumn.value === key) {
    sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortColumn.value = key
    sortDirection.value = 'asc'
  }
}

// ソート済みデータの計算
const sortedData = computed(() => {
  if (!sortColumn.value) return props.data

  return [...props.data].sort((a, b) => {
    const aValue = a[sortColumn.value]
    const bValue = b[sortColumn.value]

    if (typeof aValue === 'string' && typeof bValue === 'string') {
      return sortDirection.value === 'asc'
        ? aValue.localeCompare(bValue)
        : bValue.localeCompare(aValue)
    }

    return sortDirection.value === 'asc'
      ? aValue > bValue ? 1 : -1
      : aValue < bValue ? 1 : -1
  })
})

// 削除ハンドラー
const handleDelete = (item: Record<string, any>) => {
  if (props.delete && item.id) {
    props.delete(item.id)
  }
}
</script>

<template>
  <div class="w-full overflow-x-auto">
    <table v-if="data.length > 0" class="w-full min-w-full divide-y divide-gray-200">
      <thead class="bg-gray-50">
        <tr>
          <th
            v-for="header in generatedHeaders"
            :key="header.key"
            @click="header.sortable && sortBy(header.key)"
            class="px-6 py-3 text-left text-sm font-semibold text-gray-600"
            :class="[
              header.sortable && 'cursor-pointer hover:bg-gray-100 transition-colors',
              sortColumn === header.key && 'bg-gray-100'
            ]"
          >
            <div class="flex items-center space-x-1">
              <span>{{ header.label }}</span>
              <span v-if="header.sortable" class="text-gray-400">
                <span v-if="sortColumn === header.key" class="ml-1">
                  {{ sortDirection === 'asc' ? '' : '' }}
                </span>
                <span v-else class="ml-1 opacity-0 group-hover:opacity-50"></span>
              </span>
            </div>
          </th>
        </tr>
      </thead>
      <tbody class="bg-white divide-y divide-gray-200">
        <tr
          v-for="(item, index) in sortedData"
          :key="index"
          class="hover:bg-gray-50 transition-colors"
        >
          <td
            v-for="header in generatedHeaders"
            :key="header.key"
            class="px-6 py-4 text-sm text-gray-600 whitespace-nowrap"
          >
            <template v-if="header.key === 'actions'">
              <button
                v-if="delete"
                @click="handleDelete(item)"
                class="text-red-600 hover:text-red-800 font-medium"
              >
                削除
              </button>
            </template>
            <template v-else>
              {{ item[header.key] }}
            </template>
          </td>
        </tr>
      </tbody>
    </table>
    <div
      v-else
      class="w-full p-8 text-center text-gray-500 bg-gray-50 rounded-lg"
    >
      データがありません
    </div>
  </div>
</template>
DataForm.vue
src/components/DataForm.vue
<script setup lang="ts">
import { computed, ref } from 'vue';

interface FormField {
  key: string;
  type: string;
  value: any;
}

const props = defineProps<{
  data: Record<string, any>,
  submit: (d: typeof props.data) => Promise<void>,
}>();

const formData = ref({ ...props.data });
const isSubmitting = ref(false);
const error = ref('');

const getInputType = (value: any): string => {
  switch (typeof value) {
    case 'number':
      return 'number';
    case 'boolean':
      return 'checkbox';
    case 'string':
      return 'text';
    default:
      return 'text';
  }
};

const fields = computed((): FormField[] => {
  return Object.entries(props.data).map(([key, value]) => ({
    key,
    type: getInputType(value),
    value
  }));
});

const onSubmit = async () => {
  console.log(formData.value);
  try {
    isSubmitting.value = true;
    error.value = '';
    await props.submit(formData.value);
  } catch (e) {
    error.value = e instanceof Error ? e.message : '送信中にエラーが発生しました';
  } finally {
    isSubmitting.value = false;
  }
};
</script>

<template>
  <form @submit.prevent="onSubmit" class="space-y-4">
    <div v-for="field in fields" :key="field.key" class="form-field">
      <label :for="field.key" class="block text-sm font-medium text-gray-700">
        {{ field.key }}
      </label>
      
      <input
        v-if="field.type !== 'checkbox'"
        :id="field.key"
        :type="field.type"
        v-model="formData[field.key]"
        class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
      />
      
      <input
        v-else
        :id="field.key"
        type="checkbox"
        v-model="formData[field.key]"
        class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
      />
    </div>

    <div v-if="error" class="text-red-600 text-sm mt-2">
      {{ error }}
    </div>

    <button
      type="submit"
      :disabled="isSubmitting"
      class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
    >
      {{ isSubmitting ? '送信中...' : '保存' }}
    </button>
  </form>
</template>

まとめ

簡単にプロトタイプ開発ができる環境を整備する過程の1つとして、マークダウン形式のテーブル定義をもとに、 CRUD 操作ができる シンプルな Web アプリケーションを自動生成しました。

ルールベースの生成で実現できる部分の整備がかなり進んだので、今後は自動生成したアプリケーションに LLM で生成したコードをマージすることによって、ある程度複雑な仕様でも簡単にプロトタイプ開発ができるような環境にしていく予定です。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?