From f2bdb6a0697eee7fa39b38400ac70ded0a587eae Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sun, 10 May 2026 12:04:05 +0900 Subject: [PATCH] Add document_translations table and migrate existing data documents.{title,content,rendered_html} move to document_translations keyed by (document_id, locale). Existing rows are copied to a single translation in config('app.locale'). documents gains default_locale. Also guard the original FULLTEXT ALTER TABLE with a MySQL driver check so that the SQLite test environment can run all migrations cleanly. --- ...24_11_28_100001_create_documents_table.php | 5 +- ...eate_document_translations_and_migrate.php | 106 ++++++++++++++++++ src/tests/Feature/DocumentMigrationTest.php | 75 +++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php create mode 100644 src/tests/Feature/DocumentMigrationTest.php diff --git a/src/database/migrations/2024_11_28_100001_create_documents_table.php b/src/database/migrations/2024_11_28_100001_create_documents_table.php index f9d657c..7355c9a 100644 --- a/src/database/migrations/2024_11_28_100001_create_documents_table.php +++ b/src/database/migrations/2024_11_28_100001_create_documents_table.php @@ -2,6 +2,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration @@ -27,7 +28,9 @@ public function up(): void // FULLTEXT検索インデックス(MySQL 5.7以降) // ngramトークナイザーは日本語対応に必要だが、設定が必要 - DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram'); + if (DB::connection()->getDriverName() === 'mysql') { + DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram'); + } // 検索パフォーマンス向上用インデックス Schema::table('documents', function (Blueprint $table) { diff --git a/src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php b/src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php new file mode 100644 index 0000000..5d678ec --- /dev/null +++ b/src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php @@ -0,0 +1,106 @@ +string('default_locale', 10) + ->default(config('app.locale', 'en')) + ->after('slug'); + }); + + // 2. Create document_translations + Schema::create('document_translations', function (Blueprint $table) { + $table->id(); + $table->foreignId('document_id')->constrained('documents')->cascadeOnDelete(); + $table->string('locale', 10); + $table->string('title'); + $table->text('content'); + $table->text('rendered_html')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['document_id', 'locale']); + $table->index(['locale', 'title']); + }); + + if (DB::connection()->getDriverName() === 'mysql') { + DB::statement('ALTER TABLE document_translations ADD FULLTEXT INDEX document_translations_search_index (title, content) WITH PARSER ngram'); + } + + // 3. Migrate existing data + $defaultLocale = config('app.locale', 'en'); + $now = now(); + $rows = DB::table('documents')->get(); + foreach ($rows as $row) { + DB::table('document_translations')->insert([ + 'document_id' => $row->id, + 'locale' => $defaultLocale, + 'title' => $row->title ?? '', + 'content' => $row->content ?? '', + 'rendered_html' => $row->rendered_html, + 'created_by' => $row->created_by ?? null, + 'updated_by' => $row->updated_by ?? null, + 'created_at' => $row->created_at ?? $now, + 'updated_at' => $row->updated_at ?? $now, + ]); + DB::table('documents')->where('id', $row->id)->update(['default_locale' => $defaultLocale]); + } + + // 4. Drop the old FULLTEXT index on documents (MySQL only) + if (DB::connection()->getDriverName() === 'mysql') { + DB::statement('ALTER TABLE documents DROP INDEX documents_search_index'); + } + + // 5. Drop translatable columns from documents + // Drop the title index first (SQLite requires this before dropping the column) + Schema::table('documents', function (Blueprint $table) { + $table->dropIndex(['title']); + $table->dropColumn(['title', 'content', 'rendered_html']); + }); + } + + public function down(): void + { + // Re-add columns + Schema::table('documents', function (Blueprint $table) { + $table->string('title')->nullable()->after('default_locale'); + $table->text('content')->nullable()->after('title'); + $table->text('rendered_html')->nullable()->after('content'); + }); + + // Restore data from default_locale translation + $rows = DB::table('document_translations as t') + ->join('documents as d', 'd.id', '=', 't.document_id') + ->whereColumn('t.locale', 'd.default_locale') + ->select('t.document_id', 't.title', 't.content', 't.rendered_html') + ->get(); + + foreach ($rows as $row) { + DB::table('documents')->where('id', $row->document_id)->update([ + 'title' => $row->title, + 'content' => $row->content, + 'rendered_html' => $row->rendered_html, + ]); + } + + // Restore FULLTEXT on documents (MySQL only) + if (DB::connection()->getDriverName() === 'mysql') { + DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram'); + } + + Schema::dropIfExists('document_translations'); + + Schema::table('documents', function (Blueprint $table) { + $table->dropColumn('default_locale'); + }); + } +}; diff --git a/src/tests/Feature/DocumentMigrationTest.php b/src/tests/Feature/DocumentMigrationTest.php new file mode 100644 index 0000000..ade25e0 --- /dev/null +++ b/src/tests/Feature/DocumentMigrationTest.php @@ -0,0 +1,75 @@ +assertTrue(Schema::hasColumn('documents', 'default_locale')); + } + + public function test_documents_table_no_longer_has_translatable_columns(): void + { + $this->assertFalse(Schema::hasColumn('documents', 'title')); + $this->assertFalse(Schema::hasColumn('documents', 'content')); + $this->assertFalse(Schema::hasColumn('documents', 'rendered_html')); + } + + public function test_document_translations_table_exists_with_required_columns(): void + { + $this->assertTrue(Schema::hasTable('document_translations')); + + foreach (['document_id', 'locale', 'title', 'content', 'rendered_html', 'created_by', 'updated_by', 'created_at', 'updated_at'] as $col) { + $this->assertTrue( + Schema::hasColumn('document_translations', $col), + "document_translations missing column: $col" + ); + } + } + + public function test_document_translations_unique_document_locale(): void + { + DB::table('documents')->insert([ + 'path' => 'A.md', + 'slug' => 'a', + 'default_locale' => 'en', + 'file_size' => 0, + 'file_hash' => str_repeat('0', 64), + 'file_modified_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $docId = DB::table('documents')->where('slug', 'a')->value('id'); + + DB::table('document_translations')->insert([ + 'document_id' => $docId, + 'locale' => 'en', + 'title' => 'A', + 'content' => '...', + 'rendered_html' => '

...

', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->expectException(\Illuminate\Database\QueryException::class); + + DB::table('document_translations')->insert([ + 'document_id' => $docId, + 'locale' => 'en', + 'title' => 'duplicate', + 'content' => '...', + 'rendered_html' => '

...

', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +}