用 Laravel 遷移檔案新增表註釋的一種方法

chuoke發表於2020-12-03

首先請記住,我僅在 Laravel 8 下對 MySQL 做過測試。

使用 Laravel 的遷移,給表新增註釋是個麻煩事兒,要麼使用原生 SQL,要麼再用資料庫管理工具手動新增, 我一直沒有找到更好或更方便的方式,網路中流傳的方式也都是使用原生 SQL:

  \DB::statement("ALTER TABLE `table_name` comment '表註釋'");

這個方式不是不可,但是不夠靈活,我一般喜歡新增表字首,這時候就會變得有點棘手,那樣的程式碼寫起來不僅很累,而且似乎也不太像程式設計師會幹的事。

今天我又被這個問題所困擾,因為我又要開始建表啦,很多表。想著 Laravel 的版本已經到 8 了,是不是已經帶這個功能呢?於是又開始一番搜尋,然並卵,結果都還是上面那個原生 SQL 方案,似乎大家也已經放棄了。好像有個擴充套件包有提供這個能力,但是因為這點事就要加個包,是不是有點小題大做。順便說一句,如果你找到了不一樣的方法,記得通知我一聲。

同時透過網路中的一些討論,發現 Laravel 團隊貌似也並不想把這個功能加進來。可是在我一番探索後,以一個簡便的方式實現其實也不難,我不知道把這個功能加到框架裡會有什麼不妥,也許是我的知識不夠吧。

那麼,直接來看看結果吧:


  Blueprint::macro('comment', function ($comment) {
      return $this->addCommand('commentTable', compact('comment'));
  });

  Grammar::macro('compileCommentTable', function (Blueprint $blueprint, Fluent $command, Connection $connection) {
      return 'alter table ' . $this->wrapTable($blueprint) . $this->modifyComment($blueprint, $command);
  });

這就是我的方案的核心程式碼,原理就是利用 macroable,再使用表註釋方法之前新增自定義方法到關鍵類中。主要做兩件事,一是給 Blueprint 增加個給表新增註釋的方法,以便我們在遷移檔案中像其他方法一樣使用,這裡我的方法名叫 comment;其二是給 Grammar 增加個編譯表新增註釋的命令方法,用於解析出 SQL 語句。需要注意的是,我這裡針對的是 MySQL 資料庫,其它資料庫的語句會不同,組織語句的方式也有些不同。

肯定還有其他的方法,我也不知道我這個方法會不會有什麼奇怪的 bug,而且我這個方法有個問題,我不知道把這個步驟放在哪個地方。我能想到的一種是在 AppServiceProvider 中,但是這個功能僅在做遷移的時候才需要,感覺不太合適;另一個是監聽遷移事件,在事件開始前新增這些方法,但是這又是全域性範圍的,也似乎不妥;還有一種就是自定義 Migration 類,在建構函式中進行,然後讓遷移類繼承該類。顯而易見,我使用的是自定義類。

下面我假設你對遷移程式碼執行過程不瞭解,其實我也不瞭解,我來針對我這個方法簡單解釋下為什麼這樣就可以實現給表新增註釋,也算是我的一個思路。記住,途徑不止這一種,雖然我使用 Laravel 很長時間了,但是框架內部的事,我還不夠清楚,一些回撥搞得我暈頭轉向,但我還是探索出了這樣一條路。

給表新增註釋,可以在建立表的時候,也可以在建立表之後。顯然後者更通用,因為它同時解決了後期修改表註釋的能力。所以把這個功能進行單獨考慮,首先我通過檢視其他單個指令來確定我需要做什麼,比如,刪除表:

  Schema::dropIfExists('users');

其內部是這樣的:

  // \Illuminate\Database\Schema\Builder
  /**
   * Drop a table from the schema if it exists.
   *
   * @param  string  $table
   * @return void
   */
  public function dropIfExists($table)
  {
      $this->build(tap($this->createBlueprint($table), function ($blueprint) {
          $blueprint->dropIfExists();
      }));
  }

有看到它呼叫的是 BlueprintdropIfExists 方法嗎? tap 方法會返回 Buleprint 的例項,再轉交給 build,在那裡面會進行 SQL 解析和執行。但是我們先看看 Blueprint 中有幹什麼事。

  // Illuminate\Database\Schema\Blueprint
  /**
   * Indicate that the table should be dropped if it exists.
   *
   * @return \Illuminate\Support\Fluent
   */
  public function dropIfExists()
  {
      return $this->addCommand('dropIfExists');
  }

看樣子只是新增了一個命令,而 addCommand 方法是這樣的:

  /**
   * Add a new command to the blueprint.
   *
   * @param  string  $name
   * @param  array  $parameters
   * @return \Illuminate\Support\Fluent
   */
  protected function addCommand($name, array $parameters = [])
  {
      $this->commands[] = $command = $this->createCommand($name, $parameters);

      return $command;
  }

再多看一眼

  /**
   * Create a new Fluent command.
   *
   * @param  string  $name
   * @param  array  $parameters
   * @return \Illuminate\Support\Fluent
   */
  protected function createCommand($name, array $parameters = [])
  {
      return new Fluent(array_merge(compact('name'), $parameters));
  }

Blueprint 乾的事就這些,一切都平平無奇。看到這裡是不是該想到什麼,如果我要增加給表新增註釋的方法,好像只需要加個類似 dropIfExists 的方法到 Blueprint 即可。但是以何種方式呢?繼承吧啦吧啦之類的搞起來實在是太麻煩。如果你有開啟過 Blueprint 類,你可能有注意到 use Macroable; 這行程式碼。於是我在遷移檔案中直接嘗試了下:

Blueprint::macro('comment', function ($comment) {
    return $this->addCommand('comment', compact('comment'));
});

這樣是可以把方法新增到 Blueprint 例項中,那麼該看看是怎麼執行 SQL 的。

Illuminate\Database\Schema\Build 中檢視 build 方法

  /**
   * Execute the blueprint to build / modify the table.
   *
   * @param  \Illuminate\Database\Schema\Blueprint  $blueprint
   * @return void
   */
  protected function build(Blueprint $blueprint)
  {
      $blueprint->build($this->connection, $this->grammar);
  }

哈,又到 Blueprint 中去了!

  /**
   * Execute the blueprint against the database.
   *
   * @param  \Illuminate\Database\Connection  $connection
   * @param  \Illuminate\Database\Schema\Grammars\Grammar  $grammar
   * @return void
   */
  public function build(Connection $connection, Grammar $grammar)
  {
      foreach ($this->toSql($connection, $grammar) as $statement) {
          $connection->statement($statement);
      }
  }

顯而易見,關鍵點落到 toSql 方法了,有了 sql 語句,自然就能實現願望。

  /**
   * Get the raw SQL statements for the blueprint.
   *
   * @param  \Illuminate\Database\Connection  $connection
   * @param  \Illuminate\Database\Schema\Grammars\Grammar  $grammar
   * @return array
   */
  public function toSql(Connection $connection, Grammar $grammar)
  {
      $this->addImpliedCommands($grammar);

      $statements = [];

      // Each type of command has a corresponding compiler function on the schema
      // grammar which is used to build the necessary SQL statements to build
      // the blueprint element, so we'll just call that compilers function.
      $this->ensureCommandsAreValid($connection);

      foreach ($this->commands as $command) {
          $method = 'compile'.ucfirst($command->name);

          if (method_exists($grammar, $method) || $grammar::hasMacro($method)) {
              if (! is_null($sql = $grammar->$method($this, $command, $connection))) {
                  $statements = array_merge($statements, (array) $sql);
              }
          }
      }

      return $statements;
  }

這個方法有點長,但要關心的只有 $sql = $grammar->$method($this, $command, $connection))。可 Grammar 又是什麼鬼?根據接收返回值的變數名,可以猜到應該是做 SQL 語句編譯的。對於 dropIfExists 的編譯方法應該是 compileDropIfExists,直接搜一下,框架支援的每個資料庫系統的 Grammar 都有這個方法,我使用的是 MySQL,所以僅檢視 MySQL 相關的:

  // Illuminate\Database\Schema\Grammars\MySqlGrammar
  /**
   * Compile a drop table (if exists) command.
   *
   * @param  \Illuminate\Database\Schema\Blueprint  $blueprint
   * @param  \Illuminate\Support\Fluent  $command
   * @return string
   */
  public function compileDropIfExists(Blueprint $blueprint, Fluent $command)
  {
      return 'drop table if exists '.$this->wrapTable($blueprint);
  }

終於見到 SQL 語句啦!到這裡,事情就很清晰了,只需要向 Grammar 中新增個類似 compileComment 的方法就可以實現給表新增註釋的能力。在所有 Grammar 類的基類 Illuminate\Database\Grammar 中,我同樣發現 use Macroable; ,所以可以使用 macro。不過,為了避免與框架的方法產生不必要的衝突,我對方法名做了一點小調整:

  \Illuminate\Database\Schema\Blueprint::macro('comment', function ($comment) {
      return $this->addCommand('commentTable', compact('comment'));
  });

  \Illuminate\Database\Schema\Grammars\Grammar::macro('compileCommentTable', function (Blueprint $blueprint, Fluent $command, Connection $connection) {
      return "alter table table_name comment '這是註釋'";
  });

這和開頭的內容很很相似,我測試可以執行到最終的 SQL,當然這個樣子會報錯,而且不優雅,需要做下優化,於是我找到一些方法並利用,加上一些其他的考慮,最終的樣子其實是這樣的:

<?php

namespace App\Support\Database;

use Exception;
use Illuminate\Support\Fluent;
use Illuminate\Database\Connection;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Grammars\Grammar;
use Illuminate\Database\Migrations\Migration as AbstractMigration;

class Migration extends AbstractMigration
{
    public function __construct()
    {
        $this->addCommentTableMethod();
    }

    protected function addCommentTableMethod()
    {
        Blueprint::macro('comment', function ($comment) {
            if (!Grammar::hasMacro('compileCommentTable')) {
                Grammar::macro('compileCommentTable', function (Blueprint $blueprint, Fluent $command, Connection $connection) {
                    switch ($database_driver = $connection->getDriverName()) {
                        case 'mysql':
                            return 'alter table ' . $this->wrapTable($blueprint) . $this->modifyComment($blueprint, $command);
                        case 'pgsql':
                            return sprintf(
                                'comment on table %s is %s',
                                $this->wrapTable($blueprint),
                                "'" . str_replace("'", "''", $command->comment) . "'"
                            );
                        case 'sqlserver':
                        case 'sqlite':
                        default:
                            throw new Exception("The {$database_driver} not support table comment.");
                    }
                });
            }

            return $this->addCommand('commentTable', compact('comment'));
        });
    }
}

我給出了 MySQL 和 PostgreSQL 的實現, 我對 SQL Server 不熟,而且它的語句太難寫,沒有給出示例,而 sqlite 我就更不清楚了。如果只使用明確的資料庫,可以去掉其他的內容。需要用到表註釋的遷移繼承這個類就行(也可以使用其他方式),這不會影響到其他的地方。看個示例:

<?php

use App\Support\Database\Migration;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name', 30)->comment('名稱');

            $table->comment('使用者表');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

執行結果:

CREATE TABLE `db_users` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名稱',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='使用者表';

還可以做到像 dropIfExists 方法一樣直接在 Schema 上呼叫,比如 Schema::comment('users', '使用者表');,但我覺得目前實現的已經足夠用。


最後,如果你有其他更好的方法,記得通知我。

首發於個人站:youlinkin.com/posts/11-a-method-of...

本作品採用《CC 協議》,轉載必須註明作者和本文連結
初出茅廬,一知半解,望有識之士多多賜教。

相關文章