Не писал на Хабре еще о том, как я пришел к мысли формирования компонентов для своих будущих проектов или текущий вместо прямого написания кода. Если очень коротко сказать про это, то было все примерно так... Много писал разных проектов, придумывал псевдо компоненты и каждый раз натыкался на то, что в одном проекте ужасно удобно это использовать, а в другом ужасно не удобно. Попробовал перенести "удобные" компоненты в проект и стало все еще более не удобно... Короче, руки не из того места, голова слишком амбициозная... Со временем я дошел до другой мысли: "Надо делать репозитории на GitHub с отдельными компонентами, которые не будут иметь зависимость от других компонентов"... Все шло хорошо, но дошел я до того самого компонента, которые хочет работать с другим компонентом... В итоге на помощь пришли интерфейсы с методами. И вот теперь поговорим о компоненте SQL миграций в том ключе, как я его вижу.

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

Возможно я могу ошибаться, но лично я уверен для себя, что миграции необходимы исключительно для SQL операций с базами данных. Для актуализации файлов можно использовать тот же самый git или центральный init файл, как это есть в Yii2.

Идея

Компонент миграций, поскольку он исключительно для SQL операций, будет иметь в основе своем 2 SQL файла. Да, вот тут сейчас будет шквал критики по поводу входного порога и прочего, но скажу сразу, что со временем работы в компании мы от SQLBuilder перешли на чистый SQL, так как это быстрее. К тому же, большинство современных IDE может генерировать DDL для операций с базой данных. И вот представьте, надо вам создать таблицу, наполнить ее данными, а также что-то изменить в другой таблице. С одной стороны вы получаете длинный код билдером, с другой стороны можете использовать SQL чистый в том же билдере, а еще может быть эта ситуация вперемешку... Короче, тут я понял и решил, что в моем компоненте и подходе к программированию в целом будет как можно меньше двойственности. В связи с этим, я решил использовать только SQL код.

Суть работы компонента: консольной командой создается миграция, вы пишете туда код UP и DOWN, консольными командами применяете или откатываете. Все достаточно просто и очевидно. А теперь перейдем к детальному разбору компонента.

Разбор компонента

В компоненте реализован основной класс SqlMigration, который выполняет базовые операции по работе с миграциями. Большинство методов возвращает наборы данных или логические значения. Данный подход позволит написать практически любую обертку для собственных нужд.

Как пример реализации обертки был реализован класс ConsoleSqlMigration, которые наследуется от SqlMigration и переопределяет его методы. Переопределение первоначально вызывает parent:: после чего реализует дополнительную логику в выводе сообщений в консоль (терминал).

Для реализации компонента необходимо передать класс реализующий интерфейс DatabaseInterface и массив настроек. Обязательными параметрами в настройках являются:

  • schema - схема в базе данных для миграций

  • table - таблица в базе данных для миграций

  • path - путь в файловой структуре для папки с миграциями

Компонент самостоятельно проверяет и создает необходимые (указанные) схемы, таблицы и папки при наличии заранее определенных разрешений (прав). Для корректной работы с базой данных необходимо заранее установить соединение с ней.

Для реализации класса SqlMigration потребуется реализовать его интерфейс. Конечно, класс уже готов, но мало ли вы захотите реализовать как-то по своему. В интерфейсе всего немного методов:

  1. public function up(int $count = 0): array;

  2. public function down(int $count = 0): array;

  3. public function history(int $limit = 0): array;

  4. public function create(string $name): bool;

Эти методы сами за себя говорят. Но на всякий случай, укажу тут их описание из PHPDoc:

