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

  • Tutorial

Все, кто однажды начинал вести более-менее нормальный 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.

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Хотите больше таких публикаций?

  • 88,9%Да40
  • 11,1%Нет5

Похожие публикации

Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 17

    +1
    Как же всё это знакомо…
    Жаль, что Тейлор не подозревает о существовании более «взрослых» БД, нежели MySQL.
      0
      ну это только мотивирует людей создавать что-то из серии postgres-laravel, как в свое время Laravel обмазался компонентами с Symfony, так и этот фреймворк полностью адаптированный под Postgres, обмажет Laravel всем тем, что нам предоставляет Postgres.
        0
        А насчет MySQL, я долго время использовал ее в своих проектах, видимо потому, что я уже приходил работать в проекты, где о других БД люди не были в курсе, и после 2х лет работы с Postgres, к MySQL возвращаться не хочу.

        Нет, я ничего не имею против конкретно нее, она имеет место быть в жизни разработчиков, начальных разработчиков и тех у кого маленькие проектики, просто в PG реально возможностей больше. Да и прикипел что ли к ней все душой.
          +1

          Как я Вас понимаю =) Я лет 10 назад после очередного марафона по оптимизации настроек и запросов в MySQL обнаружил что это недоразумение отказывается использовать выделенные ему 16 гигов оперативки и использует HDD (скажем спасибо TEXT типу колонок). В итоге психанул и перевел проект на PostgreSQL с минимальными изменениями в структуре и типах полей. В итоге мало того что все проблемы с нагрузкой испарились, так я еще и не настраивал в PG ничего кроме разрешенного объема памяти. Тогда еще были проблемы с производительностью COUNT(*), но проект все-равно работал намного быстрее чем MySQL. А потом я начал вспоминать универ и стандарт SQL со всеми его возможностями, которыми в MySQL и не пахло. В общем с того момента я зарекся использовать MySQL в проектах более чем сайт-каталог с небольшим количеством данных.
          На сколько я понял — проблема с непопулярностью PostgreSQL в те времена была в основном потому что мало кто знал о нем и мало кто был способен адекватно использовать его возможности. Я вот, например, кайфовал от количества типов колонок и строгости типизации данных в сравнении с MySQL. А многие считают это проблемой или не понимают как это правильно использовать.
          А еще больше меня удивляет скудность поддержки PostgreSQL в фреймворках и ORM даже сейчас.

            0

            В "те времена" — это когда?
            PG традиционно считается более сложным в администрировании, одни его файерволы и вакумы чего стоят — неужели не настраивали?

              0

              Он вроде и олап кубы поддерживает через экстеншены… это почти уже как oracle, хотя с последним даже не работал никогда, но про кубы в нем слыхивал

                +1

                Далекие времена PostgreSQL 9.1. Я тогда настройки делал по инструкциям. Магии как с настройками MySQL не припомню. Тем не менее около 2х лет проект работал без нареканий на БД. Что было после — не знаю.
                Сейчас настройкой занимается специально обученный человек, так что не совсем в курсе насколько всё изменилось.
                Мне казалось наибольшей сложностью перехода с MySQL на PostgreSQL для большинства было незнание стандартного SQL и привычка использовать нестандартные фишки MySQL. В добавок более строгая типизация не позволяла делать всякую фигню как в MySQL без явного приведения типов. А еще в PG foreign key уже тогда нормально работали в отличие от MySQL. Это как из песочницы вылезти в реальный мир.
                А еще в PG нет вечного выбора между MyISAM и InnoDB.
                Для меня наоборот после перехода на PG всё встало на свои места т.к. в универе был весьма толковый курс по стандартному SQL.

                  0

                  Ну mysql очень расслабляет, если на нем долго сидишь… вообще стандартный SQL со строгой типизацией знать полезно а то миграция проекта с mysql на postgres может вылиться в очень большую головную боль в рефакторинге 100500 запросов процедур и триггеров

                    0

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

                      +1
                      ну либо не просто включить, а писать нормальный код, например, в одном из старых проектов видел примерно такое:

                      ```php
                      $from = date('Y-m-01 00:00:00');
                      $to = date('Y-m-31 23:59:59');
                      ```
                      и SQL-запрос в который передавались эти даты:

                      ```SQL
                      select *
                      from activities
                      where timestamp between :from and :to
                      ```

                      так вот такого г… в проекте было полно и что самое интересно этот код работал идеально, пока я не попытался переложить это на Postgres, там все валилось в тартарары, причем валилось не всегда, рандомно, периодически…

                      а дело было в том, что когда в месяце был 31 день — все было идеально, а когда в месяце было 28/29/30 дней были Exception-ы на уровне БД, т.к. Postgres валидировал дату и писал, что нет даты 2018-02-31 23:59:59, что она находится за пределами допустимых значений.

                      Поэтому зная это и другие нюансы БД подобных Postgres-у, ты уже не будешь писать такое, а будешь писать примерно такое:

                      ```php
                      $from = date('Y-m-01 00:00:00');
                      $to = date('Y-m-t 23:59:59');
                      ```

                      с одной стороны, если ты всю жизнь планируешь сидеть на MySQL / MariaDB, то возможно не стоит об этом запариваться об этой строгой типизации, лишняя морока и гемор. Особенно, если ты новичок.
                        +1

                        Оно и в mysql бы не работало без включенного ALLOW_INVALID_DATE

                          0

                          Я вообще если честно не понимаю зачем вообще разрешать невалидные даты, какая была задумка у разработчиков MySQL когда они это придумывали? У меня нет идей как это можно использовать в продакшн..


                          Я понимаю вводить опции ускоряющие чтото за счет чего то другого, или опция которая увеличивает максимальную длину строки для group concat, тут ты сам решаешь что тебе важно или нет, в зависимости от ситуации, но опция разрешающая невалидные значения это высший пилотаж)))

                            0

                            Идея была включить обратную совместимость с предыдущими версиями mysql — на каждый чих не по стандарту(?), который там был, такой флаг сделали/ Апгрейдишь версию — всё падает, включаешь флаги — работает, постепенно убираешь из код странные вещи и отключаешь флаги. Правда, в некоторых дистрах/репах уже многие костыли включены и до изменения кода руки не доходят, если вообще осознаётся, что есть такой техдолг.

          0

          Понимаю боль автора. В larevel ядро жестко захардкожено и патчить что-то в нем — лютый ад.
          Мало того при обновлении версии патчить нужно все заново.


          В свое время патчил authorization когда еще не было socialite для входа через oAuth2.0, патчил логи так как monolog умел нужный мне провайдер а вот laravel нет. Патчил models для graphql. В общем жизнь боль.


          Хорошо постепенно в laravel появилась поддержка всего этого из коробки.

            0

            Ну тут приведен пример, как я начинал патчить Лару в части миграций еще начиная с версии 6, затем дважды апнулись совсем без боли до 8-ки.


            Даже доктрину до ^3.0 и версию php8 поддерживаем.

            0
            Смешно, что в самой доктрине завести кастомный тип вообще не проблема
              0

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

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое