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

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

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

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

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

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

Миграции

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

Пример обычной миграции
<?php

declare(strict_types=1);

use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;

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 IlluminateSupportServiceProvider;

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, тесты и тд…

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

DoctrineDBALDriverPDOException: SQLSTATE[08006] [7]
FATAL:  sorry, too many clients already

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

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

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

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

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

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

DoctrineDBALDBALException: Unknown database type tsrange requested,
DoctrineDBALPlatformsPostgreSQL100Platform may not support it.

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

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

Any Doctrine type that you use has to be registered with DoctrineDBALTypesType::addType().

You can get a list of all the known types with DoctrineDBALTypesType::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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Types – наши типы

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

Пример класса, описывающего такое расширение
<?php

namespace UmbrellioPostgresExtensions;

use IlluminateSupportTraitsMacroable;
use UmbrellioPostgresExtensionsExceptionsMacroableMissedException;
use UmbrellioPostgresExtensionsExceptionsMixinInvalidException;

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 UmbrellioPostgres;

use DateTimeInterface;
use DoctrineDBALConnection;
use DoctrineDBALEvents;
use IlluminateDatabasePostgresConnection as BasePostgresConnection;
use IlluminateSupportTraitsMacroable;
use PDO;
use UmbrellioPostgresExtensionsAbstractExtension;
use UmbrellioPostgresExtensionsExceptionsExtensionInvalidException;
use UmbrellioPostgresSchemaBuilder;
use UmbrellioPostgresSchemaGrammarsPostgresGrammar;
use UmbrellioPostgresSchemaSubscribersSchemaAlterTableChangeColumnSubscriber;

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 UmbrellioPostgres;

use IlluminateDatabaseDatabaseManager;
use IlluminateDatabaseDatabaseServiceProvider;
use UmbrellioPostgresConnectorsConnectionFactory;

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 UmbrellioPostgresConnectors;

use IlluminateDatabaseConnection;
use IlluminateDatabaseConnectorsConnectionFactory as ConnectionFactoryBase;
use UmbrellioPostgresPostgresConnection;

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 AppExtensionsTsRange;

use AppExtensionsTsRangeSchemaGrammarsTsRangeSchemaGrammar;
use AppExtensionsTsRangeSchemaTsRangeBlueprint;
use AppExtensionsTsRangeTypesTsRangeType;
use UmbrellioPostgresExtensionsAbstractExtension;
use UmbrellioPostgresSchemaBlueprint;
use UmbrellioPostgresSchemaGrammarsPostgresGrammar;

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 AppExtensionsTsRangeSchema;

use IlluminateSupportFluent;
use AppExtensionsTsRangeTypesTsRangeType;
use UmbrellioPostgresExtensionsSchemaAbstractBlueprint;

class TsRangeBlueprint extends AbstractBlueprint
{
    public function tsrange()
    {
        return function (string $column): Fluent {
            return $this->addColumn(TsRangeType::TYPE_NAME, $column);
        };
    }
}
TsRangeSchemaGrammar.php
<?php

namespace AppExtensionsTsRangeSchemaGrammars;

use AppExtensionsTsRangeTypesTsRangeType;
use UmbrellioPostgresExtensionsSchemaGrammarAbstractGrammar;

class TsRangeSchemaGrammar extends AbstractGrammar
{
    protected function typeTsrange()
    {
        return function (): string {
            return TsRangeType::TYPE_NAME;
        };
    }
}
TsRangeType.php
<?php

namespace AppExtensionsTsRangeTypes;

use DoctrineDBALPlatformsAbstractPlatform;
use DoctrineDBALTypesType;

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 AppTsRangeProviders;

use IlluminateSupportServiceProvider;
use AppExtensionsTsRangeTsRangeExtension;
use UmbrellioPostgresPostgresConnection;

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.

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

Let’s block ads! (Why?)

Read More

Recent Posts

VK купила 40% билетной платформы Intickets.ru

VK объявляет о приобретении 40% компании Intickets.ru (Интикетс). Это облачный сервис для контроля и управления продажей билетов на мероприятия. Сумма…

17 часов ago

OpenAI готовится запустить поисковую систему на базе ChatGPT

OpenAI готовится запустить собственную поисковую систему на базе ChatGPT. Информацию об этом публикуют западные издания. Ожидается, что новый поисковик может…

1 день ago

Роскомнадзор рекомендовал хостинг-провайдерам ограничить сбор данных с сайтов для иностранных ботов

Центр управления связью общего пользования (ЦМУ ССОП) Роскомнадзора рекомендовал компаниям из реестра провайдеров ограничить доступ поисковых ботов к информации на российских сайтах.…

2 дня ago

Apple возобновила переговоры с OpenAI и Google для интеграции ИИ в iPhone

Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…

7 дней ago

Российская «дочка» Google подготовила 23 иска к крупнейшим игрокам рекламного рынка

Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…

1 неделя ago

Google завершил обновление основного алгоритма March 2024 Core Update

Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…

1 неделя ago