/**
	 * Применяет указанное количество миграций
	 *
	 * @param int $count Количество миграция (0 - относительно всех)
	 *
	 * @return array Возвращает список применения и ошибочных миграций. Список может иметь вид:
	 * 1. Случай, когда отсутствуют миграции для выполнения, то возвращается пустой массив
	 * 2. Когда присутствуют миграции для выполнения:
	 * [
	 *  'success' => [...],
	 *  'error' => [...]
	 * ]
	 * Ключ error добавляется только в случае ошибки выполнения миграции.
	 *
	 * @throws SqlMigrationException
	 */
	public function up(int $count = 0): array;
	
	/**
	 * Отменяет указанное количество миграций
	 *
	 * @param int $count Количество миграция (0 - относительно всех)
	 *
	 * @return array Возвращает список отменных и ошибочных миграций. Список может иметь вид:
	 * 1. Случай, когда отсутствуют миграции для выполнения, то возвращается пустой массив
	 * 2. Когда присутствуют миграции для выполнения:
	 * [
	 *  'success' => [...],
	 *  'error' => [...]
	 * ]
	 * Ключ error добавляется только в случае ошибки выполнения миграции.
	 *
	 * @throws SqlMigrationException
	 */
	public function down(int $count = 0): array;
	
	/**
	 * Возвращает список сообщений о примененных миграций
	 *
	 * @param int $limit Ограничение длины списка (null - полный список)
	 *
	 * @return array
	 */
	public function history(int $limit = 0): array;
	
	/**
	 * Создает новую миграцию и возвращает сообщение об успешном создании миграции
	 *
	 * @param string $name Название миграции
	 *
	 * @return bool Возвращает true, если миграция была успешно создана. В остальных случаях выкидывает исключение
	 *
	 * @throws RuntimeException|SqlMigrationException
	 */
	public function create(string $name): bool;

Теперь перейдем непосредственно к классу SqlMigration. Для начала определим константы операций. Это надо будет для того, чтобы в последующих универсальных методах определить точное действие миграции:

/**
 * Константы для определения типа миграции
 */
public const UP = 'up';
public const DOWN = 'down';

Для работы компонента нужен массив его настроек и интерфейс для работы с БД. Для работы с БД будет использоваться мой персональный интерфейс DatabaseInterface. В конструкторе нашего класса мы будем устанавливать зависимости (DI) и проверять корректность переданных настроек:

/**
 * SqlMigration constructor.
 *
 * @param DatabaseInterface $database Компонент работы с базой данных
 * @param array $settings Массив настроек
 *
 * @throws SqlMigrationException
 */
public function __construct(DatabaseInterface $database, array $settings) {
	$this->database = $database;
	$this->settings = $settings;
	
	foreach (['schema', 'table', 'path'] as $settingsKey) {
		if (!array_key_exists($settingsKey, $settings)) {
			throw new SqlMigrationException("Отсутствуют {$settingsKey} настроек.");
		}
	}
}

Теперь надо создать метод, который будет создавать схему и таблицу если таковые не существуют. Метод вернут bool если схема и таблица миграции была создана успешно и в остальных случаях выкидывает исключение:

/**
 * Создает схему и таблицу в случае их отсутствия
 *
 * @return bool Возвращает true, если схема и таблица миграции была создана успешно. В остальных случаях выкидывает
 * исключение
 *
 * @throws SqlMigrationException
 */
public function initSchemaAndTable(): bool {
	$schemaSql = <<<SQL
		CREATE SCHEMA IF NOT EXISTS {$this->settings['schema']};
	SQL;
	
	if (!$this->database->execute($schemaSql)) {
		throw new SqlMigrationException('Ошибка создания схемы миграции');
	}
	
	$tableSql = <<<SQL
		CREATE TABLE IF NOT EXISTS {$this->settings['schema']}.{$this->settings['table']} (
			"name" varchar(180) COLLATE "default" NOT NULL,
			apply_time int4,
			CONSTRAINT {$this->settings['table']}_pk PRIMARY KEY ("name")
		) WITH (OIDS=FALSE)
	SQL;
	
	if (!$this->database->execute($tableSql)) {
		throw new SqlMigrationException('Ошибка создания таблицы миграции');
	}
	
	return true;
}

Теперь надо подготовить методы для работы с миграциями. Начнем с генерации и валидации имени миграции (папки миграции):

/**
 * Проверяет имя миграции на корректность
 *
 * @param string $name Название миграции
 *
 * @throws SqlMigrationException
 */
protected function validateName(string $name): void {
	if (!preg_match('/^[\w]+$/', $name)) {
		throw new SqlMigrationException('Имя миграции должно содержать только буквы, цифры и символы подчеркивания.');
	}
}

/**
 * Создает имя миграции по шаблону: m{дата в формате Ymd_His}_name
 *
 * @param string $name Название миграции
 *
 * @return string
 */
protected function generateName(string $name): string {
	return 'm' . gmdate('Ymd_His') . "_{$name}";
}

Следующим этапом будет создание самой миграции, а именно папки и файлов в ней. Папка будет иметь определенный формат имени: m_дата_пользовательское_имя - а проверка имени файла осуществляется на буквы, цифры и символы подчеркивания:

/**
 * @inheritDoc
 *
 * @throws RuntimeException|SqlMigrationException
 */
public function create(string $name): bool {
	$this->validateName($name);
	
	$migrationMame = $this->generateName($name);
	$path = "{$this->settings['path']}/{$migrationMame}";
	
	if (!mkdir($path, 0775, true) && !is_dir($path)) {
		throw new RuntimeException("Ошибка создания директории. Директория {$path}не была создана");
	}
	
	if (file_put_contents($path . '/up.sql', '') === false) {
		throw new RuntimeException("Ошибка создания файла миграции {$path}/up.sql");
	}
	
	if (!file_put_contents($path . '/down.sql', '') === false) {
		throw new RuntimeException("Ошибка создания файла миграции {$path}/down.sql");
	}
	
	return true;
}

Поскольку мы работаем со списком миграций, то интересно было бы получить вообще все миграции, которые были применены. Этот метод поможет нам в дальнейшем определени не примененных миграций:

/**
 * Возвращает список примененных миграций
 *
 * @param int $limit Ограничение длины списка (null - полный список)
 *
 * @return array
 */
protected function getHistoryList(int $limit = 0): array {
	$limitSql = $limit === 0 ? '' : "LIMIT {$limit}";
	$historySql = <<<SQL
		SELECT "name", apply_time
		FROM {$this->settings['schema']}.{$this->settings['table']}
		ORDER BY apply_time DESC, "name" DESC {$limitSql}
	SQL;
	
	return $this->database->queryAll($historySql);
}

Чтобы получать миграции на основе этого метода напишем еще один метод, который является оберткой на него и доступен для вызова:

/**
 * @inheritDoc
 */
public function history(int $limit = 0): array {
	$historyList = $this->getHistoryList($limit);
	
	if (empty($historyList)) {
		return ['История миграций пуста'];
	}
	
	$messages = [];
	
	foreach ($historyList as $historyRow) {
		$messages[] = "Миграция {$historyRow['name']} от " . date('Y-m-d H:i:s', $historyRow['apply_time']);
	}
	
	return $messages;
}

Так, получили все миграции, которые были применены, а теперь сделаем возможность добавления и удаления миграции в БД. Это надо для того, чтобы при применении миграции записать ее в список уже примененных и не применять заново.

/**
 * Добавляет запись в таблицу миграций
 *
 * @param string $name Наименование миграции
 *
 * @return bool Возвращает true, если миграция была успешно применена (добавлена в таблицу миграций).
 * В остальных случаях выкидывает исключение.
 *
 * @throws SqlMigrationException
 */
protected function addHistory(string $name): bool {
	$sql = <<<SQL
		INSERT INTO {$this->settings['schema']}.{$this->settings['table']} ("name", apply_time) VALUES(:name, :apply_time);
	SQL;
	
	if (!$this->database->execute($sql, ['name' => $name, 'apply_time' => time()])) {
		throw new SqlMigrationException("Ошибка применения миграция {$name}");
	}
	
	return true;
}

/**
 * Удаляет миграцию из таблицы миграций
 *
 * @param string $name Наименование миграции
 *
 * @return bool Возвращает true, если миграция была успешно отменена (удалена из таблицы миграций).
 * В остальных случаях выкидывает исключение.
 *
 * @throws SqlMigrationException
 */
