Расширяем возможности миграций Laravel за счет Postgres

от автора

Все, кто однажды начинал вести более-менее нормальный Enterprise проект на Laravel, сталкивался с тем, что стандартных решений, которые предлагает Laravel из коробки, уже недостаточно.

А если вы, как и я, используете в своих проектах Postgres, то рано или поздно вам потребуются плюшки этой замечательной СУБД, такие как: различного рода индексы и констрейнты, расширения, новые типы и тд…

Сегодня, как вы уже заметили, мы будем говорить про Postgres, про миграции Laravel, как это все вместе подружить, в общем, обо всем том, чего нам не хватает в стандартных миграциях Лары.

Ну а для тех, кто не хочет погружаться в тонкости внутреннего устройства Laravel, может просто скачать пакет, расширяющий возможности миграций Laravel и Postgres по этой ссылке и использовать его в своих проектах.

Но я все же рекомендую не пролистывать, а прочитать все до конца.

Миграции

Вот как выглядит стандартная миграция в Laravel:

Пример обычной миграции
<?php  declare(strict_types=1);  use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema;  class CreateDocuments extends Migration {     private const TABLE = 'documents';      public function up()     {         Schema::create(static::TABLE, function (Blueprint $table) {             $table->bigIncrements('id');             $table->timestamps();             $table->softDeletes();             $table->string('number');             $table->date('issued_date')->nullable();             $table->date('expiry_date')->nullable();             $table->string('file');             $table->bigInteger('author_id');             $table->bigInteger('type_id');             $table->foreign('author_id')->references('id')->on('users');             $table->foreign('type_id')->references('id')->on('document_types');         });     }      public function down()     {         Schema::dropIfExists(static::TABLE);     } }

Но, тут вы прочитали статью на хабре про новые типы в Postgres, например, tsrange и захотели добавить в миграцию что-то вроде этого…

$table->addColumn('tsrange', 'period')->nullable();

Казалось бы, все просто, но ваш перфекционизм наводит вас на мысль, а почему бы не залезть в сорцы и не пропатчить Laravel, ведь я буду часть использовать это, хочется чтобы можно было использовать это примерно так:

$table->tsRange('period')->nullable();

Для тех, кому интересно сколько вендоровских классов нужно пропатчить, чтобы добиться этого, загляните под спойлер:

Пример как пропатчить Laravel миграции

Патчим Blueprint

<?php  Blueprint::macro('tsRange', function (string $columnName) {   return $this->addColumn('tsrange', $columnName); }); 

Патчим PostgresGrammar

<?php  PostgresGrammar::macro('typeTsrange', function () {   rerurn 'tsrange'; }); 

Далее создаем какой-нить провайдер, типа ExtendDatabaseProvider:

<?php  use Illuminate\Support\ServiceProvider;  class DatabaseServiceProvider extends ServiceProvider {     public function register()     {         Blueprint::macro('tsRange', function (string $columnName) {             return $this->addColumn('tsrange', $columnName);         });          PostgresGrammar::macro('typeTsrange', function () {             return 'tsrange';         });     } }

Вроде бы все, запускаем миграцию, все работает..

И не важно, переопределили ли вы половину компонентов Laravel для работы с БД или воспользовались макросами и миксинами из MacroableTrait, круги ада еще не закончились.

Круги ада (часть 1)

И вот вы локально все это крутите, все работает как часы, написали +100500 строк кода, и решили выкатить готовую таску в гитлаб. Мы же идем в ногу со временем и там у нас Докер, CI, тесты и тд…

И вот мы замечаем, что наши миграции в «не локальном» окружении не работают из-за ошибки:

Doctrine\DBAL\Driver\PDOException: SQLSTATE[08006] [7] FATAL:  sorry, too many clients already

И вот ты сидишь и думаешь, что ты не так делаешь, начинаешь подпихивать в локальный .env окружения из CI вашего GitLab, переписывать код, вместо макросов переопределять разные классы, распихивать везде и всюду дебаги, все идеально, локально ошибки нет. А в пайплайнах CI не так-то просто дебажить, начинаешь пихать везде и всюду логирование, и это не помогает, ведь ошибка где-то внутри, в vendor. Но ты об этом еще пока не знаешь.

