diff --git a/composer.lock b/composer.lock index 91cb09a27..573407f5b 100644 --- a/composer.lock +++ b/composer.lock @@ -513,16 +513,16 @@ }, { "name": "laravel/pint", - "version": "v1.13.3", + "version": "v1.13.5", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "93b2d0d49719bc6e444ba21cd4dbbccec935413d" + "reference": "df105cf8ce7a8f0b8a9425ff45cd281a5448e423" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/93b2d0d49719bc6e444ba21cd4dbbccec935413d", - "reference": "93b2d0d49719bc6e444ba21cd4dbbccec935413d", + "url": "https://api.github.com/repos/laravel/pint/zipball/df105cf8ce7a8f0b8a9425ff45cd281a5448e423", + "reference": "df105cf8ce7a8f0b8a9425ff45cd281a5448e423", "shasum": "" }, "require": { @@ -534,12 +534,12 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1", - "illuminate/view": "^10.23.1", + "illuminate/view": "^10.26.2", "laravel-zero/framework": "^10.1.2", "mockery/mockery": "^1.6.6", "nunomaduro/larastan": "^2.6.4", "nunomaduro/termwind": "^1.15.1", - "pestphp/pest": "^2.18.2" + "pestphp/pest": "^2.20.0" }, "bin": [ "builds/pint" @@ -575,7 +575,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2023-10-10T15:39:09+00:00" + "time": "2023-10-26T09:26:10+00:00" }, { "name": "myclabs/deep-copy", @@ -839,16 +839,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.39", + "version": "1.10.40", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "d9dedb0413f678b4d03cbc2279a48f91592c97c4" + "reference": "93c84b5bf7669920d823631e39904d69b9c7dc5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d9dedb0413f678b4d03cbc2279a48f91592c97c4", - "reference": "d9dedb0413f678b4d03cbc2279a48f91592c97c4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93c84b5bf7669920d823631e39904d69b9c7dc5d", + "reference": "93c84b5bf7669920d823631e39904d69b9c7dc5d", "shasum": "" }, "require": { @@ -897,7 +897,7 @@ "type": "tidelift" } ], - "time": "2023-10-17T15:46:26+00:00" + "time": "2023-10-30T14:48:31+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 6b574878c..fa59aecd3 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -428,6 +428,19 @@ abstract public function getDocument(string $collection, string $id, array $quer */ abstract public function createDocument(string $collection, Document $document): Document; + /** + * Create Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws DatabaseException + */ + abstract public function createDocuments(string $collection, array $documents, int $batchSize): array; + /** * Update Document * @@ -438,6 +451,19 @@ abstract public function createDocument(string $collection, Document $document): */ abstract public function updateDocument(string $collection, Document $document): Document; + /** + * Update Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws DatabaseException + */ + abstract public function updateDocuments(string $collection, array $documents, int $batchSize): array; + /** * Delete Document * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 6e51947f4..05d66ef60 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -754,6 +754,109 @@ public function createDocument(string $collection, Document $document): Document return $document; } + /** + * Create Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws DuplicateException + */ + public function createDocuments(string $collection, array $documents, int $batchSize = Database::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return $documents; + } + + $this->getPDO()->beginTransaction(); + + try { + $name = $this->filter($collection); + $batches = \array_chunk($documents, max(1, $batchSize)); + + foreach ($batches as $batch) { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $permissions = []; + + foreach ($batch as $document) { + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + $columns = []; + foreach (\array_keys($attributes) as $key => $attribute) { + $columns[$key] = "`{$this->filter($attribute)}`"; + } + + $columns = '(' . \implode(', ', $columns) . ')'; + + $bindKeys = []; + + foreach ($attributes as $value) { + if (\is_array($value)) { + $value = \json_encode($value); + } + $value = (\is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $value; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + foreach (Database::PERMISSIONS as $type) { + foreach ($document->getPermissionsByType($type) as $permission) { + $permission = \str_replace('"', '', $permission); + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + } + } + } + + $stmt = $this->getPDO()->prepare( + " + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES " . \implode(', ', $batchKeys) + ); + + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmt->execute(); + + if (!empty($permissions)) { + $stmtPermissions = $this->getPDO()->prepare( + " + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document) + VALUES " . \implode(', ', $permissions) + ); + $stmtPermissions?->execute(); + } + } + + if (!$this->getPDO()->commit()) { + throw new Exception('Failed to commit transaction'); + } + + return $documents; + + } catch (PDOException $e) { + $this->getPDO()->rollBack(); + + throw match ($e->getCode()) { + 1062, 23000 => new DuplicateException('Duplicated document: ' . $e->getMessage()), + default => $e, + }; + } + } + /** * Update Document * @@ -961,6 +1064,221 @@ public function updateDocument(string $collection, Document $document): Document return $document; } + /** + * Update Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws DuplicateException + */ + public function updateDocuments(string $collection, array $documents, int $batchSize = Database::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return $documents; + } + + $this->getPDO()->beginTransaction(); + + try { + $name = $this->filter($collection); + $batches = \array_chunk($documents, max(1, $batchSize)); + + foreach ($batches as $batch) { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + + $removeQuery = ''; + $removeBindValues = []; + + $addQuery = ''; + $addBindValues = []; + + foreach ($batch as $index => $document) { + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = json_encode($document->getPermissions()); + + $columns = \array_map(function ($attribute) { + return "`" . $this->filter($attribute) . "`"; + }, \array_keys($attributes)); + + $bindKeys = []; + + foreach ($attributes as $value) { + if (\is_array($value)) { + $value = json_encode($value); + } + $value = (is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $value; + $bindIndex++; + } + + $batchKeys[] = '(' . implode(', ', $bindKeys) . ')'; + + // Permissions logic + $permissionsStmt = $this->getPDO()->prepare(" + SELECT _type, _permission + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid + "); + $permissionsStmt->bindValue(':_uid', $document->getId()); + $permissionsStmt->execute(); + $permissions = $permissionsStmt->fetchAll(PDO::FETCH_ASSOC); + + $initial = []; + foreach (Database::PERMISSIONS as $type) { + $initial[$type] = []; + } + + $permissions = \array_reduce($permissions, function (array $carry, array $item) { + $carry[$item['_type']][] = $item['_permission']; + return $carry; + }, $initial); + + // Get removed Permissions + $removals = []; + foreach (Database::PERMISSIONS as $type) { + $diff = array_diff($permissions[$type], $document->getPermissionsByType($type)); + if (!empty($diff)) { + $removals[$type] = $diff; + } + } + + // Build inner query to remove permissions + if (!empty($removals)) { + foreach ($removals as $type => $permissionsToRemove) { + $bindKey = 'uid_' . $index; + $removeBindKeys[] = ':uid_' . $index; + $removeBindValues[$bindKey] = $document->getId(); + + $removeQuery .= "( + _document = :uid_{$index} + AND _type = '{$type}' + AND _permission IN (" . implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { + $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; + $removeBindKeys[] = ':' . $bindKey; + $removeBindValues[$bindKey] = $permissionsToRemove[$i]; + + return ':' . $bindKey; + }, \array_keys($permissionsToRemove))) . + ") + )"; + + if ($type !== \array_key_last($removals)) { + $removeQuery .= ' OR '; + } + } + + if ($index !== \array_key_last($batch)) { + $removeQuery .= ' OR '; + } + } + + // Get added Permissions + $additions = []; + foreach (Database::PERMISSIONS as $type) { + $diff = \array_diff($document->getPermissionsByType($type), $permissions[$type]); + if (!empty($diff)) { + $additions[$type] = $diff; + } + } + + // Build inner query to add permissions + if (!empty($additions)) { + foreach ($additions as $type => $permissionsToAdd) { + foreach ($permissionsToAdd as $i => $permission) { + $bindKey = 'uid_' . $index; + $addBindValues[$bindKey] = $document->getId(); + + $bindKey = 'add_' . $type . '_' . $index . '_' . $i; + $addBindValues[$bindKey] = $permission; + + $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey})"; + + if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { + $addQuery .= ', '; + } + } + } + if ($index !== \array_key_last($batch)) { + $addQuery .= ', '; + } + } + } + + $updateClause = ''; + for ($i = 0; $i < \count($columns); $i++) { + $column = $columns[$i]; + if (!empty($updateClause)) { + $updateClause .= ', '; + } + $updateClause .= "{$column} = VALUES({$column})"; + } + + $stmt = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name)} (" . \implode(", ", $columns) . ") + VALUES " . \implode(', ', $batchKeys) . " + ON DUPLICATE KEY UPDATE $updateClause + "); + + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmt->execute(); + + if (!empty($removeQuery)) { + $stmtRemovePermissions = $this->getPDO()->prepare(" + DELETE + FROM {$this->getSQLTable($name . '_perms')} + WHERE ({$removeQuery}) + "); + + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmtRemovePermissions->execute(); + } + + if (!empty($addQuery)) { + $stmtAddPermissions = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name . '_perms')} (`_document`, `_type`, `_permission`) + VALUES {$addQuery} + "); + + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmtAddPermissions->execute(); + } + } + + if (!$this->getPDO()->commit()) { + throw new Exception('Failed to commit transaction'); + } + + return $documents; + } catch (PDOException $e) { + $this->getPDO()->rollBack(); + + throw match ($e->getCode()) { + 1062, 23000 => new DuplicateException('Duplicated document: ' . $e->getMessage()), + default => $e, + }; + } + } + /** * Increase or decrease an attribute value * diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index f780e854b..451913461 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -682,6 +682,43 @@ public function createDocument(string $collection, Document $document): Document return new Document($result); } + /** + * Create Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws Duplicate + */ + public function createDocuments(string $collection, array $documents, int $batchSize): array + { + $name = $this->getNamespace() . '_' . $this->filter($collection); + + $records = []; + foreach ($documents as $document) { + $document->removeAttribute('$internalId'); + + $record = $this->replaceChars('$', '_', (array)$document); + $record = $this->timeToMongo($record); + + $records[] = $this->removeNullKeys($record); + } + + $documents = $this->client->insertMany($name, $records); + + foreach ($documents as $index => $document) { + $documents[$index] = $this->replaceChars('_', '$', $this->client->toArray($document)); + $documents[$index] = $this->timeToDocument($documents[$index]); + + $documents[$index] = new Document($documents[$index]); + } + + return $documents; + } + /** * * @param string $name @@ -733,6 +770,34 @@ public function updateDocument(string $collection, Document $document): Document return $document; } + /** + * Update Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws Duplicate + */ + public function updateDocuments(string $collection, array $documents, int $batchSize): array + { + $name = $this->getNamespace() . '_' . $this->filter($collection); + + foreach ($documents as $index => $document) { + $document = $document->getArrayCopy(); + $document = $this->replaceChars('$', '_', $document); + $document = $this->timeToMongo($document); + + $this->client->update($name, ['_uid' => $document['_uid']], $document); + + $documents[$index] = new Document($document); + } + + return $documents; + } + /** * Increase or decrease an attribute value * diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3e19f975b..6b9e3795c 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -756,6 +756,109 @@ public function createDocument(string $collection, Document $document): Document return $document; } + /** + * Create Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws Duplicate + */ + public function createDocuments(string $collection, array $documents, int $batchSize = Database::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return $documents; + } + + $this->getPDO()->beginTransaction(); + + try { + $name = $this->filter($collection); + $batches = \array_chunk($documents, max(1, $batchSize)); + + foreach ($batches as $batch) { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $permissions = []; + + foreach ($batch as $document) { + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + $columns = []; + foreach (\array_keys($attributes) as $key => $attribute) { + $columns[$key] = "\"{$this->filter($attribute)}\""; + } + + $columns = '(' . \implode(', ', $columns) . ')'; + + $bindKeys = []; + + foreach ($attributes as $value) { + if (\is_array($value)) { + $value = \json_encode($value); + } + $value = (\is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $value; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + foreach (Database::PERMISSIONS as $type) { + foreach ($document->getPermissionsByType($type) as $permission) { + $permission = \str_replace('"', '', $permission); + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + } + } + } + + $stmt = $this->getPDO()->prepare( + " + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES " . \implode(', ', $batchKeys) + ); + + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmt->execute(); + + if (!empty($permissions)) { + $stmtPermissions = $this->getPDO()->prepare( + " + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document) + VALUES " . \implode(', ', $permissions) + ); + $stmtPermissions?->execute(); + } + } + + if (!$this->getPDO()->commit()) { + throw new Exception('Failed to commit transaction'); + } + + return $documents; + + } catch (PDOException $e) { + $this->getPDO()->rollBack(); + + throw match ($e->getCode()) { + 1062, 23000 => new Duplicate('Duplicated document: ' . $e->getMessage()), + default => $e, + }; + } + } + /** * Update Document * @@ -952,6 +1055,221 @@ public function updateDocument(string $collection, Document $document): Document return $document; } + /** + * Update Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws Duplicate + */ + public function updateDocuments(string $collection, array $documents, int $batchSize = Database::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return $documents; + } + + $this->getPDO()->beginTransaction(); + + try { + $name = $this->filter($collection); + $batches = \array_chunk($documents, max(1, $batchSize)); + + foreach ($batches as $batch) { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + + $removeQuery = ''; + $removeBindValues = []; + + $addQuery = ''; + $addBindValues = []; + + foreach ($batch as $index => $document) { + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = json_encode($document->getPermissions()); + + $columns = \array_map(function ($attribute) { + return '"' . $this->filter($attribute) . '"'; + }, \array_keys($attributes)); + + $bindKeys = []; + + foreach ($attributes as $value) { + if (\is_array($value)) { + $value = json_encode($value); + } + $value = (is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $value; + $bindIndex++; + } + + $batchKeys[] = '(' . implode(', ', $bindKeys) . ')'; + + // Permissions logic + $permissionsStmt = $this->getPDO()->prepare(" + SELECT _type, _permission + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid + "); + $permissionsStmt->bindValue(':_uid', $document->getId()); + $permissionsStmt->execute(); + $permissions = $permissionsStmt->fetchAll(PDO::FETCH_ASSOC); + + $initial = []; + foreach (Database::PERMISSIONS as $type) { + $initial[$type] = []; + } + + $permissions = \array_reduce($permissions, function (array $carry, array $item) { + $carry[$item['_type']][] = $item['_permission']; + return $carry; + }, $initial); + + // Get removed Permissions + $removals = []; + foreach (Database::PERMISSIONS as $type) { + $diff = array_diff($permissions[$type], $document->getPermissionsByType($type)); + if (!empty($diff)) { + $removals[$type] = $diff; + } + } + + // Build inner query to remove permissions + if (!empty($removals)) { + foreach ($removals as $type => $permissionsToRemove) { + $bindKey = 'uid_' . $index; + $removeBindKeys[] = ':uid_' . $index; + $removeBindValues[$bindKey] = $document->getId(); + + $removeQuery .= "( + _document = :uid_{$index} + AND _type = '{$type}' + AND _permission IN (" . implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { + $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; + $removeBindKeys[] = ':' . $bindKey; + $removeBindValues[$bindKey] = $permissionsToRemove[$i]; + + return ':' . $bindKey; + }, \array_keys($permissionsToRemove))) . + ") + )"; + + if ($type !== \array_key_last($removals)) { + $removeQuery .= ' OR '; + } + } + + if ($index !== \array_key_last($batch)) { + $removeQuery .= ' OR '; + } + } + + // Get added Permissions + $additions = []; + foreach (Database::PERMISSIONS as $type) { + $diff = \array_diff($document->getPermissionsByType($type), $permissions[$type]); + if (!empty($diff)) { + $additions[$type] = $diff; + } + } + + // Build inner query to add permissions + if (!empty($additions)) { + foreach ($additions as $type => $permissionsToAdd) { + foreach ($permissionsToAdd as $i => $permission) { + $bindKey = 'uid_' . $index; + $addBindValues[$bindKey] = $document->getId(); + + $bindKey = 'add_' . $type . '_' . $index . '_' . $i; + $addBindValues[$bindKey] = $permission; + + $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey})"; + + if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { + $addQuery .= ', '; + } + } + } + if ($index !== \array_key_last($batch)) { + $addQuery .= ', '; + } + } + } + + $updateClause = ''; + for ($i = 0; $i < \count($columns); $i++) { + $column = $columns[$i]; + if (!empty($updateClause)) { + $updateClause .= ', '; + } + $updateClause .= "{$column} = excluded.{$column}"; + } + + $stmt = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name)} (" . \implode(", ", $columns) . ") + VALUES " . \implode(', ', $batchKeys) . " + ON CONFLICT (LOWER(_uid)) DO UPDATE SET $updateClause + "); + + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmt->execute(); + + if (!empty($removeQuery)) { + $stmtRemovePermissions = $this->getPDO()->prepare(" + DELETE + FROM {$this->getSQLTable($name . '_perms')} + WHERE ({$removeQuery}) + "); + + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmtRemovePermissions->execute(); + } + + if (!empty($addQuery)) { + $stmtAddPermissions = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission) + VALUES {$addQuery} + "); + + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmtAddPermissions->execute(); + } + } + + if (!$this->getPDO()->commit()) { + throw new Exception('Failed to commit transaction'); + } + + return $documents; + } catch (PDOException $e) { + $this->getPDO()->rollBack(); + + throw match ($e->getCode()) { + 1062, 23000 => new Duplicate('Duplicated document: ' . $e->getMessage()), + default => $e, + }; + } + } + /** * Increase or decrease an attribute value * diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index c30980465..c4afe6810 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -730,6 +730,221 @@ public function updateDocument(string $collection, Document $document): Document return $document; } + /** + * Update Documents in batches + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws Duplicate + */ + public function updateDocuments(string $collection, array $documents, int $batchSize = Database::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return $documents; + } + + $this->getPDO()->beginTransaction(); + + try { + $name = $this->filter($collection); + $batches = \array_chunk($documents, max(1, $batchSize)); + + foreach ($batches as $batch) { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + + $removeQuery = ''; + $removeBindValues = []; + + $addQuery = ''; + $addBindValues = []; + + foreach ($batch as $index => $document) { + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = json_encode($document->getPermissions()); + + $columns = \array_map(function ($attribute) { + return "`" . $this->filter($attribute) . "`"; + }, \array_keys($attributes)); + + $bindKeys = []; + + foreach ($attributes as $value) { + if (\is_array($value)) { + $value = json_encode($value); + } + $value = (is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $value; + $bindIndex++; + } + + $batchKeys[] = '(' . implode(', ', $bindKeys) . ')'; + + // Permissions logic + $permissionsStmt = $this->getPDO()->prepare(" + SELECT _type, _permission + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid + "); + $permissionsStmt->bindValue(':_uid', $document->getId()); + $permissionsStmt->execute(); + $permissions = $permissionsStmt->fetchAll(PDO::FETCH_ASSOC); + + $initial = []; + foreach (Database::PERMISSIONS as $type) { + $initial[$type] = []; + } + + $permissions = \array_reduce($permissions, function (array $carry, array $item) { + $carry[$item['_type']][] = $item['_permission']; + return $carry; + }, $initial); + + // Get removed Permissions + $removals = []; + foreach (Database::PERMISSIONS as $type) { + $diff = array_diff($permissions[$type], $document->getPermissionsByType($type)); + if (!empty($diff)) { + $removals[$type] = $diff; + } + } + + // Build inner query to remove permissions + if (!empty($removals)) { + foreach ($removals as $type => $permissionsToRemove) { + $bindKey = 'uid_' . $index; + $removeBindKeys[] = ':uid_' . $index; + $removeBindValues[$bindKey] = $document->getId(); + + $removeQuery .= "( + _document = :uid_{$index} + AND _type = '{$type}' + AND _permission IN (" . implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { + $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; + $removeBindKeys[] = ':' . $bindKey; + $removeBindValues[$bindKey] = $permissionsToRemove[$i]; + + return ':' . $bindKey; + }, \array_keys($permissionsToRemove))) . + ") + )"; + + if ($type !== \array_key_last($removals)) { + $removeQuery .= ' OR '; + } + } + + if ($index !== \array_key_last($batch)) { + $removeQuery .= ' OR '; + } + } + + // Get added Permissions + $additions = []; + foreach (Database::PERMISSIONS as $type) { + $diff = \array_diff($document->getPermissionsByType($type), $permissions[$type]); + if (!empty($diff)) { + $additions[$type] = $diff; + } + } + + // Build inner query to add permissions + if (!empty($additions)) { + foreach ($additions as $type => $permissionsToAdd) { + foreach ($permissionsToAdd as $i => $permission) { + $bindKey = 'uid_' . $index; + $addBindValues[$bindKey] = $document->getId(); + + $bindKey = 'add_' . $type . '_' . $index . '_' . $i; + $addBindValues[$bindKey] = $permission; + + $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey})"; + + if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { + $addQuery .= ', '; + } + } + } + if ($index !== \array_key_last($batch)) { + $addQuery .= ', '; + } + } + } + + $updateClause = ''; + for ($i = 0; $i < \count($columns); $i++) { + $column = $columns[$i]; + if (!empty($updateClause)) { + $updateClause .= ', '; + } + $updateClause .= "{$column} = excluded.{$column}"; + } + + $stmt = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name)} (" . \implode(", ", $columns) . ") + VALUES " . \implode(', ', $batchKeys) . " + ON CONFLICT(_uid) DO UPDATE SET $updateClause + "); + + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmt->execute(); + + if (!empty($removeQuery)) { + $stmtRemovePermissions = $this->getPDO()->prepare(" + DELETE + FROM {$this->getSQLTable($name . '_perms')} + WHERE ({$removeQuery}) + "); + + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmtRemovePermissions->execute(); + } + + if (!empty($addQuery)) { + $stmtAddPermissions = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name . '_perms')} (`_document`, `_type`, `_permission`) + VALUES {$addQuery} + "); + + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + $stmtAddPermissions->execute(); + } + } + + if (!$this->getPDO()->commit()) { + throw new Exception('Failed to commit transaction'); + } + + return $documents; + } catch (PDOException $e) { + $this->getPDO()->rollBack(); + + throw match ($e->getCode()) { + 1062, 23000 => new Duplicate('Duplicated document: ' . $e->getMessage()), + default => $e, + }; + } + } + /** * Is schemas supported? * diff --git a/src/Database/Database.php b/src/Database/Database.php index 42046db4d..d8393ac8f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -108,8 +108,10 @@ class Database public const EVENT_DOCUMENT_FIND = 'document_find'; public const EVENT_DOCUMENT_CREATE = 'document_create'; + public const EVENT_DOCUMENTS_CREATE = 'documents_create'; public const EVENT_DOCUMENT_READ = 'document_read'; public const EVENT_DOCUMENT_UPDATE = 'document_update'; + public const EVENT_DOCUMENTS_UPDATE = 'documents_update'; public const EVENT_DOCUMENT_DELETE = 'document_delete'; public const EVENT_DOCUMENT_COUNT = 'document_count'; public const EVENT_DOCUMENT_SUM = 'document_sum'; @@ -128,6 +130,8 @@ class Database public const EVENT_INDEX_CREATE = 'index_create'; public const EVENT_INDEX_DELETE = 'index_delete'; + public const INSERT_BATCH_SIZE = 100; + protected Adapter $adapter; protected Cache $cache; @@ -1612,7 +1616,7 @@ public function createRelationship( $twoWayKey ??= $collection->getId(); $attributes = $collection->getAttribute('attributes', []); - /** @var Document[] $attributes */ + /** @var array $attributes */ foreach ($attributes as $attribute) { if (\strtolower($attribute->getId()) === \strtolower($id)) { throw new DuplicateException('Attribute already exists'); @@ -2718,6 +2722,57 @@ public function createDocument(string $collection, Document $document): Document return $document; } + /** + * Create Documents in a batch + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws AuthorizationException + * @throws StructureException + * @throws Exception + */ + public function createDocuments(string $collection, array $documents, int $batchSize = self::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return []; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $time = DateTime::now(); + + foreach ($documents as $key => $document) { + $document + ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) + ->setAttribute('$collection', $collection->getId()) + ->setAttribute('$createdAt', $time) + ->setAttribute('$updatedAt', $time); + + $document = $this->encode($collection, $document); + + $validator = new Structure($collection); + if (!$validator->isValid($document)) { + throw new StructureException($validator->getDescription()); + } + + $documents[$key] = $document; + } + + $documents = $this->adapter->createDocuments($collection->getId(), $documents, $batchSize); + + foreach ($documents as $key => $document) { + $documents[$key] = $this->decode($collection, $document); + } + + $this->trigger(self::EVENT_DOCUMENTS_CREATE, $documents); + + return $documents; + } + /** * @param Document $collection * @param Document $document @@ -3159,6 +3214,68 @@ public function updateDocument(string $collection, string $id, Document $documen return $document; } + /** + * Update Documents in a batch + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * + * @return array + * + * @throws AuthorizationException + * @throws Exception + * @throws StructureException + */ + public function updateDocuments(string $collection, array $documents, int $batchSize = self::INSERT_BATCH_SIZE): array + { + if (empty($documents)) { + return []; + } + + $time = DateTime::now(); + $collection = $this->silent(fn () => $this->getCollection($collection)); + + foreach ($documents as $document) { + if (!$document->getId()) { + throw new Exception('Must define $id attribute for each document'); + } + + $document->setAttribute('$updatedAt', $time); + $document = $this->encode($collection, $document); + + $old = Authorization::skip(fn () => $this->silent( + fn () => $this->getDocument( + $collection->getId(), + $document->getId() + ) + )); + + $validator = new Authorization(self::PERMISSION_UPDATE); + if ($collection->getId() !== self::METADATA + && !$validator->isValid($old->getUpdate())) { + throw new AuthorizationException($validator->getDescription()); + } + + $validator = new Structure($collection); + if (!$validator->isValid($document)) { + throw new StructureException($validator->getDescription()); + } + } + + $documents = $this->adapter->updateDocuments($collection->getId(), $documents, $batchSize); + + foreach ($documents as $key => $document) { + $documents[$key] = $this->decode($collection, $document); + + $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $document->getId() . ':*'); + } + + $this->trigger(self::EVENT_DOCUMENTS_UPDATE, $documents); + + return $documents; + } + /** * @param Document $collection * @param Document $old diff --git a/tests/Database/Adapter/MariaDBTest.php b/tests/Database/Adapter/MariaDBTest.php index af616f260..d5014432e 100644 --- a/tests/Database/Adapter/MariaDBTest.php +++ b/tests/Database/Adapter/MariaDBTest.php @@ -48,7 +48,7 @@ public static function getDatabase(): Database $database = new Database(new MariaDB($pdo), $cache); $database->setDefaultDatabase('utopiaTests'); - $database->setNamespace('myapp_'.uniqid()); + $database->setNamespace('myapp_' . uniqid()); if ($database->exists('utopiaTests')) { $database->delete('utopiaTests'); diff --git a/tests/Database/Base.php b/tests/Database/Base.php index 438d5bd53..31b0dc9df 100644 --- a/tests/Database/Base.php +++ b/tests/Database/Base.php @@ -1016,6 +1016,54 @@ public function testCreateDocument(): Document return $document; } + /** + * @return array + */ + public function testCreateDocuments(): array + { + $count = 3; + $collection = 'testCreateDocuments'; + + static::getDatabase()->createCollection($collection); + + $this->assertEquals(true, static::getDatabase()->createAttribute($collection, 'string', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, static::getDatabase()->createAttribute($collection, 'integer', Database::VAR_INTEGER, 0, true)); + $this->assertEquals(true, static::getDatabase()->createAttribute($collection, 'bigint', Database::VAR_INTEGER, 8, true)); + + // Create an array of documents with random attributes. Don't use the createDocument function + $documents = []; + + for ($i = 0; $i < $count; $i++) { + $documents[] = new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'string' => 'text📝', + 'integer' => 5, + 'bigint' => 8589934592, // 2^33 + ]); + } + + $documents = static::getDatabase()->createDocuments($collection, $documents, 3); + + $this->assertEquals($count, count($documents)); + + foreach ($documents as $document) { + $this->assertNotEmpty(true, $document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working + $this->assertIsInt($document->getAttribute('integer')); + $this->assertEquals(5, $document->getAttribute('integer')); + $this->assertIsInt($document->getAttribute('bigint')); + $this->assertEquals(8589934592, $document->getAttribute('bigint')); + } + + return $documents; + } + public function testRespectNulls(): Document { static::getDatabase()->createCollection('documents_nulls'); @@ -1426,6 +1474,53 @@ public function testUpdateDocument(Document $document): Document return $document; } + /** + * @depends testCreateDocuments + * @param array $documents + */ + public function testUpdateDocuments(array $documents): void + { + $collection = 'testCreateDocuments'; + + foreach ($documents as $document) { + $document + ->setAttribute('string', 'text📝 updated') + ->setAttribute('integer', 6) + ->setAttribute('$permissions', [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ]); + } + + $documents = static::getDatabase()->updateDocuments( + $collection, + $documents, + \count($documents) + ); + + foreach ($documents as $document) { + $this->assertEquals('text📝 updated', $document->getAttribute('string')); + $this->assertEquals(6, $document->getAttribute('integer')); + } + + $documents = static::getDatabase()->find($collection, [ + Query::limit(\count($documents)) + ]); + + foreach ($documents as $document) { + $this->assertEquals('text📝 updated', $document->getAttribute('string')); + $this->assertEquals(6, $document->getAttribute('integer')); + $this->assertEquals([ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], $document->getAttribute('$permissions')); + } + } + /** * @depends testUpdateDocument */