1
0
Fork 0
mirror of synced 2024-06-03 03:14:50 +12:00
appwrite/src/Appwrite/Migration/Version/V12.php
2022-05-23 14:54:50 +00:00

602 lines
24 KiB
PHP

<?php
namespace Appwrite\Migration\Version;
use Appwrite\Migration\Migration;
use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
class V12 extends Migration
{
/**
* @var \PDO $pdo
*/
private $pdo;
public function execute(): void
{
global $register;
Console::log('Migrating project: ' . $this->project->getAttribute('name') . ' (' . $this->project->getId() . ')');
$this->pdo = $register->get('db');
Console::info('Migrating Project Schemas');
$this->migrateProjectSchema($this->project->getId());
/**
* Switch to migrated Console Project
*/
if ($this->project->getId() === 'console') {
$this->consoleDB->setNamespace('_console');
$this->projectDB->setNamespace('_console');
}
Console::info('Migrating Permissions');
$this->fixPermissions();
Console::info('Migrating Collections');
$this->migrateCustomCollections();
$this->fixCollections();
Console::info('Migrating Documents');
$this->forEachDocument([$this, 'fixDocument']);
}
/**
* Migrate Project Tables.
*
* @param string $projectId
* @return void
* @throws \Exception
* @throws \PDOException
*/
private function migrateProjectSchema(string $projectId): void
{
/**
* Remove empty generated Console Project.
*/
if ($this->consoleDB->getNamespace() === '_project_console' && $projectId === 'console') {
$all = ['_console_bucket_1', '_console_bucket_1_perms'];
foreach ($this->collections as $collection) {
$all[] = "_{$projectId}_{$collection['$id']}";
$all[] = "_{$projectId}_{$collection['$id']}_perms";
}
$this->pdo->prepare('DROP TABLE IF EXISTS ' . implode(', ', $all) . ';')->execute();
} elseif ($this->projectDB->getNamespace() === '_console') {
return;
}
/**
* Rename Database Tables.
*/
foreach ($this->collections as $collection) {
$id = $collection['$id'];
/**
* Skip new tables that don't exists on old schema.
*/
if (in_array($id, ['buckets', 'deployments', 'builds'])) {
continue;
}
$this->pdo->prepare("ALTER TABLE IF EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_project_{$projectId}_{$id}` RENAME TO `_{$projectId}_{$id}`")->execute();
$this->pdo->prepare("CREATE TABLE IF NOT EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_{$projectId}_{$id}_perms` (
`_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`_type` VARCHAR(12) NOT NULL,
`_permission` VARCHAR(255) NOT NULL,
`_document` VARCHAR(255) NOT NULL,
PRIMARY KEY (`_id`),
UNIQUE INDEX `_index1` (`_type`,`_document`,`_permission`),
INDEX `_index2` (`_permission`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;")->execute();
}
}
/**
* Migrate all Collection Structure.
*
* @return void
*/
protected function fixCollections(): void
{
foreach ($this->collections as $collection) {
$id = $collection['$id'];
/**
* Skip new tables that don't exists on old schema.
*/
if (in_array($id, ['buckets', 'deployments', 'builds'])) {
continue;
}
Console::log("- {$id}");
switch ($id) {
case 'sessions':
try {
/**
* Rename providerToken to providerAccessToken
*/
$this->projectDB->renameAttribute($id, 'providerToken', 'providerAccessToken');
} catch (\Throwable $th) {
Console::warning("'providerAccessToken' from {$id}: {$th->getMessage()}");
}
try {
/**
* Create providerRefreshToken
*/
$this->projectDB->createAttribute(collection: $id, id: 'providerRefreshToken', type: Database::VAR_STRING, size: 16384, signed: true, required: false, filters: ['encrypt']);
} catch (\Throwable $th) {
Console::warning("'providerRefreshToken' from {$id}: {$th->getMessage()}");
}
try {
/**
* Create providerAccessTokenExpiry
*/
$this->projectDB->createAttribute(collection: $id, id: 'providerAccessTokenExpiry', type: Database::VAR_INTEGER, size: 0, required: false);
} catch (\Throwable $th) {
Console::warning("'providerAccessTokenExpiry' from {$id}: {$th->getMessage()}");
}
break;
case 'memberships':
try {
/**
* Add search attribute and index to memberships.
*/
$this->projectDB->createAttribute(collection: $id, id: 'search', type: Database::VAR_STRING, size: 16384, required: false);
$this->projectDB->createIndex(collection: $id, id: '_key_search', type: Database::INDEX_FULLTEXT, attributes: ['search']);
} catch (\Throwable $th) {
Console::warning("'search' from {$id}: {$th->getMessage()}");
}
break;
case 'files':
/**
* Create bucket table if not exists.
*/
$this->createCollection('buckets');
if (!$this->projectDB->findOne('buckets', [new Query('$id', Query::TYPE_EQUAL, ['default'])])) {
$this->projectDB->createDocument('buckets', new Document([
'$id' => 'default',
'$collection' => 'buckets',
'dateCreated' => \time(),
'dateUpdated' => \time(),
'name' => 'Default',
'permission' => 'file',
'maximumFileSize' => (int) App::getEnv('_APP_STORAGE_LIMIT', 0), // 10MB
'allowedFileExtensions' => [],
'enabled' => true,
'encryption' => true,
'antivirus' => true,
'$read' => ['role:all'],
'$write' => ['role:all'],
'search' => 'buckets Default',
]));
$this->createCollection('files', 'bucket_1');
/**
* Migrate all files to default Bucket.
*/
$nextDocument = null;
do {
$documents = $this->projectDB->find('files', limit: $this->limit, cursor: $nextDocument);
$count = count($documents);
\Co\run(function (array $documents) {
foreach ($documents as $document) {
go(function (Document $document) {
/**
* Update File Path
*/
$path = "/storage/uploads/app-{$this->project->getId()}";
$new = str_replace($path, "{$path}/default", $document->getAttribute('path'));
$document->setAttribute('path', $new);
/**
* Populate search string from Migration to 0.12.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name'], $document));
}
/**
* Set new values.
*/
$document
->setAttribute('bucketId', 'default')
->setAttribute('chunksTotal', 1)
->setAttribute('chunksUploaded', 1);
$this->projectDB->createDocument('bucket_1', $document);
}, $document);
}
}, $documents);
if ($count !== $this->limit) {
$nextDocument = null;
} else {
$nextDocument = end($documents);
$nextDocument->setAttribute('$collection', 'files');
}
} while (!is_null($nextDocument));
/**
* Rename folder on volumes.
*/
$path = "/storage/uploads/app-{$this->project->getId()}";
if (is_dir("{$path}/")) {
mkdir("/storage/uploads/app-{$this->project->getId()}/default");
foreach (new \DirectoryIterator($path) as $fileinfo) {
if ($fileinfo->isDir() && !$fileinfo->isDot() && $fileinfo->getFilename() !== 'default') {
rename("{$path}/{$fileinfo->getFilename()}", "{$path}/default/{$fileinfo->getFilename()}");
}
}
}
}
break;
case 'functions':
try {
/**
* Rename tag to deployment
*/
$this->projectDB->renameAttribute($id, 'tag', 'deployment');
} catch (\Throwable $th) {
Console::warning("'deployment' from {$id}: {$th->getMessage()}");
}
/**
* Create deployments table if not exists.
*/
$this->createCollection('deployments');
/**
* Create builds table if not exists.
*/
$this->createCollection('builds');
break;
case 'executions':
try {
/**
* Rename tag to deployment
*/
$this->projectDB->renameAttribute($id, 'tagId', 'deploymentId');
} catch (\Throwable $th) {
Console::warning("'deploymentId' from {$id}: {$th->getMessage()}");
}
try {
/**
* Create statusCode
*/
$this->projectDB->createAttribute(collection: $id, id: 'statusCode', type: Database::VAR_INTEGER, size: 0, required: false);
} catch (\Throwable $th) {
Console::warning("'statusCode' from {$id}: {$th->getMessage()}");
}
break;
case 'teams':
try {
/**
* Rename tag to deployment
*/
$this->projectDB->renameAttribute($id, 'sum', 'total');
} catch (\Throwable $th) {
Console::warning("'total' from {$id}: {$th->getMessage()}");
}
break;
}
usleep(100000);
}
}
/**
* Migrates permissions to dedicated table.
*
* @param \Utopia\Database\Document $document
* @param string $internalId
* @return void
* @throws \Exception
* @throws \PDOException
*/
protected function migratePermissionsToDedicatedTable(string $collection, Document $document): void
{
$sql = "SELECT _read, _write FROM `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$collection}` WHERE _uid = {$this->pdo->quote($document->getid())}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute();
$permissions = $stmt->fetch();
$read = json_decode($permissions['_read'] ?? null) ?? [];
$write = json_decode($permissions['_write'] ?? null) ?? [];
$permissions = [];
foreach ($read as $permission) {
$permissions[] = "('read', '{$permission}', '{$document->getId()}')";
}
foreach ($write as $permission) {
$permissions[] = "('write', '{$permission}', '{$document->getId()}')";
}
if (!empty($permissions)) {
$queryPermissions = "INSERT IGNORE INTO `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$collection}_perms` (_type, _permission, _document) VALUES " . implode(', ', $permissions);
$stmtPermissions = $this->pdo->prepare($queryPermissions);
$stmtPermissions->execute();
}
}
/**
* Migrates all user's database collections.
*
* @return void
* @throws \Exception
*/
protected function migrateCustomCollections(): void
{
$nextCollection = null;
do {
$documents = $this->projectDB->find('collections', limit: $this->limit, cursor: $nextCollection);
$count = count($documents);
\Co\run(function (array $documents) {
foreach ($documents as $document) {
go(function (Document $collection) {
$id = $collection->getId();
$projectId = $this->project->getId();
$internalId = $collection->getInternalId();
if ($this->projectDB->exists(App::getEnv('_APP_DB_SCHEMA', 'appwrite'), "collection_{$internalId}")) {
return;
}
Console::log("- {$id} ({$collection->getAttribute('name')})");
/**
* Rename user's colletion table schema
*/
$this->pdo->prepare("ALTER TABLE IF EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_project_{$projectId}_collection_{$id}` RENAME TO `_{$projectId}_collection_{$internalId}`")->execute();
$this->pdo->prepare("CREATE TABLE IF NOT EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_{$projectId}_collection_{$internalId}_perms` (
`_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`_type` VARCHAR(12) NOT NULL,
`_permission` VARCHAR(255) NOT NULL,
`_document` VARCHAR(255) NOT NULL,
PRIMARY KEY (`_id`),
UNIQUE INDEX `_index1` (`_type`,`_document`,`_permission`),
INDEX `_index2` (`_permission`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;")->execute();
/**
* Update metadata table.
*/
$this->pdo->prepare("UPDATE `{$this->projectDB->getDefaultDatabase()}`.`_{$projectId}__metadata`
SET
_uid = 'collection_{$internalId}',
name = 'collection_{$internalId}'
WHERE _uid = 'collection_{$id}';
")->execute();
$nextDocument = null;
do {
$documents = $this->projectDB->find('collection_' . $internalId, limit: $this->limit, cursor: $nextDocument);
$count = count($documents);
foreach ($documents as $document) {
go(function (Document $document, string $internalId) {
$this->migratePermissionsToDedicatedTable("collection_{$internalId}", $document);
}, $document, $internalId);
}
if ($count !== $this->limit) {
$nextDocument = null;
} else {
$nextDocument = end($documents);
}
} while (!is_null($nextDocument));
/**
* Remove _read and _write columns
*/
$this->pdo->prepare("
ALTER TABLE `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_collection_{$internalId}`
DROP COLUMN _read,
DROP COLUMN _write
")->execute();
}, $document);
}
}, $documents);
if ($count !== $this->limit) {
$nextCollection = null;
} else {
$nextCollection = end($documents);
}
} while (!is_null($nextCollection));
}
/**
* Migrate all Permission to new System with dedicated Table.
*
* @return void
* @throws \Exception
*/
protected function fixPermissions()
{
foreach ($this->collections as $collection) {
$id = $collection['$id'];
/**
* Skip new tables that don't exists on old schema.
*/
if (in_array($id, ['buckets', 'deployments', 'builds'])) {
continue;
}
/**
* Check if permissions have already been migrated.
*/
try {
$stmtCheck = $this->pdo->prepare("SHOW COLUMNS from `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$id}` LIKE '_read'");
$stmtCheck->execute();
if (empty($stmtCheck->fetchAll())) {
continue;
}
} catch (\Throwable $th) {
if ($th->getCode() === "42S02") {
continue;
}
throw $th;
}
Console::log("- {$collection['$id']}");
$nextDocument = null;
do {
$documents = $this->projectDB->find($id, limit: $this->limit, cursor: $nextDocument);
$count = count($documents);
\Co\run(function (array $documents) {
foreach ($documents as $document) {
go(function (Document $document) {
$this->migratePermissionsToDedicatedTable($document->getCollection(), $document);
}, $document);
}
}, $documents);
if ($count !== $this->limit) {
$nextDocument = null;
} else {
$nextDocument = end($documents);
}
} while (!is_null($nextDocument));
/**
* Remove _read and _write columns
*/
$this->pdo->prepare("
ALTER TABLE `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$id}`
DROP COLUMN _read,
DROP COLUMN _write
")->execute();
}
/**
* Timeout to give MariaDB some room to breath
*/
usleep(100000);
}
/**
* Fix run on each document
*
* @param \Utopia\Database\Document $document
* @return \Utopia\Database\Document
*/
protected function fixDocument(Document $document)
{
switch ($document->getCollection()) {
case 'projects':
/**
* Bump Project version number.
*/
$document->setAttribute('version', '0.13.0');
/**
* Populate search string from Migration to 0.12.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name'], $document));
}
break;
case 'users':
/**
* Populate search string from Migration to 0.12.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'email', 'name'], $document));
}
break;
case 'teams':
/**
* Populate search string from Migration to 0.12.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name'], $document));
}
break;
case 'functions':
$document->setAttribute('deployment', null);
/**
* Populate search string from Migration to 0.12.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name', 'runtime'], $document));
}
break;
case 'executions':
/**
* Populate search string from Migration to 0.12.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'functionId'], $document));
}
break;
case 'memberships':
/**
* Populate search string.
*/
if (empty($document->getAttribute('search'))) {
$document->setAttribute('search', $this->buildSearchAttribute(['$id', 'userId'], $document));
}
break;
case 'sessions':
$document
->setAttribute('providerRefreshToken', '')
->setAttribute('providerAccessTokenExpiry', 0)
->setAttribute('providerAccessToken', $document->getAttribute('providerToken', ''))
->removeAttribute('providerToken');
break;
}
return $document;
}
/**
* Builds a search string for a fulltext index.
*
* @param array $values
* @param Document $document
* @return string
*/
private function buildSearchAttribute(array $values, Document $document): string
{
$values = array_filter(array_map(fn (string $value) => $document->getAttribute($value) ?? '', $values));
return implode(' ', $values);
}
}