protected function removeHistory(string $name): bool {
	$sql = <<<SQL
		DELETE FROM {$this->settings['schema']}.{$this->settings['table']} WHERE "name" = :name;
	SQL;
	
	if (!$this->database->execute($sql, ['name' => $name])) {
		throw new SqlMigrationException("Ошибка отмены миграции {$name}");
	}
	
	return true;
}

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

/**
 * Возвращает список не примененных миграций
 *
 * @return array
 */
protected function getNotAppliedList(): array {
	$historyList = $this->getHistoryList();
	$historyMap = [];
	
	foreach ($historyList as $item) {
		$historyMap[$item['name']] = true;
	}
	
	$notApplied = [];
	$directoryList = glob("{$this->settings['path']}/m*_*_*");
	
	foreach ($directoryList as $directory) {
		if (!is_dir($directory)) {
			continue;
		}
		
		$directoryParts = explode('/', $directory);
		preg_match('/^(m(\d{8}_?\d{6})\D.*?)$/is', end($directoryParts), $matches);
		$migrationName = $matches[1];
		
		if (!isset($historyMap[$migrationName])) {
			$migrationDateTime = DateTime::createFromFormat('Ymd_His', $matches[2])->format('Y-m-d H:i:s');
			$notApplied[] = [
				'path' => $directory,
				'name' => $migrationName,
				'date_time' => $migrationDateTime
			];
		}
	}
	
	ksort($notApplied);
	
	return $notApplied;
}

И теперь осталось написать методы для накатывания и отката миграции: up и down. Но тут есть маленький нюанс, up и down доступны для вызова и работают одинаково за исключением применяемого файла. Следовательно, надо сделать центральный метод, который выполняет миграцию. Такой метод на вход будет принимать список миграций для выполнения, количество миграций для ограничения (если надо) и тип (up/down - константы, которые мы указали в начале).

/**
 * Выполняет миграции
 *
 * @param array $list Массив миграций
 * @param int $count Количество миграций для применения
 * @param string $type Тип миграции (up/down)
 *
 * @return array Список выполненных миграций
 *
 * @throws RuntimeException
 */
protected function execute(array $list, int $count, string $type): array {
	$migrationInfo = [];
	
	for ($index = 0; $index < $count; $index++) {
		$migration = $list[$index];
		$migration['path'] = array_key_exists('path', $migration) ? $migration['path'] :
			"{$this->settings['path']}/{$migration['name']}";
		$migrationContent = file_get_contents("{$migration['path']}/{$type}.sql");
		
		if ($migrationContent === false) {
			throw new RuntimeException('Ошибка поиска/чтения миграции');
		}
		
		try {
			if (!empty($migrationContent)) {
				$this->database->beginTransaction();
				$this->database->execute($migrationContent);
				$this->database->commit();
			}
			
			if ($type === self::UP) {
				$this->addHistory($migration['name']);
			} else {
				$this->removeHistory($migration['name']);
			}
			
			$migrationInfo['success'][] = $migration;
		} catch (SqlMigrationException | PDOException $exception) {
			$migrationInfo['error'][] = array_merge($migration, ['errorMessage' => $exception->getMessage()]);
			
			break;
		}
	}
	
	return $migrationInfo;
}

Метод до жути простой:

  1. Идет по каждой миграции в ограничение количества миграций для выполнения и берем ее по индексу

  2. Получаем путь до миграции $migration['path'] = array_key_exists('path', $migration) ? $migration['path'] : "{$this->settings['path']}/{$migration['name']}";

  3. Далее получаем содержимое файла с определенным типом (говорили выше): $migrationContent = file_get_contents("{$migration['path']}/{$type}.sql");

  4. И далее просто выполняем все это дело в транзакции. Если UP - до добавляем в истории, а иначе удаляем из истории.

  5. В конце пишем информацию по примененным и ошибочным миграциям (будет одна, так как на этом все упадет).

Достаточно просто, согласитесь. Ну а теперь распишем одинаковые (почти) методы up и down:

/**
 * @inheritDoc
 */