Затем, спустя целый дня гугления ошибки, решения так и нет, куча бессмысленных советов.

Начинаешь ощущать по-тихоньку себя идиотом (простите за мой французский), который не способен ни на что, в прямом смысле этого слова, словно ты школьник, который только-только выпустился из школы, день убит, проблема не решена.

Важен контекст

Ошибка sorry, too many clients already может быть совершенно по любой причине.

Ты возвращаешься к началу, гуглишь то с чего начинал, с внедрения макросами в PostgresGrammar нового типа и вот чудо, ты натыкаешься на похожую проблему, но с первого взгляда она немного другая…. где кто-то, как и ты добавлял новый тип и у него не заводится БД. Но у него ошибка другая:

Doctrine\DBAL\DBALException: Unknown database type tsrange requested, Doctrine\DBAL\Platforms\PostgreSQL100Platform may not support it.

Отчаявшись, первая мысль, а чем черт не шутит… ты пробуешь чужие решения, даже самые абсурдные и в один прекрасный день все начинает работать. Вы уже догадались в чем дело?

Барабанная дробь

Any Doctrine type that you use has to be registered with \Doctrine\DBAL\Types\Type::addType().

You can get a list of all the known types with \Doctrine\DBAL\Types\Type::getTypesMap().

If this error occurs during database introspection then you might have forgotten to register all database types for a Doctrine Type.

Use AbstractPlatform#registerDoctrineTypeMapping() or have your custom types implement Type#getMappedDatabaseTypes().

If the type name is empty you might have a problem with the cache or forgot some mapping information.

Иными словами, нужно успеть зарегистрировать тип в Doctrine\Dbal прежде, чем до вашего Database Connection дойдет информация, что вы используете кастомные типы (под кастомными я подразумеваю те, которые есть в Postgres, но отсутствуют в заветном getTypesMap в недрах Doctrine.

Круги ада (часть 2)

Вы лезете в исходники, куда-то очень глубоко в vendor в недры doctrine\dbal…

Спустя некоторое кол-во впустую потраченных часов, понимаете, что чтобы зарегистрировать тип в Doctrine, вам нужно переопределить с десяток классов и, помимо прочего, еще и с десяток методов.

О боже, часть из них приватные!

Все больше не могу, это единственные мысли и слова, которые выходят из ваших уст..

Руки опускаются окончательно..

Спасительный круг

Не буду ходить вокруг, да около.

Это, как вы уже поняли, был мой личный опыт, мои руки не опустились, все-таки я победил этот великий и ужасный Doctrine.

Подумаем о будущем

А что, если я не первый, что если не мне одному надо это, что если сделать публичный пакет, который бы позволял расширять возможности наших миграций любому, добавлять новые типы, чтобы это было просто, прозрачно, и чтобы не приходилось лезть в исходники Laravel и Doctrine, и чтобы он работал по принципу автоботов.

Помните, как Оптимус Прайм при помощи трейлера приобретал способности летать, а другие автоботы, были для него своего рода доп. орудиями.

Представим себе такой DatabaseProvider, который мы внедряем в свой проект вместо стандартного от Laravel, опишем структуру будущих Extension-ов в виде маленьких библиотек с похожей структурой, чтобы они легко коннектились к нашему провайдеру, и забыть, как страшный сон исходники Doctrine.

Основные компоненты

Эти объекты нам надо модифицировать, но сделать это в стиле ООП, сбоку, по типу как трейты иньектятся в классы:

  • Blueprint — объект, использующийся в миграциях, по сути билдер

  • Builder — он же фасад Schema

  • PostgresGrammar — объект для компиляции Blueprint-а в SQL-выражения

  • Types — наши типы

Давайте, придумаем объект, который будет подмешивать объекты расширений во внутренние объекты Laravel таким образом, чтобы и овцы были целы и волки сыты, имею ввиду, чтобы IDE был счастлив, все работало, а наш код был понятным.

Пример класса, описывающего такое расширение
<?php  namespace Umbrellio\Postgres\Extensions;  use Illuminate\Support\Traits\Macroable; use Umbrellio\Postgres\Extensions\Exceptions\MacroableMissedException; use Umbrellio\Postgres\Extensions\Exceptions\MixinInvalidException;  abstract class AbstractExtension extends AbstractComponent {     abstract public static function getMixins(): array;      abstract public static function getName(): string;      public static function getTypes(): array     {         return [];     }      final public static function register(): void     {         collect(static::getMixins())->each(static function ($extension, $mixin) {             if (!is_subclass_of($mixin, AbstractComponent::class)) {                 throw new MixinInvalidException(sprintf(                     'Mixed class %s is not descendant of %s.',                     $mixin,                     AbstractComponent::class                 ));             }             if (!method_exists($extension, 'mixin')) {                 throw new MacroableMissedException(sprintf('Class %s doesn’t use Macroable Trait.', $extension));             }             /** @var Macroable $extension */             $extension::mixin(new $mixin());         });     } }

Теперь, пропатчим соединение базы данных, чтобы оно умело работать с этим объектом и решало все наши проблемы, регистрировало в нужные внутренние компоненты Laravel наши дополнения, в том числе и решало проблему с регистрацией типов в Doctrine.

Патчим PostgresConnection
<?php  namespace Umbrellio\Postgres;  use DateTimeInterface; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Events; use Illuminate\Database\PostgresConnection as BasePostgresConnection; use Illuminate\Support\Traits\Macroable; use PDO; use Umbrellio\Postgres\Extensions\AbstractExtension; use Umbrellio\Postgres\Extensions\Exceptions\ExtensionInvalidException; use Umbrellio\Postgres\Schema\Builder; use Umbrellio\Postgres\Schema\Grammars\PostgresGrammar; use Umbrellio\Postgres\Schema\Subscribers\SchemaAlterTableChangeColumnSubscriber;  class PostgresConnection extends BasePostgresConnection {     use Macroable;      private static $extensions = [];      final public static function registerExtension(string $extension): void     {         if (!is_subclass_of($extension, AbstractExtension::class)) {             throw new ExtensionInvalidException(sprintf(                 'Class %s must be implemented from %s',                 $extension,                 AbstractExtension::class             ));         }         self::$extensions[$extension::getName()] = $extension;     }      public function getSchemaBuilder()     {         if ($this->schemaGrammar === null) {             $this->useDefaultSchemaGrammar();         }         return new Builder($this);     }      public function useDefaultPostProcessor(): void     {         parent::useDefaultPostProcessor();          $this->registerExtensions();     }      protected function getDefaultSchemaGrammar()     {         return $this->withTablePrefix(new PostgresGrammar());     }      private function registerExtensions(): void     {         collect(self::$extensions)->each(function ($extension) {             /** @var AbstractExtension $extension */             $extension::register();             foreach ($extension::getTypes() as $type => $typeClass) {                 $this                     ->getSchemaBuilder()                     ->registerCustomDoctrineType($typeClass, $type, $type);             }         });     } }

А также необходимо переопределить провайдер и фабрику для работы с БД:

Патчим DatabaseProvider
<?php  namespace Umbrellio\Postgres;  use Illuminate\Database\DatabaseManager; use Illuminate\Database\DatabaseServiceProvider; use Umbrellio\Postgres\Connectors\ConnectionFactory;  class UmbrellioPostgresProvider extends DatabaseServiceProvider {     protected function registerConnectionServices(): void     {         $this->app->singleton('db.factory', function ($app) {             return new ConnectionFactory($app);         });          $this->app->singleton('db', function ($app) {             return new DatabaseManager($app, $app['db.factory']);         });          $this->app->bind('db.connection', function ($app) {             return $app['db']->connection();         });     } }

Патчим ConnectionFactory
<?php  namespace Umbrellio\Postgres\Connectors;  use Illuminate\Database\Connection; use Illuminate\Database\Connectors\ConnectionFactory as ConnectionFactoryBase; use Umbrellio\Postgres\PostgresConnection;  class ConnectionFactory extends ConnectionFactoryBase {     protected function createConnection($driver, $connection, $database, $prefix = '', array $config = [])     {         if ($resolver = Connection::getResolver($driver)) {             return $resolver($connection, $database, $prefix, $config);         }          if ($driver === 'pgsql') {             return new PostgresConnection($connection, $database, $prefix, $config);         }          return parent::createConnection($driver, $connection, $database, $prefix, $config);     } }

Начало работы

Представим что нам надо добавить поддержку нового типа tsrange в наши миграции. Как будет выглядеть наше расширение в нашем проекте теперь.

TsRangeExtension.php
<?php  namespace App\Extensions\TsRange;  use App\Extensions\TsRange\Schema\Grammars\TsRangeSchemaGrammar; use App\Extensions\TsRange\Schema\TsRangeBlueprint; use App\Extensions\TsRange\Types\TsRangeType; use Umbrellio\Postgres\Extensions\AbstractExtension; use Umbrellio\Postgres\Schema\Blueprint; use Umbrellio\Postgres\Schema\Grammars\PostgresGrammar;  class TsRangeExtension extends AbstractExtension {     public const NAME = TsRangeType::TYPE_NAME;      public static function getMixins(): array     {         return [             TsRangeBlueprint::class => Blueprint::class,             TsRangeSchemaGrammar::class => PostgresGrammar::class,             // ... список миксинов может включать в себя почти любой внутренний компонент Laravel         ];     }      public static function getName(): string     {         return static::NAME;     }      public static function getTypes(): array     {         return [             static::NAME => TsRangeType::class,         ];     } }

TsRangeBlueprint.php
<?php  namespace App\Extensions\TsRange\Schema;  use Illuminate\Support\Fluent; use App\Extensions\TsRange\Types\TsRangeType; use Umbrellio\Postgres\Extensions\Schema\AbstractBlueprint;  class TsRangeBlueprint extends AbstractBlueprint {     public function tsrange()     {         return function (string $column): Fluent {             return $this->addColumn(TsRangeType::TYPE_NAME, $column);         };     } }

TsRangeSchemaGrammar.php
<?php  namespace App\Extensions\TsRange\Schema\Grammars;  use App\Extensions\TsRange\Types\TsRangeType; use Umbrellio\Postgres\Extensions\Schema\Grammar\AbstractGrammar;  class TsRangeSchemaGrammar extends AbstractGrammar {     protected function typeTsrange()     {         return function (): string {             return TsRangeType::TYPE_NAME;         };     } }

TsRangeType.php
<?php  namespace App\Extensions\TsRange\Types;  use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type;  class TsRangeType extends Type {     public const TYPE_NAME = 'tsrange';          public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string     {         return static::TYPE_NAME;     }      public function convertToPHPValue($value, AbstractPlatform $platform): ?array     {         //...          return $value;       }      public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string     {         //...                return $value;     }      public function getName(): string     {         return self::TYPE_NAME;     } }

Теперь необходимо зарегистрировать наше расширение TsRangeExtension в нашем провайдере для работы с БД:

<?php  namespace App\TsRange\Providers;  use Illuminate\Support\ServiceProvider; use App\Extensions\TsRange\TsRangeExtension; use Umbrellio\Postgres\PostgresConnection;  class TsRangeExtensionProvider extends ServiceProvider {     public function register(): void     {         PostgresConnection::registerExtension(TsRangeExtension::class);     } }

Итог

Вы можете писать свои расширения для Postgres имплементируя AbstractExtension, на мой взгляд, очень быстро и просто, не вникая в тонкости работы Laravel и Doctrine.

Это мой первый опыт, сделать что-то полезное для PHP сообщества, для тех кто использует в своих проектах Laravel / Postgres, не судите строго, пожалуйста.

Но я буду рад обратной связи, в любом ее проявлении, в Issues / Pull-реквестах, или в комментах относительно не только моей публикации, но и пакета в целом приму любую критику.

Пощупать данный пакет можно на GitHub: laravel-pg-extensions.

Спасибо за внимание.

ссылка на оригинал статьи https://habr.com/ru/post/537426/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *