From bae61b90cb8ae868a3df8477f5c446b5fc33d020 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 5 Aug 2025 21:55:44 +1200 Subject: [PATCH] Fix mongo shared tables for v2 --- composer.lock | 6 +- src/Database/Adapter.php | 9 - src/Database/Adapter/Mongo.php | 153 +- src/Database/Adapter/Pool.php | 5 - src/Database/Adapter/SQL.php | 1521 ++++++++++---------- tests/e2e/Adapter/MongoDBTest.php | 1 - tests/e2e/Adapter/Scopes/DocumentTests.php | 28 +- 7 files changed, 872 insertions(+), 851 deletions(-) diff --git a/composer.lock b/composer.lock index 26ac05c5d..debf3125e 100644 --- a/composer.lock +++ b/composer.lock @@ -4341,7 +4341,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4349,6 +4349,6 @@ "ext-pdo": "*", "ext-mbstring": "*" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 04e7c2f38..7ccea8767 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1208,15 +1208,6 @@ abstract public function getInternalIndexesKeys(): array; */ abstract public function getSchemaAttributes(string $collection): array; - /** - * Get the query to check for tenant when in shared tables mode - * - * @param string $collection The collection being queried - * @param string $alias The alias of the parent collection if in a subquery - * @return string - */ - abstract public function getTenantQuery(string $collection, string $alias = ''): string; - /** * @param mixed $stmt * @return bool diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9be680df9..faf16ef23 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -288,7 +288,11 @@ public function createCollection(string $name, array $attributes = [], array $in $key[$attribute] = $order; } - $newIndexes[$i] = ['key' => $key, 'name' => $this->filter($index->getId()), 'unique' => $unique]; + $newIndexes[$i] = [ + 'key' => $key, + 'name' => $this->filter($index->getId()), + 'unique' => $unique + ]; } if (!$this->getClient()->createIndexes($id, $newIndexes)) { @@ -337,7 +341,7 @@ public function getSizeOfCollection(string $collection): int { $namespace = $this->getNamespace(); $collection = $this->filter($collection); - $collection = $namespace. '_' . $collection; + $collection = $namespace . '_' . $collection; $command = [ 'collStats' => $collection, @@ -745,7 +749,7 @@ public function getDocument(string $collection, string $id, array $queries = [], $filters = ['_uid' => $id]; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } $options = []; @@ -761,7 +765,7 @@ public function getDocument(string $collection, string $id, array $queries = [], if (empty($result)) { return new Document([]); } - + $result = $this->replaceChars('_', '$', (array)$result[0]); return new Document($result); @@ -778,7 +782,7 @@ public function getDocument(string $collection, string $id, array $queries = [], */ public function createDocument(string $collection, Document $document): Document { - + $name = $this->getNamespace() . '_' . $this->filter($collection); $sequence = $document->getSequence(); @@ -795,25 +799,22 @@ public function createDocument(string $collection, Document $document): Document if (!empty($sequence)) { $record['_id'] = $sequence; } - + $result = $this->insertDocument($name, $this->removeNullKeys($record)); - + $result = $this->replaceChars('_', '$', $result); return new Document($result); } - /** * Returns the document after casting from - *@param Document $collection + * @param Document $collection * @param Document $document - * @return Document */ - public function castingAfter($collection, $document): Document + public function castingAfter(Document $collection, Document $document): Document { - if (!$this->getSupportForInternalCasting()) { return $document; } @@ -850,7 +851,7 @@ public function castingAfter($collection, $document): Document break; case Database::VAR_DATETIME : if ($node instanceof UTCDateTime) { - $node = DateTime::format($node->toDateTime()); + $node = DateTime::format($node->toDateTime()); } break; default: @@ -866,14 +867,13 @@ public function castingAfter($collection, $document): Document /** * Returns the document after casting to - *@param Document $collection + * @param Document $collection * @param Document $document - * @return Document + * @throws Exception */ - public function castingBefore($collection, $document): Document + public function castingBefore(Document $collection, Document $document): Document { - if (!$this->getSupportForInternalCasting()) { return $document; } @@ -939,7 +939,7 @@ public function createDocuments(string $collection, array $documents): array $records = []; $hasSequence = null; - $documents = array_map(fn ($doc) => clone $doc, $documents); + $documents = \array_map(fn ($doc) => clone $doc, $documents); foreach ($documents as $document) { $sequence = $document->getSequence(); @@ -950,12 +950,6 @@ public function createDocuments(string $collection, array $documents): array throw new DatabaseException('All documents must have an sequence if one is set'); } - $document->removeAttribute('$sequence'); - - if ($this->sharedTables) { - $document->setAttribute('$tenant', $this->getTenant()); - } - $record = $this->replaceChars('$', '_', (array)$document); if (!empty($sequence)) { @@ -985,7 +979,6 @@ public function createDocuments(string $collection, array $documents): array */ private function insertDocument(string $name, array $document): array { - try { $this->client->insert($name, $document); @@ -993,7 +986,7 @@ private function insertDocument(string $name, array $document): array $filters['_uid'] = $document['_uid']; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($name); } $result = $this->client->find( @@ -1001,24 +994,23 @@ private function insertDocument(string $name, array $document): array $filters, ['limit' => 1] )->cursor->firstBatch[0]; - + return $this->client->toArray($result); } catch (MongoException $e) { throw new Duplicate($e->getMessage()); } } - - /** * Update Document * * @param string $collection * @param string $id * @param Document $document - * + * @param bool $skipPermissions * @return Document - * @throws Exception + * @throws DatabaseException + * @throws Duplicate */ public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document { @@ -1027,12 +1019,13 @@ public function updateDocument(string $collection, string $id, Document $documen $record = $document->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - $filters = []; $filters['_uid'] = $id; + if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } + try { unset($record['_id']); // Don't update _id @@ -1069,7 +1062,7 @@ public function updateDocuments(string $collection, Document $updates, array $do $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } $record = $updates->getArrayCopy(); @@ -1115,7 +1108,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a if (!empty($document->getSequence())) { $attributes['_id'] = new ObjectId($document->getSequence()); - } + } if ($this->sharedTables) { $attributes['_tenant'] = $document->getTenant(); @@ -1125,10 +1118,10 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $record = $this->removeNullKeys($record); // Build filter for upsert - $filter = ['_uid' => $document->getId()]; - + $filters = ['_uid' => $document->getId()]; + if ($this->sharedTables) { - $filter['_tenant'] = $document->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } unset($record['_id']); // Don't update _id @@ -1140,7 +1133,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a // Remove the attribute from $set since we're incrementing it // it is requierd to mimic the behaver of SQL on duplicate key update unset($record[$attribute]); - + // Increment the specific attribute and update all other fields $update = [ '$inc' => [$attribute => $attributeValue], @@ -1154,7 +1147,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a } $operations[] = [ - 'filter' => $filter, + 'filter' => $filters, 'update' => $update, ]; } @@ -1168,7 +1161,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a } catch (MongoException $e) { throw $this->processException($e); } - + return \array_map(fn ($change) => $change->getNew(), $changes); } @@ -1178,6 +1171,8 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a * @param string $collection * @param array $documents * @return array + * @throws DatabaseException + * @throws MongoException */ public function getSequences(string $collection, array $documents): array { @@ -1203,15 +1198,15 @@ public function getSequences(string $collection, array $documents): array $filters = ['_uid' => ['$in' => $documentIds]]; if ($this->sharedTables) { - $filters['_tenant'] = ['$in' => $documentTenants]; + $filters['_tenant'] = $this->getTenantFilters($collection, $documentTenants); } - $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + + foreach ($results->cursor->firstBatch as $result) { + $sequences[$result->_uid] = (string)$result->_id; + } - foreach ($results->cursor->firstBatch as $result) { - $sequences[$result->_uid] = (string)$result->_id; - } - foreach ($documents as $document) { if (isset($sequences[$document->getId()])) { $document['$sequence'] = $sequences[$document->getId()]; @@ -1242,7 +1237,7 @@ public function increaseDocumentAttribute(string $collection, string $id, string $filters = ['_uid' => $id]; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } if ($max) { @@ -1280,8 +1275,9 @@ public function deleteDocument(string $collection, string $id): bool $filters = []; $filters['_uid'] = $id; + if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } $result = $this->client->delete($name, $filters); @@ -1304,7 +1300,7 @@ public function deleteDocuments(string $collection, array $sequences, array $per $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_id', $sequences)]); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); @@ -1342,7 +1338,6 @@ public function updateAttribute(string $collection, string $id, string $type, in if (!empty($newKey) && $newKey !== $id) { return $this->renameAttribute($collection, $id, $newKey); } - return true; } @@ -1387,14 +1382,13 @@ protected function getInternalKeyForAttribute(string $attribute): string */ public function find(string $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array { - $name = $this->getNamespace() . '_' . $this->filter($collection); $queries = array_map(fn ($query) => clone $query, $queries); $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenant(); + $filters['_tenant'] = $this->getTenantFilters($collection); } // permissions @@ -1521,7 +1515,6 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } - /** * Converts timestamp to Mongo\BSON datetime format. * @@ -1593,6 +1586,10 @@ public function count(string $collection, array $queries = [], ?int $max = null) // queries $filters = $this->buildFilters($queries); + if ($this->sharedTables) { + $filters['_tenant'] = $this->getTenantFilters($collection); + } + // permissions if (Authorization::$status) { // skip if authorization is disabled $roles = \implode('|', Authorization::getRoles()); @@ -1621,6 +1618,10 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = array_map(fn ($query) => clone $query, $queries); $filters = $this->buildFilters($queries); + if ($this->sharedTables) { + $filters['_tenant'] = $this->getTenantFilters($collection); + } + // permissions if (Authorization::$status) { // skip if authorization is disabled $roles = \implode('|', Authorization::getRoles()); @@ -1839,10 +1840,10 @@ protected function getQueryValue(string $method, mixed $value): mixed switch ($method) { case Query::TYPE_STARTS_WITH: $value = $this->escapeWildcards($value); - return $value.'.*'; + return $value . '.*'; case Query::TYPE_ENDS_WITH: $value = $this->escapeWildcards($value); - return '.*'.$value; + return '.*' . $value; default: return $value; } @@ -1861,7 +1862,7 @@ protected function getOrder(string $order): int return match ($order) { Database::ORDER_ASC => 1, Database::ORDER_DESC => -1, - default => throw new DatabaseException('Unknown sort order:' . $order . '. Must be one of ' . Database::ORDER_ASC . ', ' . Database::ORDER_DESC), + default => throw new DatabaseException('Unknown sort order:' . $order . '. Must be one of ' . Database::ORDER_ASC . ', ' . Database::ORDER_DESC), }; } @@ -2289,7 +2290,7 @@ protected function execute(mixed $stmt): bool return true; } - /** + /** * @return string */ public function getIdAttributeType(): string @@ -2320,10 +2321,36 @@ public function getSchemaAttributes(string $collection): array return []; } - public function getTenantQuery(string $collection, string $parentAlias = ''): string - { - // ** tenant in mongodb is an int but we need to return a string in order to be compatible with the rest of the code - return (string)$this->getTenant(); - } + /** + * @param string $collection + * @param array $tenants + * @return int|array> + */ + public function getTenantFilters( + string $collection, + array $tenants = [], + ): int|array { + $values = []; + if (!$this->sharedTables) { + return $values; + } + if (\count($tenants) === 0) { + $values[] = $this->getTenant(); + } else { + for ($index = 0; $index < \count($tenants); $index++) { + $values[] = $tenants[$index]; + } + } + + if ($collection === Database::METADATA) { + $values[] = null; + } + + if (\count($values) === 1) { + return $values[0]; + } + + return ['$in' => $values]; + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 02925b6fc..060d6bb45 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -485,11 +485,6 @@ public function getSchemaAttributes(string $collection): array return $this->delegate(__FUNCTION__, \func_get_args()); } - public function getTenantQuery(string $collection, string $alias = ''): string - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - protected function execute(mixed $stmt): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fa575bd13..d1207c704 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -398,372 +398,669 @@ public function getDocument(string $collection, string $id, array $queries = [], } /** - * Update documents - * - * Updates all documents which match the given query. + * Create Documents in batches * * @param string $collection - * @param Document $updates * @param array $documents * - * @return int + * @return array * - * @throws DatabaseException + * @throws DuplicateException + * @throws \Throwable */ - public function updateDocuments(string $collection, Document $updates, array $documents): int + public function createDocuments(string $collection, array $documents): array { if (empty($documents)) { - return 0; - } - - $attributes = $updates->getAttributes(); - - if (!empty($updates->getUpdatedAt())) { - $attributes['_updatedAt'] = $updates->getUpdatedAt(); - } - - if (!empty($updates->getCreatedAt())) { - $attributes['_createdAt'] = $updates->getCreatedAt(); + return $documents; } - if (!empty($updates->getPermissions())) { - $attributes['_permissions'] = json_encode($updates->getPermissions()); - } + try { + $name = $this->filter($collection); - if (empty($attributes)) { - return 0; - } + $attributeKeys = Database::INTERNAL_ATTRIBUTE_KEYS; - $bindIndex = 0; - $columns = ''; - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - $columns .= "{$this->quote($column)} = :key_{$bindIndex}"; + $hasSequence = null; + foreach ($documents as $document) { + $attributes = $document->getAttributes(); + $attributeKeys = [...$attributeKeys, ...\array_keys($attributes)]; - if ($attribute !== \array_key_last($attributes)) { - $columns .= ','; + if ($hasSequence === null) { + $hasSequence = !empty($document->getSequence()); + } elseif ($hasSequence == empty($document->getSequence())) { + throw new DatabaseException('All documents must have an sequence if one is set'); + } } - $bindIndex++; - } - - $name = $this->filter($collection); - $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); - - $sql = " - UPDATE {$this->getSQLTable($name)} - SET {$columns} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); - $stmt = $this->getPDO()->prepare($sql); - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); - } + $attributeKeys = array_unique($attributeKeys); - $attributeIndex = 0; - foreach ($attributes as $value) { - if (is_array($value)) { - $value = json_encode($value); + if ($hasSequence) { + $attributeKeys[] = '_id'; } - $bindKey = 'key_' . $attributeIndex; - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; - } + if ($this->sharedTables) { + $attributeKeys[] = '_tenant'; + } - $stmt->execute(); - $affected = $stmt->rowCount(); + $columns = []; + foreach ($attributeKeys as $key => $attribute) { + $columns[$key] = $this->quote($this->filter($attribute)); + } - // Permissions logic - if (!empty($updates->getPermissions())) { - $removeQueries = []; - $removeBindValues = []; + $columns = '(' . \implode(', ', $columns) . ')'; - $addQuery = ''; - $addBindValues = []; + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $permissions = []; foreach ($documents as $index => $document) { - // Permissions logic - $sql = " - SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); - - $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); - if ($this->sharedTables) { - $permissionsStmt->bindValue(':_tenant', $this->tenant); + if (!empty($document->getSequence())) { + $attributes['_id'] = $document->getSequence(); } - $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(); - $permissionsStmt->closeCursor(); - - $initial = []; - foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; + if ($this->sharedTables) { + $attributes['_tenant'] = $document->getTenant(); } - $permissions = \array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - return $carry; - }, $initial); + $bindKeys = []; - // Get removed Permissions - $removals = []; - foreach (Database::PERMISSIONS as $type) { - $diff = array_diff($permissions[$type], $updates->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; + foreach ($attributeKeys as $key) { + $value = $attributes[$key] ?? null; + if (\is_array($value)) { + $value = \json_encode($value); } + $value = (\is_bool($value)) ? (int)$value : $value; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $value; + $bindIndex++; } - // Build inner query to remove permissions - if (!empty($removals)) { - foreach ($removals as $type => $permissionsToRemove) { - $bindKey = '_uid_' . $index; - $removeBindKeys[] = ':_uid_' . $index; - $removeBindValues[$bindKey] = $document->getId(); - - $removeQueries[] = "( - _document = :_uid_{$index} - {$this->getTenantQuery($collection)} - 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))) . - ") - )"; - } - } + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - // Get added Permissions - $additions = []; foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($updates->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; + foreach ($document->getPermissionsByType($type) as $permission) { + $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; + $permission = \str_replace('"', '', $permission); + $permission = "('{$type}', '{$permission}', :_uid_{$index} {$tenantBind})"; + $permissions[] = $permission; } } + } - // 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(); + $batchKeys = \implode(', ', $batchKeys); - $bindKey = 'add_' . $type . '_' . $index . '_' . $i; - $addBindValues[$bindKey] = $permission; + $stmt = $this->getPDO()->prepare(" + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES {$batchKeys} + "); - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + foreach ($bindValues as $key => $value) { + $stmt->bindValue($key, $value, $this->getPDOType($value)); + } - if ($this->sharedTables) { - $addQuery .= ", :_tenant)"; - } else { - $addQuery .= ")"; - } + $this->execute($stmt); - if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { - $addQuery .= ', '; - } - } - } - if ($index !== \array_key_last($documents)) { - $addQuery .= ', '; - } - } - } + if (!empty($permissions)) { + $tenantColumn = $this->sharedTables ? ', _tenant' : ''; + $permissions = \implode(', ', $permissions); - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); + $sqlPermissions = " + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) + VALUES {$permissions}; + "; - $stmtRemovePermissions = $this->getPDO()->prepare(" - DELETE - FROM {$this->getSQLTable($name . '_perms')} - WHERE ({$removeQuery}) - "); + $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + foreach ($documents as $index => $document) { + $stmtPermissions->bindValue(":_uid_{$index}", $document->getId()); + if ($this->sharedTables) { + $stmtPermissions->bindValue(":_tenant_{$index}", $document->getTenant()); + } } - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - $stmtRemovePermissions->execute(); + $this->execute($stmtPermissions); } - if (!empty($addQuery)) { - $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission - "; + } catch (PDOException $e) { + throw $this->processException($e); + } - if ($this->sharedTables) { - $sqlAddPermissions .= ', _tenant)'; - } else { - $sqlAddPermissions .= ')'; - } + return $documents; + } - $sqlAddPermissions .= " VALUES {$addQuery}"; + /** + * @param string $collection + * @param string $attribute + * @param array $changes + * @return array + * @throws DatabaseException + */ + public function createOrUpdateDocuments( + string $collection, + string $attribute, + array $changes + ): array { + if (empty($changes)) { + return $changes; + } - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + try { + $name = $this->filter($collection); + $attribute = $this->filter($attribute); - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + $attributes = []; + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getSequence())) { + $attributes['_id'] = $document->getSequence(); } if ($this->sharedTables) { - $stmtAddPermissions->bindValue(':_tenant', $this->tenant); + $attributes['_tenant'] = $document->getTenant(); } - $stmtAddPermissions->execute(); - } - } + \ksort($attributes); - return $affected; - } + $columns = []; + foreach (\array_keys($attributes) as $key => $attr) { + /** + * @var string $attr + */ + $columns[$key] = "{$this->quote($this->filter($attr))}"; + } + $columns = '(' . \implode(', ', $columns) . ')'; + $bindKeys = []; - /** - * Delete Documents - * - * @param string $collection - * @param array $sequences - * @param array $permissionIds - * - * @return int - * @throws DatabaseException - */ - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int - { - if (empty($sequences)) { - return 0; - } + foreach ($attributes as $attrValue) { + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } - try { - $name = $this->filter($collection); + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + } - $sql = " - DELETE FROM {$this->getSQLTable($name)} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; + $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $attributes, $bindValues, $attribute); + $stmt->execute(); + $stmt->closeCursor(); - $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); + $removeQueries = []; + $removeBindValues = []; + $addQueries = []; + $addBindValues = []; - $stmt = $this->getPDO()->prepare($sql); + foreach ($changes as $index => $change) { + $old = $change->getOld(); + $document = $change->getNew(); - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); - } + $current = []; + foreach (Database::PERMISSIONS as $type) { + $current[$type] = $old->getPermissionsByType($type); + } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } + // Calculate removals + foreach (Database::PERMISSIONS as $type) { + $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); + if (!empty($toRemove)) { + $removeQueries[] = "( + _document = :_uid_{$index} + " . ($this->sharedTables ? " AND _tenant = :_tenant_{$index}" : '') . " + AND _type = '{$type}' + AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") + )"; + $removeBindValues[":_uid_{$index}"] = $document->getId(); + if ($this->sharedTables) { + $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); + } + foreach ($toRemove as $i => $perm) { + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; + } + } + } - if (!$stmt->execute()) { - throw new DatabaseException('Failed to delete documents'); - } + // Calculate additions + foreach (Database::PERMISSIONS as $type) { + $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); - if (!empty($permissionIds)) { - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($permissionIds))) . ") - {$this->getTenantQuery($collection)} - "; + foreach ($toAdd as $i => $permission) { + $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + if ($this->sharedTables) { + $addQuery .= ", :_tenant_{$index}"; + } - $stmtPermissions = $this->getPDO()->prepare($sql); + $addQuery .= ")"; + $addQueries[] = $addQuery; + $addBindValues[":_uid_{$index}"] = $document->getId(); + $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; - foreach ($permissionIds as $id => $value) { - $stmtPermissions->bindValue(":_id_{$id}", $value); + if ($this->sharedTables) { + $addBindValues[":_tenant_{$index}"] = $document->getTenant(); + } + } } + } - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); + // Execute permission removals + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); + $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 (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to delete permissions'); + // Execute permission additions + if (!empty($addQueries)) { + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; + if ($this->sharedTables) { + $sqlAddPermissions .= ", _tenant"; + } + $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); } + $stmtAddPermissions->execute(); } - } catch (\Throwable $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } catch (PDOException $e) { + throw $this->processException($e); } - return $stmt->rowCount(); + return \array_map(fn ($change) => $change->getNew(), $changes); } /** - * Assign internal IDs for the given documents + * Update documents + * + * Updates all documents which match the given query. * * @param string $collection + * @param Document $updates * @param array $documents - * @return array + * + * @return int + * * @throws DatabaseException */ - public function getSequences(string $collection, array $documents): array + public function updateDocuments(string $collection, Document $updates, array $documents): int { - $documentIds = []; - $keys = []; - $binds = []; - - foreach ($documents as $i => $document) { - if (empty($document->getSequence())) { - $documentIds[] = $document->getId(); + if (empty($documents)) { + return 0; + } - $key = ":uid_{$i}"; + $attributes = $updates->getAttributes(); - $binds[$key] = $document->getId(); - $keys[] = $key; + if (!empty($updates->getUpdatedAt())) { + $attributes['_updatedAt'] = $updates->getUpdatedAt(); + } - if ($this->sharedTables) { - $binds[':_tenant_'.$i] = $document->getTenant(); - } - } + if (!empty($updates->getCreatedAt())) { + $attributes['_createdAt'] = $updates->getCreatedAt(); } - if (empty($documentIds)) { - return $documents; + if (!empty($updates->getPermissions())) { + $attributes['_permissions'] = json_encode($updates->getPermissions()); } - $placeholders = implode(',', array_values($keys)); + if (empty($attributes)) { + return 0; + } - $sql = " - SELECT _uid, _id - FROM {$this->getSQLTable($collection)} - WHERE {$this->quote('_uid')} IN ({$placeholders}) - {$this->getTenantQuery($collection, tenantCount: \count($documentIds))} - "; + $bindIndex = 0; + $columns = ''; + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); + $columns .= "{$this->quote($column)} = :key_{$bindIndex}"; - $stmt = $this->getPDO()->prepare($sql); + if ($attribute !== \array_key_last($attributes)) { + $columns .= ','; + } - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value); + $bindIndex++; } - $stmt->execute(); - $sequences = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR); // Fetch as [documentId => sequence] + $name = $this->filter($collection); + $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); + + $sql = " + UPDATE {$this->getSQLTable($name)} + SET {$columns} + WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); + $stmt = $this->getPDO()->prepare($sql); + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + foreach ($sequences as $id => $value) { + $stmt->bindValue(":_id_{$id}", $value); + } + + $attributeIndex = 0; + foreach ($attributes as $value) { + if (is_array($value)) { + $value = json_encode($value); + } + + $bindKey = 'key_' . $attributeIndex; + $value = (is_bool($value)) ? (int)$value : $value; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $attributeIndex++; + } + + $stmt->execute(); + $affected = $stmt->rowCount(); + + // Permissions logic + if (!empty($updates->getPermissions())) { + $removeQueries = []; + $removeBindValues = []; + + $addQuery = ''; + $addBindValues = []; + + foreach ($documents as $index => $document) { + // Permissions logic + $sql = " + SELECT _type, _permission + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + + $permissionsStmt = $this->getPDO()->prepare($sql); + $permissionsStmt->bindValue(':_uid', $document->getId()); + + if ($this->sharedTables) { + $permissionsStmt->bindValue(':_tenant', $this->tenant); + } + + $permissionsStmt->execute(); + $permissions = $permissionsStmt->fetchAll(); + $permissionsStmt->closeCursor(); + + $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], $updates->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(); + + $removeQueries[] = "( + _document = :_uid_{$index} + {$this->getTenantQuery($collection)} + 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))) . + ") + )"; + } + } + + // Get added Permissions + $additions = []; + foreach (Database::PERMISSIONS as $type) { + $diff = \array_diff($updates->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 ($this->sharedTables) { + $addQuery .= ", :_tenant)"; + } else { + $addQuery .= ")"; + } + + if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { + $addQuery .= ', '; + } + } + } + if ($index !== \array_key_last($documents)) { + $addQuery .= ', '; + } + } + } + + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); + + $stmtRemovePermissions = $this->getPDO()->prepare(" + DELETE + FROM {$this->getSQLTable($name . '_perms')} + WHERE ({$removeQuery}) + "); + + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + if ($this->sharedTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } + $stmtRemovePermissions->execute(); + } + + if (!empty($addQuery)) { + $sqlAddPermissions = " + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission + "; + + if ($this->sharedTables) { + $sqlAddPermissions .= ', _tenant)'; + } else { + $sqlAddPermissions .= ')'; + } + + $sqlAddPermissions .= " VALUES {$addQuery}"; + + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + + if ($this->sharedTables) { + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); + } + + $stmtAddPermissions->execute(); + } + } + + return $affected; + } + + + /** + * Delete Documents + * + * @param string $collection + * @param array $sequences + * @param array $permissionIds + * + * @return int + * @throws DatabaseException + */ + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + { + if (empty($sequences)) { + return 0; + } + + try { + $name = $this->filter($collection); + + $sql = " + DELETE FROM {$this->getSQLTable($name)} + WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($sequences as $id => $value) { + $stmt->bindValue(":_id_{$id}", $value); + } + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + if (!$stmt->execute()) { + throw new DatabaseException('Failed to delete documents'); + } + + if (!empty($permissionIds)) { + $sql = " + DELETE FROM {$this->getSQLTable($name . '_perms')} + WHERE _document IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($permissionIds))) . ") + {$this->getTenantQuery($collection)} + "; + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + + $stmtPermissions = $this->getPDO()->prepare($sql); + + foreach ($permissionIds as $id => $value) { + $stmtPermissions->bindValue(":_id_{$id}", $value); + } + + if ($this->sharedTables) { + $stmtPermissions->bindValue(':_tenant', $this->tenant); + } + + if (!$stmtPermissions->execute()) { + throw new DatabaseException('Failed to delete permissions'); + } + } + } catch (\Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + return $stmt->rowCount(); + } + + /** + * Assign internal IDs for the given documents + * + * @param string $collection + * @param array $documents + * @return array + * @throws DatabaseException + */ + public function getSequences(string $collection, array $documents): array + { + $documentIds = []; + $keys = []; + $binds = []; + + foreach ($documents as $i => $document) { + if (empty($document->getSequence())) { + $documentIds[] = $document->getId(); + + $key = ":uid_{$i}"; + + $binds[$key] = $document->getId(); + $keys[] = $key; + + if ($this->sharedTables) { + $binds[':_tenant_'.$i] = $document->getTenant(); + } + } + } + + if (empty($documentIds)) { + return $documents; + } + + $placeholders = implode(',', array_values($keys)); + + $sql = " + SELECT _uid, _id + FROM {$this->getSQLTable($collection)} + WHERE {$this->quote('_uid')} IN ({$placeholders}) + {$this->getTenantQuery($collection, tenantCount: \count($documentIds))} + "; + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $value) { + $stmt->bindValue($key, $value); + } + + $stmt->execute(); + $sequences = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR); // Fetch as [documentId => sequence] $stmt->closeCursor(); foreach ($documents as $document) { @@ -1605,551 +1902,263 @@ protected function getSQLPermissionsCondition( $roles = \implode(', ', $roles); return "{$this->quote($alias)}.{$this->quote('_uid')} IN ( - SELECT _document - FROM {$this->getSQLTable($collection . '_perms')} - WHERE _permission IN ({$roles}) - AND _type = '{$type}' - {$this->getTenantQuery($collection)} - )"; - } - - /** - * Get SQL table - * - * @param string $name - * @return string - * @throws DatabaseException - */ - protected function getSQLTable(string $name): string - { - return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace() . '_' .$this->filter($name))}"; - } - - /** - * Returns the current PDO object - * @return mixed - */ - protected function getPDO(): mixed - { - return $this->pdo; - } - - /** - * Get PDO Type - * - * @param mixed $value - * @return int - * @throws Exception - */ - abstract protected function getPDOType(mixed $value): int; - - /** - * Returns default PDO configuration - * - * @return array - */ - public static function getPDOAttributes(): array - { - return [ - \PDO::ATTR_TIMEOUT => 3, // Specifies the timeout duration in seconds. Takes a value of type int. - \PDO::ATTR_PERSISTENT => true, // Create a persistent connection - \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, // Fetch a result row as an associative array. - \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, // PDO will throw a PDOException on errors - \PDO::ATTR_EMULATE_PREPARES => true, // Emulate prepared statements - \PDO::ATTR_STRINGIFY_FETCHES => true // Returns all fetched data as Strings - ]; - } - - public function getHostname(): string - { - try { - return $this->pdo->getHostname(); - } catch (\Throwable) { - return ''; - } - } - - /** - * @return int - */ - public function getMaxVarcharLength(): int - { - return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 - } - - /** - * @return string - */ - public function getIdAttributeType(): string - { - return Database::VAR_INTEGER; - } - - /** - * @return int - */ - public function getMaxIndexLength(): int - { - /** - * $tenant int = 1 - */ - return $this->sharedTables ? 767 : 768; - } - - /** - * @param Query $query - * @param array $binds - * @return string - * @throws Exception - */ - abstract protected function getSQLCondition(Query $query, array &$binds): string; - - /** - * @param array $queries - * @param array $binds - * @param string $separator - * @return string - * @throws Exception - */ - public function getSQLConditions(array $queries, array &$binds, string $separator = 'AND'): string - { - $conditions = []; - foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { - continue; - } - - if ($query->isNested()) { - $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod()); - } else { - $conditions[] = $this->getSQLCondition($query, $binds); - } - } - - $tmp = implode(' ' . $separator . ' ', $conditions); - return empty($tmp) ? '' : '(' . $tmp . ')'; - } - - /** - * @return string - */ - public function getLikeOperator(): string - { - return 'LIKE'; - } - - public function getInternalIndexesKeys(): array - { - return []; - } - - public function getSchemaAttributes(string $collection): array - { - return []; - } - - public function getTenantQuery( - string $collection, - string $alias = '', - int $tenantCount = 0, - string $condition = 'AND' - ): string { - if (!$this->sharedTables) { - return ''; - } - - $dot = ''; - if ($alias !== '') { - $dot = '.'; - $alias = $this->quote($alias); - } - - $bindings = []; - if ($tenantCount === 0) { - $bindings[] = ':_tenant'; - } else { - for ($index = 0; $index < $tenantCount; $index++) { - $bindings[] = ":_tenant_{$index}"; - } - } - $bindings = \implode(',', $bindings); - - $orIsNull = ''; - if ($collection === Database::METADATA) { - $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; - } - - return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})"; + SELECT _document + FROM {$this->getSQLTable($collection . '_perms')} + WHERE _permission IN ({$roles}) + AND _type = '{$type}' + {$this->getTenantQuery($collection)} + )"; } /** - * Get the SQL projection given the selected attributes + * Get SQL table * - * @param array $selections - * @param string $prefix - * @return mixed - * @throws Exception + * @param string $name + * @return string + * @throws DatabaseException */ - protected function getAttributeProjection(array $selections, string $prefix): mixed + protected function getSQLTable(string $name): string { - if (empty($selections) || \in_array('*', $selections)) { - return "{$this->quote($prefix)}.*"; - } - - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; - - $selections = \array_diff($selections, [...$internalKeys, '$collection']); - - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); - } - - foreach ($selections as &$selection) { - $selection = "{$this->quote($prefix)}.{$this->quote($this->filter($selection))}"; - } - - return \implode(',', $selections); + return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace() . '_' .$this->filter($name))}"; } - protected function getInternalKeyForAttribute(string $attribute): string + /** + * Returns the current PDO object + * @return mixed + */ + protected function getPDO(): mixed { - return match ($attribute) { - '$id' => '_uid', - '$sequence' => '_id', - '$collection' => '_collection', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - '$permissions' => '_permissions', - default => $attribute - }; + return $this->pdo; } - protected function escapeWildcards(string $value): string + /** + * Get PDO Type + * + * @param mixed $value + * @return int + * @throws Exception + */ + abstract protected function getPDOType(mixed $value): int; + + /** + * Returns default PDO configuration + * + * @return array + */ + public static function getPDOAttributes(): array { - $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; + return [ + \PDO::ATTR_TIMEOUT => 3, // Specifies the timeout duration in seconds. Takes a value of type int. + \PDO::ATTR_PERSISTENT => true, // Create a persistent connection + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, // Fetch a result row as an associative array. + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, // PDO will throw a PDOException on errors + \PDO::ATTR_EMULATE_PREPARES => true, // Emulate prepared statements + \PDO::ATTR_STRINGIFY_FETCHES => true // Returns all fetched data as Strings + ]; + } - foreach ($wildcards as $wildcard) { - $value = \str_replace($wildcard, "\\$wildcard", $value); + public function getHostname(): string + { + try { + return $this->pdo->getHostname(); + } catch (\Throwable) { + return ''; } - - return $value; } - protected function processException(PDOException $e): \Exception + /** + * @return int + */ + public function getMaxVarcharLength(): int { - return $e; + return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 } /** - * @param mixed $stmt - * @return bool + * @return string */ - protected function execute(mixed $stmt): bool + public function getIdAttributeType(): string { - return $stmt->execute(); + return Database::VAR_INTEGER; } /** - * Create Documents in batches - * - * @param string $collection - * @param array $documents - * - * @return array - * - * @throws DuplicateException - * @throws \Throwable + * @return int */ - public function createDocuments(string $collection, array $documents): array + public function getMaxIndexLength(): int { - if (empty($documents)) { - return $documents; - } - - try { - $name = $this->filter($collection); - - $attributeKeys = Database::INTERNAL_ATTRIBUTE_KEYS; - - $hasSequence = null; - foreach ($documents as $document) { - $attributes = $document->getAttributes(); - $attributeKeys = [...$attributeKeys, ...\array_keys($attributes)]; - - if ($hasSequence === null) { - $hasSequence = !empty($document->getSequence()); - } elseif ($hasSequence == empty($document->getSequence())) { - throw new DatabaseException('All documents must have an sequence if one is set'); - } - } - - $attributeKeys = array_unique($attributeKeys); - - if ($hasSequence) { - $attributeKeys[] = '_id'; - } - - if ($this->sharedTables) { - $attributeKeys[] = '_tenant'; - } - - $columns = []; - foreach ($attributeKeys as $key => $attribute) { - $columns[$key] = $this->quote($this->filter($attribute)); - } - - $columns = '(' . \implode(', ', $columns) . ')'; - - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $permissions = []; - - foreach ($documents as $index => $document) { - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); - - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); - } - - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } - - $bindKeys = []; - - foreach ($attributeKeys as $key) { - $value = $attributes[$key] ?? null; - 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) { - $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; - $permission = \str_replace('"', '', $permission); - $permission = "('{$type}', '{$permission}', :_uid_{$index} {$tenantBind})"; - $permissions[] = $permission; - } - } - } - - $batchKeys = \implode(', ', $batchKeys); - - $stmt = $this->getPDO()->prepare(" - INSERT INTO {$this->getSQLTable($name)} {$columns} - VALUES {$batchKeys} - "); - - foreach ($bindValues as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } - - $this->execute($stmt); - - if (!empty($permissions)) { - $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $permissions = \implode(', ', $permissions); - - $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) - VALUES {$permissions}; - "; - - $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); + /** + * $tenant int = 1 + */ + return $this->sharedTables ? 767 : 768; + } - foreach ($documents as $index => $document) { - $stmtPermissions->bindValue(":_uid_{$index}", $document->getId()); - if ($this->sharedTables) { - $stmtPermissions->bindValue(":_tenant_{$index}", $document->getTenant()); - } - } + /** + * @param Query $query + * @param array $binds + * @return string + * @throws Exception + */ + abstract protected function getSQLCondition(Query $query, array &$binds): string; - $this->execute($stmtPermissions); + /** + * @param array $queries + * @param array $binds + * @param string $separator + * @return string + * @throws Exception + */ + public function getSQLConditions(array $queries, array &$binds, string $separator = 'AND'): string + { + $conditions = []; + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + continue; } - } catch (PDOException $e) { - throw $this->processException($e); + if ($query->isNested()) { + $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod()); + } else { + $conditions[] = $this->getSQLCondition($query, $binds); + } } - return $documents; + $tmp = implode(' ' . $separator . ' ', $conditions); + return empty($tmp) ? '' : '(' . $tmp . ')'; } /** - * @param string $collection - * @param string $attribute - * @param array $changes - * @return array - * @throws DatabaseException + * @return string */ - public function createOrUpdateDocuments( - string $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; - } - - try { - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - - $attributes = []; - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - - foreach ($changes as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); - - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); - } - - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } + public function getLikeOperator(): string + { + return 'LIKE'; + } - \ksort($attributes); + public function getInternalIndexesKeys(): array + { + return []; + } - $columns = []; - foreach (\array_keys($attributes) as $key => $attr) { - /** - * @var string $attr - */ - $columns[$key] = "{$this->quote($this->filter($attr))}"; - } - $columns = '(' . \implode(', ', $columns) . ')'; + public function getSchemaAttributes(string $collection): array + { + return []; + } - $bindKeys = []; + /** + * Get the query to check for tenant when in shared tables mode + * + * @param string $collection The collection being queried + * @param string $alias The alias of the parent collection if in a subquery + * @param int $tenantCount The number of tenants to check against + * @param string $condition + * @return string + */ + public function getTenantQuery( + string $collection, + string $alias = '', + int $tenantCount = 0, + string $condition = 'AND' + ): string { + if (!$this->sharedTables) { + return ''; + } - foreach ($attributes as $attrValue) { - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - $bindValues[$bindKey] = $attrValue; - $bindIndex++; - } + $dot = ''; + if ($alias !== '') { + $dot = '.'; + $alias = $this->quote($alias); + } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + $bindings = []; + if ($tenantCount === 0) { + $bindings[] = ':_tenant'; + } else { + for ($index = 0; $index < $tenantCount; $index++) { + $bindings[] = ":_tenant_{$index}"; } + } + $bindings = \implode(',', $bindings); - $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $attributes, $bindValues, $attribute); - $stmt->execute(); - $stmt->closeCursor(); - - $removeQueries = []; - $removeBindValues = []; - $addQueries = []; - $addBindValues = []; + $orIsNull = ''; + if ($collection === Database::METADATA) { + $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; + } - foreach ($changes as $index => $change) { - $old = $change->getOld(); - $document = $change->getNew(); + return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})"; + } - $current = []; - foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); - } + /** + * Get the SQL projection given the selected attributes + * + * @param array $selections + * @param string $prefix + * @return mixed + * @throws Exception + */ + protected function getAttributeProjection(array $selections, string $prefix): mixed + { + if (empty($selections) || \in_array('*', $selections)) { + return "{$this->quote($prefix)}.*"; + } - // Calculate removals - foreach (Database::PERMISSIONS as $type) { - $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); - if (!empty($toRemove)) { - $removeQueries[] = "( - _document = :_uid_{$index} - " . ($this->sharedTables ? " AND _tenant = :_tenant_{$index}" : '') . " - AND _type = '{$type}' - AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") - )"; - $removeBindValues[":_uid_{$index}"] = $document->getId(); - if ($this->sharedTables) { - $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - foreach ($toRemove as $i => $perm) { - $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; - } - } - } + $internalKeys = [ + '$id', + '$sequence', + '$permissions', + '$createdAt', + '$updatedAt', + ]; - // Calculate additions - foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + $selections = \array_diff($selections, [...$internalKeys, '$collection']); - foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + foreach ($internalKeys as $internalKey) { + $selections[] = $this->getInternalKeyForAttribute($internalKey); + } - if ($this->sharedTables) { - $addQuery .= ", :_tenant_{$index}"; - } + foreach ($selections as &$selection) { + $selection = "{$this->quote($prefix)}.{$this->quote($this->filter($selection))}"; + } - $addQuery .= ")"; - $addQueries[] = $addQuery; - $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + return \implode(',', $selections); + } - if ($this->sharedTables) { - $addBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - } - } - } + protected function getInternalKeyForAttribute(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + '$sequence' => '_id', + '$collection' => '_collection', + '$tenant' => '_tenant', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + '$permissions' => '_permissions', + default => $attribute + }; + } - // Execute permission removals - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); - $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(); - } + protected function escapeWildcards(string $value): string + { + $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; - // Execute permission additions - if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; - if ($this->sharedTables) { - $sqlAddPermissions .= ", _tenant"; - } - $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtAddPermissions->execute(); - } - } catch (PDOException $e) { - throw $this->processException($e); + foreach ($wildcards as $wildcard) { + $value = \str_replace($wildcard, "\\$wildcard", $value); } - return \array_map(fn ($change) => $change->getNew(), $changes); + return $value; + } + + protected function processException(PDOException $e): \Exception + { + return $e; + } + + /** + * @param mixed $stmt + * @return bool + */ + protected function execute(mixed $stmt): bool + { + return $stmt->execute(); } } diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 39033c61a..55b21f8e4 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -5,7 +5,6 @@ use Exception; use Redis; use Utopia\Cache\Adapter\Redis as RedisAdapter; -use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Database; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 14e045fa3..218571e27 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -42,8 +42,8 @@ public function testCreateDocument(): Document $this->assertEquals(true, $database->createAttribute('documents', 'id', Database::VAR_ID, 0, false, null)); $sequence = '1000000'; - if($database->getAdapter()->getIdAttributeType()== Database::VAR_OBJECT_ID){ - $sequence= '6890c1e3c00288c2470de7a0' ; + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_OBJECT_ID) { + $sequence = '6890c1e3c00288c2470de7a0' ; } $document = $database->createDocument('documents', new Document([ @@ -101,8 +101,8 @@ public function testCreateDocument(): Document $sequence = '56000'; - if($database->getAdapter()->getIdAttributeType()== Database::VAR_OBJECT_ID){ - $sequence= '6890c1e3c00288c2470de7b3' ; + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_OBJECT_ID) { + $sequence = '6890c1e3c00288c2470de7b3' ; } // Test create document with manual internal id @@ -278,8 +278,8 @@ public function testCreateDocument(): Document $this->assertNull($documentIdNull->getAttribute('id')); $sequence = '0'; - if($database->getAdapter()->getIdAttributeType()== Database::VAR_OBJECT_ID){ - $sequence='6890c1e3c00288c0000de7b3'; + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_OBJECT_ID) { + $sequence = '6890c1e3c00288c0000de7b3'; } /** @@ -399,10 +399,10 @@ public function testCreateDocumentsWithAutoIncrement(): void /** @var array $documents */ $documents = []; $offset = 1000000; - for ($i = $offset; $i <= ($offset+10); $i++) { + for ($i = $offset; $i <= ($offset + 10); $i++) { $sequence = (string)$i; - if($database->getAdapter()->getIdAttributeType()== Database::VAR_OBJECT_ID){ - $sequence='689000288c0000de7'.$i; + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_OBJECT_ID) { + $sequence = '689000288c0000de7'.$i; } $hash[$i] = $sequence; @@ -418,7 +418,7 @@ public function testCreateDocumentsWithAutoIncrement(): void 'string' => 'text', ]); } - + $count = $database->createDocuments(__FUNCTION__, $documents, 6); $this->assertEquals($count, \count($documents)); @@ -427,7 +427,7 @@ public function testCreateDocumentsWithAutoIncrement(): void ]); foreach ($documents as $index => $document) { - $this->assertEquals($hash[$index+$offset], $document->getSequence()); + $this->assertEquals($hash[$index + $offset], $document->getSequence()); $this->assertNotEmpty(true, $document->getId()); $this->assertEquals('text', $document->getAttribute('string')); } @@ -550,7 +550,7 @@ public function testSkipPermissions(): void ]; $documents = array_map(fn ($d) => new Document($d), $data); - + Authorization::disable(); $results = []; @@ -4688,8 +4688,8 @@ public function testExceptionCaseInsensitiveDuplicate(Document $document): Docum $database = static::getDatabase(); $sequence = '200'; - if($database->getAdapter()->getIdAttributeType()== Database::VAR_OBJECT_ID){ - $sequence= '6890c1e3c00288c2470de7a0' ; + if ($database->getAdapter()->getIdAttributeType() == Database::VAR_OBJECT_ID) { + $sequence = '6890c1e3c00288c2470de7a0' ; } $document->setAttribute('$id', 'caseSensitive');