public function up(int $count = 0): array {
	$executeList = $this->getNotAppliedList();
	
	if (empty($executeList)) {
		return [];
	}
	
	$executeListCount = count($executeList);
	$executeCount = $count === 0 ? $executeListCount : min($count, $executeListCount);
	
	return $this->execute($executeList, $executeCount, self::UP);
}

/**
 * @inheritDoc
 */
public function down(int $count = 0): array {
	$executeList = $this->getHistoryList();
	
	if (empty($executeList)) {
		return [];
	}
	
	$executeListCount = count($executeList);
	$executeCount = $count === 0 ? $executeListCount : min($count, $executeListCount);
	
	return $this->execute($executeList, $executeCount, self::DOWN);
}

Расскажу маленькую особенность этого компонента. Вы могли заметить, что тут все возвращаемся массивами. Это надо для того, чтобы вы могли реализовать обработку и выполнение миграций именно тем методом, каким вы хотите. Это что-то на подобии работы API по формату ответа. Вот, например, далее я реализовал класс, который работает с миграциями через консоль и выводит информацию туда:

<?php

declare(strict_types = 1);

namespace mepihindeveloper\components;

use mepihindeveloper\components\exceptions\SqlMigrationException;
use mepihindeveloper\components\interfaces\DatabaseInterface;
use RuntimeException;

/**
 * Class ConsoleSqlMigration
 *
 * Класс предназначен для работы с SQL миграциями с выводом сообщений в консоль (терминал)
 *
 * @package mepihindeveloper\components
 */
class ConsoleSqlMigration extends SqlMigration {
	
	public function __construct(DatabaseInterface $database, array $settings) {
		parent::__construct($database, $settings);
		
		try {
			$this->initSchemaAndTable();
			
			Console::writeLine('Схема и таблица для миграции были успешно созданы', Console::FG_GREEN);
		} catch (SqlMigrationException $exception) {
			Console::writeLine($exception->getMessage(), Console::FG_RED);
			
			exit;
		}
	}
	
	public function up(int $count = 0): array {
		$migrations = parent::up($count);
		
		if (empty($migrations)) {
			Console::writeLine("Нет миграций для применения");
			
			exit;
		}
		
		foreach ($migrations['success'] as $successMigration) {
			Console::writeLine("Миграция {$successMigration['name']} успешно применена", Console::FG_GREEN);
		}
		
		if (array_key_exists('error', $migrations)) {
			foreach ($migrations['error'] as $errorMigration) {
				Console::writeLine("Ошибка применения миграции {$errorMigration['name']}", Console::FG_RED);
			}
			
			exit;
		}
		
		return $migrations;
	}
	
	public function down(int $count = 0): array {
		$migrations = parent::down($count);
		
		if (empty($migrations)) {
			Console::writeLine("Нет миграций для отмены");
			
			exit;
		}
		
		if (array_key_exists('error', $migrations)) {
			foreach ($migrations['error'] as $errorMigration) {
				Console::writeLine("Ошибка отмены миграции {$errorMigration['name']} : " .
					PHP_EOL .
					$errorMigration['errorMessage'],
					Console::FG_RED);
			}
			
			exit;
		}
		
		foreach ($migrations['success'] as $successMigration) {
			Console::writeLine("Миграция {$successMigration['name']} успешно отменена", Console::FG_GREEN);
		}
		
		return $migrations;
	}
	
	public function create(string $name): bool {
		try {
			parent::create($name);
			
			Console::writeLine("Миграция {$name} успешно создана");
		} catch (RuntimeException | SqlMigrationException $exception) {
			Console::writeLine($exception->getMessage(), Console::FG_RED);
			
			return false;
		}
		
		return true;
	}
	
	public function history(int $limit = 0): array {
		$historyList = parent::history($limit);
		
		foreach ($historyList as $historyRow) {
			Console::writeLine($historyRow);
		}
		
		return $historyList;
	}
}

Соглашусь, что компонент вышел не прям убойный и есть вопросы по DI к нему, но работает исправно хорошо. Данный компонент можно посмотреть на GitHub и в Composer.