From be5d321ddf6136d9c6c9ccebadf3684e385959a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 09:59:08 +0200 Subject: [PATCH] Memory adapter --- .github/workflows/tests.yml | 1 + src/Database/Adapter/Memory.php | 1583 ++++++++++++++++++++++++++++++ tests/e2e/Adapter/MemoryTest.php | 518 ++++++++++ 3 files changed, 2102 insertions(+) create mode 100644 src/Database/Adapter/Memory.php create mode 100644 tests/e2e/Adapter/MemoryTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 386d728b6..825d47037 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,6 +76,7 @@ jobs: MySQL, Postgres, SQLite, + Memory, Mirror, Pool, SharedTables/MongoDB, diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php new file mode 100644 index 000000000..94bef96c0 --- /dev/null +++ b/src/Database/Adapter/Memory.php @@ -0,0 +1,1583 @@ + + */ + protected array $databases = []; + + /** + * @var array>, indexes: array>, documents: array>, sequence: int}> + */ + protected array $data = []; + + /** + * @var array> + */ + protected array $permissions = []; + + /** + * Transaction savepoint stack. Each entry is a [data, permissions] tuple. + * + * @var array, permissions: array}> + */ + protected array $snapshots = []; + + /** + * @var bool + */ + protected bool $supportForAttributes = true; + + public function __construct() + { + // No external resources to initialise + } + + public function getDriver(): mixed + { + return 'memory'; + } + + protected function key(string $collection): string + { + return $this->getNamespace() . '_' . $this->filter($collection); + } + + public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + { + // No-op: nothing to time out in-memory + } + + public function ping(): bool + { + return true; + } + + public function reconnect(): void + { + // No-op + } + + public function startTransaction(): bool + { + $this->snapshots[] = [ + 'data' => $this->deepCopy($this->data), + 'permissions' => $this->deepCopy($this->permissions), + ]; + $this->inTransaction++; + return true; + } + + public function commitTransaction(): bool + { + if ($this->inTransaction === 0) { + return false; + } + + \array_pop($this->snapshots); + $this->inTransaction--; + return true; + } + + public function rollbackTransaction(): bool + { + if ($this->inTransaction === 0) { + return false; + } + + $snapshot = \array_pop($this->snapshots); + if ($snapshot !== null) { + $this->data = $snapshot['data']; + $this->permissions = $snapshot['permissions']; + } + $this->inTransaction = 0; + $this->snapshots = []; + return true; + } + + public function create(string $name): bool + { + $this->databases[$name] = true; + return true; + } + + public function exists(string $database, ?string $collection = null): bool + { + if ($collection === null) { + return isset($this->databases[$database]); + } + + return isset($this->data[$this->key($collection)]); + } + + public function list(): array + { + $databases = []; + foreach (\array_keys($this->databases) as $name) { + $databases[] = new Document(['name' => $name]); + } + return $databases; + } + + public function delete(string $name): bool + { + unset($this->databases[$name]); + $prefix = $this->getNamespace() . '_'; + foreach (\array_keys($this->data) as $key) { + if (\str_starts_with($key, $prefix)) { + unset($this->data[$key]); + unset($this->permissions[$key]); + } + } + return true; + } + + public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + { + $key = $this->key($name); + if (isset($this->data[$key])) { + throw new DuplicateException('Collection already exists'); + } + + $this->data[$key] = [ + 'attributes' => [], + 'indexes' => [], + 'documents' => [], + 'sequence' => 0, + ]; + $this->permissions[$key] = []; + + foreach ($attributes as $attribute) { + $attrId = $this->filter($attribute->getId()); + $this->data[$key]['attributes'][$attrId] = [ + 'type' => $attribute->getAttribute('type'), + 'size' => $attribute->getAttribute('size', 0), + 'signed' => $attribute->getAttribute('signed', true), + 'array' => $attribute->getAttribute('array', false), + 'required' => $attribute->getAttribute('required', false), + ]; + } + + foreach ($indexes as $index) { + $indexId = $this->filter($index->getId()); + $this->data[$key]['indexes'][$indexId] = [ + 'type' => $index->getAttribute('type'), + 'attributes' => $index->getAttribute('attributes', []), + 'lengths' => $index->getAttribute('lengths', []), + 'orders' => $index->getAttribute('orders', []), + ]; + } + + return true; + } + + public function deleteCollection(string $id): bool + { + $key = $this->key($id); + unset($this->data[$key]); + unset($this->permissions[$key]); + return true; + } + + public function analyzeCollection(string $collection): bool + { + return false; + } + + public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + $id = $this->filter($id); + $this->data[$key]['attributes'][$id] = [ + 'type' => $type, + 'size' => $size, + 'signed' => $signed, + 'array' => $array, + 'required' => $required, + ]; + return true; + } + + public function createAttributes(string $collection, array $attributes): bool + { + foreach ($attributes as $attribute) { + $this->createAttribute( + $collection, + (string) $attribute['$id'], + (string) $attribute['type'], + (int) ($attribute['size'] ?? 0), + (bool) ($attribute['signed'] ?? true), + (bool) ($attribute['array'] ?? false), + (bool) ($attribute['required'] ?? false), + ); + } + return true; + } + + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + $id = $this->filter($id); + if (!empty($newKey) && $newKey !== $id) { + return $this->renameAttribute($collection, $id, $newKey); + } + + $this->data[$key]['attributes'][$id] = [ + 'type' => $type, + 'size' => $size, + 'signed' => $signed, + 'array' => $array, + 'required' => $required, + ]; + return true; + } + + public function deleteAttribute(string $collection, string $id): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + return true; + } + + $id = $this->filter($id); + unset($this->data[$key]['attributes'][$id]); + foreach ($this->data[$key]['documents'] as &$document) { + unset($document[$id]); + } + unset($document); + return true; + } + + public function renameAttribute(string $collection, string $old, string $new): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + $old = $this->filter($old); + $new = $this->filter($new); + + if (!isset($this->data[$key]['attributes'][$old])) { + return true; + } + + $this->data[$key]['attributes'][$new] = $this->data[$key]['attributes'][$old]; + unset($this->data[$key]['attributes'][$old]); + + foreach ($this->data[$key]['documents'] as &$document) { + if (\array_key_exists($old, $document)) { + $document[$new] = $document[$old]; + unset($document[$old]); + } + } + unset($document); + return true; + } + + public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool + { + throw new DatabaseException('Relationships are not implemented in the Memory adapter'); + } + + public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool + { + throw new DatabaseException('Relationships are not implemented in the Memory adapter'); + } + + public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool + { + throw new DatabaseException('Relationships are not implemented in the Memory adapter'); + } + + public function renameIndex(string $collection, string $old, string $new): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + $old = $this->filter($old); + $new = $this->filter($new); + + if (!isset($this->data[$key]['indexes'][$old])) { + return true; + } + + $this->data[$key]['indexes'][$new] = $this->data[$key]['indexes'][$old]; + unset($this->data[$key]['indexes'][$old]); + return true; + } + + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + if ($type === Database::INDEX_FULLTEXT) { + throw new DatabaseException('Fulltext indexes are not implemented in the Memory adapter'); + } + + $id = $this->filter($id); + $this->data[$key]['indexes'][$id] = [ + 'type' => $type, + 'attributes' => $attributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]; + return true; + } + + public function deleteIndex(string $collection, string $id): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + return true; + } + + $id = $this->filter($id); + unset($this->data[$key]['indexes'][$id]); + return true; + } + + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + return new Document([]); + } + + $doc = $this->data[$key]['documents'][$id] ?? null; + if ($doc === null) { + return new Document([]); + } + + if ($this->sharedTables && ($doc['_tenant'] ?? null) !== $this->getTenant()) { + return new Document([]); + } + + return new Document($this->rowToDocument($doc)); + } + + public function createDocument(Document $collection, Document $document): Document + { + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + if (isset($this->data[$key]['documents'][$document->getId()])) { + $existing = $this->data[$key]['documents'][$document->getId()]; + if (!$this->sharedTables || ($existing['_tenant'] ?? null) === $this->getTenant()) { + throw new DuplicateException('Document already exists'); + } + } + + $this->enforceUniqueIndexes($key, $document, null); + + $sequence = $document->getSequence(); + if (empty($sequence)) { + $this->data[$key]['sequence']++; + $sequence = $this->data[$key]['sequence']; + } else { + $sequence = (int) $sequence; + if ($sequence > $this->data[$key]['sequence']) { + $this->data[$key]['sequence'] = $sequence; + } + } + + $row = $this->documentToRow($document); + $row['_id'] = $sequence; + + $this->data[$key]['documents'][$document->getId()] = $row; + $this->writePermissions($key, $document); + + $document['$sequence'] = (string) $sequence; + return $document; + } + + public function createDocuments(Document $collection, array $documents): array + { + $created = []; + foreach ($documents as $document) { + $created[] = $this->createDocument($collection, $document); + } + return $created; + } + + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + throw new NotFoundException('Collection not found'); + } + + $existing = $this->data[$key]['documents'][$id] ?? null; + if ($existing === null) { + throw new NotFoundException('Document not found'); + } + + $newId = $document->getId(); + if ($newId !== $id && isset($this->data[$key]['documents'][$newId])) { + throw new DuplicateException('Document already exists'); + } + + $this->enforceUniqueIndexes($key, $document, $id); + + $row = $this->documentToRow($document); + $row['_id'] = $existing['_id']; + + if ($newId !== $id) { + unset($this->data[$key]['documents'][$id]); + } + $this->data[$key]['documents'][$newId] = $row; + + if (!$skipPermissions) { + // Remove any permissions keyed to the old uid and rewrite. + $this->permissions[$key] = \array_values(\array_filter( + $this->permissions[$key], + fn (array $p) => $p['document'] !== $id && $p['document'] !== $newId + )); + $this->writePermissions($key, $document); + } elseif ($newId !== $id) { + foreach ($this->permissions[$key] as &$row) { + if ($row['document'] === $id) { + $row['document'] = $newId; + } + } + unset($row); + } + + return $document; + } + + public function updateDocuments(Document $collection, Document $updates, array $documents): int + { + if (empty($documents)) { + return 0; + } + + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + return 0; + } + + $attrs = $updates->getAttributes(); + $hasCreatedAt = !empty($updates->getCreatedAt()); + $hasUpdatedAt = !empty($updates->getUpdatedAt()); + $hasPermissions = $updates->offsetExists('$permissions'); + if (empty($attrs) && !$hasCreatedAt && !$hasUpdatedAt && !$hasPermissions) { + return 0; + } + + $count = 0; + foreach ($documents as $doc) { + $uid = $doc->getId(); + if (!isset($this->data[$key]['documents'][$uid])) { + continue; + } + + $row = &$this->data[$key]['documents'][$uid]; + foreach ($attrs as $attribute => $value) { + if (\is_array($value)) { + $value = \json_encode($value); + } + $row[$this->filter($attribute)] = $value; + } + + if ($hasCreatedAt) { + $row['_createdAt'] = $updates->getCreatedAt(); + } + if ($hasUpdatedAt) { + $row['_updatedAt'] = $updates->getUpdatedAt(); + } + if ($hasPermissions) { + $row['_permissions'] = \json_encode($updates->getPermissions()); + $this->permissions[$key] = \array_values(\array_filter( + $this->permissions[$key], + fn (array $p) => $p['document'] !== $uid + )); + foreach (Database::PERMISSIONS as $type) { + foreach ($updates->getPermissionsByType($type) as $permission) { + $this->permissions[$key][] = [ + 'document' => $uid, + 'type' => $type, + 'permission' => \str_replace('"', '', $permission), + 'tenant' => $this->getTenant(), + ]; + } + } + } + $count++; + unset($row); + } + + return $count; + } + + public function upsertDocuments(Document $collection, string $attribute, array $changes): array + { + throw new DatabaseException('Upsert is not implemented in the Memory adapter'); + } + + public function getSequences(string $collection, array $documents): array + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + return $documents; + } + + foreach ($documents as $index => $doc) { + $existing = $this->data[$key]['documents'][$doc->getId()] ?? null; + if ($existing !== null) { + $documents[$index]->setAttribute('$sequence', (string) $existing['_id']); + } + } + return $documents; + } + + public function deleteDocument(string $collection, string $id): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + return false; + } + + if (!isset($this->data[$key]['documents'][$id])) { + return false; + } + + unset($this->data[$key]['documents'][$id]); + $this->permissions[$key] = \array_values(\array_filter( + $this->permissions[$key] ?? [], + fn (array $p) => $p['document'] !== $id + )); + return true; + } + + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + return 0; + } + + $seqSet = []; + foreach ($sequences as $seq) { + $seqSet[(string) $seq] = true; + } + + $count = 0; + foreach ($this->data[$key]['documents'] as $uid => $row) { + if (isset($seqSet[(string) ($row['_id'] ?? '')])) { + unset($this->data[$key]['documents'][$uid]); + $count++; + } + } + + if (!empty($permissionIds)) { + $permSet = \array_flip(\array_map('strval', $permissionIds)); + $this->permissions[$key] = \array_values(\array_filter( + $this->permissions[$key] ?? [], + fn (array $p) => !isset($permSet[$p['document']]) + )); + } + + return $count; + } + + public function find(Document $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 + { + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + return []; + } + + $rows = \array_values($this->data[$key]['documents']); + $rows = $this->applyTenantFilter($rows); + $rows = $this->applyQueries($rows, $queries); + $rows = $this->applyPermissions($collection, $rows, $forPermission); + $rows = $this->applyOrdering($rows, $orderAttributes, $orderTypes, $cursorDirection); + $rows = $this->applyCursor($rows, $orderAttributes, $orderTypes, $cursor, $cursorDirection); + + if (!is_null($offset)) { + $rows = \array_slice($rows, $offset); + } + if (!is_null($limit)) { + $rows = \array_slice($rows, 0, $limit); + } + + $results = []; + foreach ($rows as $row) { + $results[] = new Document($this->rowToDocument($row)); + } + + if ($cursorDirection === Database::CURSOR_BEFORE) { + $results = \array_reverse($results); + } + + return $results; + } + + public function count(Document $collection, array $queries = [], ?int $max = null): int + { + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + return 0; + } + + $rows = \array_values($this->data[$key]['documents']); + $rows = $this->applyTenantFilter($rows); + $rows = $this->applyQueries($rows, $queries); + $rows = $this->applyPermissions($collection, $rows, Database::PERMISSION_READ); + + $total = \count($rows); + if (!is_null($max) && $max > 0 && $total > $max) { + return $max; + } + return $total; + } + + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + { + $key = $this->key($collection->getId()); + if (!isset($this->data[$key])) { + return 0; + } + + $rows = \array_values($this->data[$key]['documents']); + $rows = $this->applyTenantFilter($rows); + $rows = $this->applyQueries($rows, $queries); + $rows = $this->applyPermissions($collection, $rows, Database::PERMISSION_READ); + + if (!is_null($max) && $max > 0) { + $rows = \array_slice($rows, 0, $max); + } + + $sum = 0; + $isFloat = false; + $column = $this->filter($attribute); + foreach ($rows as $row) { + if (!\array_key_exists($column, $row) || $row[$column] === null) { + continue; + } + if (\is_float($row[$column])) { + $isFloat = true; + } + $sum += $row[$column]; + } + + return $isFloat ? (float) $sum : (int) $sum; + } + + public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool + { + $key = $this->key($collection); + if (!isset($this->data[$key]['documents'][$id])) { + throw new NotFoundException('Document not found'); + } + + $column = $this->filter($attribute); + $current = $this->data[$key]['documents'][$id][$column] ?? 0; + $current = is_numeric($current) ? $current + 0 : 0; + $next = $current + $value; + + if (!is_null($min) && $next < $min) { + return false; + } + if (!is_null($max) && $next > $max) { + return false; + } + + $this->data[$key]['documents'][$id][$column] = $next; + $this->data[$key]['documents'][$id]['_updatedAt'] = $updatedAt; + return true; + } + + public function getSizeOfCollection(string $collection): int + { + $key = $this->key($collection); + if (!isset($this->data[$key])) { + return 0; + } + return \strlen(\serialize($this->data[$key])); + } + + public function getSizeOfCollectionOnDisk(string $collection): int + { + return $this->getSizeOfCollection($collection); + } + + public function getLimitForString(): int + { + return 4294967295; + } + + public function getLimitForInt(): int + { + return 4294967295; + } + + public function getLimitForAttributes(): int + { + return 1017; + } + + public function getLimitForIndexes(): int + { + return 64; + } + + public function getMaxIndexLength(): int + { + return 0; + } + + public function getMaxVarcharLength(): int + { + return 16381; + } + + public function getMaxUIDLength(): int + { + return 255; + } + + public function getMinDateTime(): \DateTime + { + return new \DateTime('0001-01-01 00:00:00'); + } + + public function getIdAttributeType(): string + { + return Database::VAR_INTEGER; + } + + public function getSupportForSchemas(): bool + { + return false; + } + + public function getSupportForAttributes(): bool + { + return $this->supportForAttributes; + } + + public function setSupportForAttributes(bool $support): bool + { + $this->supportForAttributes = $support; + return $this->supportForAttributes; + } + + public function getSupportForSchemaAttributes(): bool + { + return false; + } + + public function getSupportForSchemaIndexes(): bool + { + return false; + } + + public function getSupportForIndex(): bool + { + return true; + } + + public function getSupportForIndexArray(): bool + { + return false; + } + + public function getSupportForCastIndexArray(): bool + { + return false; + } + + public function getSupportForUniqueIndex(): bool + { + return true; + } + + public function getSupportForFulltextIndex(): bool + { + return false; + } + + public function getSupportForFulltextWildcardIndex(): bool + { + return false; + } + + public function getSupportForCasting(): bool + { + return false; + } + + public function getSupportForQueryContains(): bool + { + return true; + } + + public function getSupportForTimeouts(): bool + { + return false; + } + + public function getSupportForRelationships(): bool + { + return false; + } + + public function getSupportForUpdateLock(): bool + { + return false; + } + + public function getSupportForBatchOperations(): bool + { + return true; + } + + public function getSupportForAttributeResizing(): bool + { + return true; + } + + public function getSupportForGetConnectionId(): bool + { + return false; + } + + public function getSupportForUpserts(): bool + { + return false; + } + + public function getSupportForVectors(): bool + { + return false; + } + + public function getSupportForCacheSkipOnFailure(): bool + { + return false; + } + + public function getSupportForReconnection(): bool + { + return false; + } + + public function getSupportForHostname(): bool + { + return false; + } + + public function getSupportForBatchCreateAttributes(): bool + { + return true; + } + + public function getSupportForSpatialAttributes(): bool + { + return false; + } + + public function getSupportForObject(): bool + { + return false; + } + + public function getSupportForObjectIndexes(): bool + { + return false; + } + + public function getSupportForSpatialIndexNull(): bool + { + return false; + } + + public function getSupportForOperators(): bool + { + return false; + } + + public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool + { + return false; + } + + public function getSupportForSpatialIndexOrder(): bool + { + return false; + } + + public function getSupportForSpatialAxisOrder(): bool + { + return false; + } + + public function getSupportForBoundaryInclusiveContains(): bool + { + return false; + } + + public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + { + return false; + } + + public function getSupportForMultipleFulltextIndexes(): bool + { + return false; + } + + public function getSupportForIdenticalIndexes(): bool + { + return false; + } + + public function getSupportForOrderRandom(): bool + { + return true; + } + + public function getCountOfAttributes(Document $collection): int + { + return \count($collection->getAttribute('attributes', [])) + $this->getCountOfDefaultAttributes(); + } + + public function getCountOfIndexes(Document $collection): int + { + return \count($collection->getAttribute('indexes', [])) + $this->getCountOfDefaultIndexes(); + } + + public function getCountOfDefaultAttributes(): int + { + return \count(Database::INTERNAL_ATTRIBUTES); + } + + public function getCountOfDefaultIndexes(): int + { + return \count(Database::INTERNAL_INDEXES); + } + + public function getDocumentSizeLimit(): int + { + return 0; + } + + public function getAttributeWidth(Document $collection): int + { + return 0; + } + + public function getKeywords(): array + { + return []; + } + + protected function getAttributeProjection(array $selections, string $prefix): mixed + { + return $selections; + } + + public function getConnectionId(): string + { + return '0'; + } + + public function getInternalIndexesKeys(): array + { + return []; + } + + public function getSchemaAttributes(string $collection): array + { + return []; + } + + public function getSchemaIndexes(string $collection): array + { + return []; + } + + public function getTenantQuery(string $collection, string $alias = ''): string + { + return ''; + } + + protected function execute(mixed $stmt): bool + { + return true; + } + + protected function quote(string $string): string + { + return '"' . $string . '"'; + } + + public function decodePoint(string $wkb): array + { + throw new DatabaseException('Spatial types are not implemented in the Memory adapter'); + } + + public function decodeLinestring(string $wkb): array + { + throw new DatabaseException('Spatial types are not implemented in the Memory adapter'); + } + + public function decodePolygon(string $wkb): array + { + throw new DatabaseException('Spatial types are not implemented in the Memory adapter'); + } + + public function castingBefore(Document $collection, Document $document): Document + { + return $document; + } + + public function castingAfter(Document $collection, Document $document): Document + { + return $document; + } + + public function getSupportForInternalCasting(): bool + { + return false; + } + + public function getSupportForUTCCasting(): bool + { + return false; + } + + public function setUTCDatetime(string $value): mixed + { + return $value; + } + + public function getSupportForIntegerBooleans(): bool + { + return false; + } + + public function getSupportForAlterLocks(): bool + { + return false; + } + + public function getSupportNonUtfCharacters(): bool + { + return true; + } + + public function getSupportForTrigramIndex(): bool + { + return false; + } + + public function getSupportForPCRERegex(): bool + { + return false; + } + + public function getSupportForPOSIXRegex(): bool + { + return false; + } + + public function getSupportForTransactionRetries(): bool + { + return false; + } + + public function getSupportForNestedTransactions(): bool + { + return true; + } + + // ----------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------- + + /** + * @param array $value + * @return array + */ + protected function deepCopy(array $value): array + { + return \unserialize(\serialize($value)); + } + + /** + * @return array + */ + protected function documentToRow(Document $document): array + { + $attributes = $document->getAttributes(); + foreach ($attributes as $attribute => $value) { + if (\is_array($value)) { + $attributes[$attribute] = \json_encode($value); + } + } + + $row = []; + foreach ($attributes as $attribute => $value) { + $row[$this->filter($attribute)] = $value; + } + + $row['_uid'] = $document->getId(); + $row['_createdAt'] = $document->getCreatedAt(); + $row['_updatedAt'] = $document->getUpdatedAt(); + $row['_permissions'] = \json_encode($document->getPermissions()); + if ($this->sharedTables) { + $row['_tenant'] = $this->getTenant(); + } + return $row; + } + + /** + * @param array $row + * @return array + */ + protected function rowToDocument(array $row): array + { + $document = []; + foreach ($row as $key => $value) { + switch ($key) { + case '_id': + $document['$sequence'] = (string) $value; + break; + case '_uid': + $document['$id'] = $value; + break; + case '_tenant': + $document['$tenant'] = $value; + break; + case '_createdAt': + $document['$createdAt'] = $value; + break; + case '_updatedAt': + $document['$updatedAt'] = $value; + break; + case '_permissions': + $document['$permissions'] = \is_string($value) ? (\json_decode($value, true) ?? []) : ($value ?? []); + break; + default: + $document[$key] = $value; + } + } + return $document; + } + + /** + * @param string $key + * @param Document $document + */ + protected function writePermissions(string $key, Document $document): void + { + $uid = $document->getId(); + foreach (Database::PERMISSIONS as $type) { + foreach ($document->getPermissionsByType($type) as $permission) { + $this->permissions[$key][] = [ + 'document' => $uid, + 'type' => $type, + 'permission' => \str_replace('"', '', $permission), + 'tenant' => $this->getTenant(), + ]; + } + } + } + + /** + * @param array> $rows + * @return array> + */ + protected function applyTenantFilter(array $rows): array + { + if (!$this->sharedTables) { + return $rows; + } + + $tenant = $this->getTenant(); + return \array_values(\array_filter( + $rows, + fn (array $row) => ($row['_tenant'] ?? null) === $tenant + )); + } + + /** + * @param array> $rows + * @param array $queries + * @return array> + */ + protected function applyQueries(array $rows, array $queries): array + { + foreach ($queries as $query) { + $method = $query->getMethod(); + + if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { + continue; + } + + $rows = \array_values(\array_filter($rows, fn (array $row) => $this->matches($row, $query))); + } + return $rows; + } + + /** + * @param array $row + */ + protected function matches(array $row, Query $query): bool + { + $method = $query->getMethod(); + + if ($method === Query::TYPE_AND) { + foreach ($query->getValues() as $sub) { + if (!($sub instanceof Query) || !$this->matches($row, $sub)) { + return false; + } + } + return true; + } + + if ($method === Query::TYPE_OR) { + foreach ($query->getValues() as $sub) { + if ($sub instanceof Query && $this->matches($row, $sub)) { + return true; + } + } + return false; + } + + $attribute = $this->mapAttribute($query->getAttribute()); + $value = \array_key_exists($attribute, $row) ? $row[$attribute] : null; + $queryValues = $query->getValues(); + + switch ($method) { + case Query::TYPE_EQUAL: + foreach ($queryValues as $candidate) { + if ($this->looseEquals($value, $candidate)) { + return true; + } + } + return false; + + case Query::TYPE_NOT_EQUAL: + foreach ($queryValues as $candidate) { + if ($this->looseEquals($value, $candidate)) { + return false; + } + } + return true; + + case Query::TYPE_LESSER: + return $value !== null && $value < $queryValues[0]; + + case Query::TYPE_LESSER_EQUAL: + return $value !== null && $value <= $queryValues[0]; + + case Query::TYPE_GREATER: + return $value !== null && $value > $queryValues[0]; + + case Query::TYPE_GREATER_EQUAL: + return $value !== null && $value >= $queryValues[0]; + + case Query::TYPE_IS_NULL: + return $value === null; + + case Query::TYPE_IS_NOT_NULL: + return $value !== null; + + case Query::TYPE_BETWEEN: + return $value !== null && $value >= $queryValues[0] && $value <= $queryValues[1]; + + case Query::TYPE_NOT_BETWEEN: + return $value === null || $value < $queryValues[0] || $value > $queryValues[1]; + + case Query::TYPE_STARTS_WITH: + return \is_string($value) && \is_string($queryValues[0]) && \str_starts_with($value, $queryValues[0]); + + case Query::TYPE_NOT_STARTS_WITH: + return !\is_string($value) || !\is_string($queryValues[0]) || !\str_starts_with($value, $queryValues[0]); + + case Query::TYPE_ENDS_WITH: + return \is_string($value) && \is_string($queryValues[0]) && \str_ends_with($value, $queryValues[0]); + + case Query::TYPE_NOT_ENDS_WITH: + return !\is_string($value) || !\is_string($queryValues[0]) || !\str_ends_with($value, $queryValues[0]); + + case Query::TYPE_CONTAINS: + $haystack = $this->decodeArrayValue($value); + if ($haystack === null && \is_string($value)) { + foreach ($queryValues as $needle) { + if (\is_string($needle) && \str_contains($value, $needle)) { + return true; + } + } + return false; + } + if (!\is_array($haystack)) { + return false; + } + foreach ($queryValues as $needle) { + foreach ($haystack as $item) { + if ($this->looseEquals($item, $needle)) { + return true; + } + } + } + return false; + + case Query::TYPE_NOT_CONTAINS: + return !$this->matches($row, new Query(Query::TYPE_CONTAINS, $query->getAttribute(), $queryValues)); + + case Query::TYPE_SEARCH: + case Query::TYPE_NOT_SEARCH: + case Query::TYPE_REGEX: + throw new DatabaseException('Search and regex queries are not implemented in the Memory adapter'); + } + + throw new DatabaseException('Query method not implemented in the Memory adapter: ' . $method); + } + + protected function looseEquals(mixed $a, mixed $b): bool + { + if ($a === $b) { + return true; + } + if (\is_numeric($a) && \is_numeric($b)) { + return $a + 0 === $b + 0; + } + return false; + } + + /** + * Return the decoded array if $value looks like a JSON-encoded array + * or is already an array; null otherwise. + * + * @return array|null + */ + protected function decodeArrayValue(mixed $value): ?array + { + if (\is_array($value)) { + return $value; + } + if (\is_string($value) && $value !== '' && ($value[0] === '[' || $value[0] === '{')) { + $decoded = \json_decode($value, true); + return \is_array($decoded) ? $decoded : null; + } + return null; + } + + protected function mapAttribute(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + '$sequence' => '_id', + '$tenant' => '_tenant', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + '$permissions' => '_permissions', + default => $this->filter($attribute), + }; + } + + /** + * @param Document $collection + * @param array> $rows + * @return array> + */ + protected function applyPermissions(Document $collection, array $rows, string $forPermission): array + { + if (!$this->authorization->getStatus()) { + return $rows; + } + + $key = $this->key($collection->getId()); + $roles = $this->authorization->getRoles(); + $roleSet = \array_flip($roles); + + $allowed = []; + foreach ($this->permissions[$key] ?? [] as $perm) { + if ($perm['type'] !== $forPermission) { + continue; + } + if ($this->sharedTables && ($perm['tenant'] ?? null) !== $this->getTenant()) { + continue; + } + if (isset($roleSet[$perm['permission']])) { + $allowed[$perm['document']] = true; + } + } + + return \array_values(\array_filter( + $rows, + fn (array $row) => isset($allowed[$row['_uid'] ?? '']) + )); + } + + /** + * @param array> $rows + * @param array $orderAttributes + * @param array $orderTypes + * @return array> + */ + protected function applyOrdering(array $rows, array $orderAttributes, array $orderTypes, string $cursorDirection): array + { + if (empty($orderAttributes)) { + return $rows; + } + + \usort($rows, function (array $a, array $b) use ($orderAttributes, $orderTypes, $cursorDirection) { + foreach ($orderAttributes as $i => $attribute) { + $direction = $orderTypes[$i] ?? Database::ORDER_ASC; + if ($direction === Database::ORDER_RANDOM) { + return \random_int(-1, 1); + } + + if ($cursorDirection === Database::CURSOR_BEFORE) { + $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + } + + $column = $this->mapAttribute($attribute); + $av = $a[$column] ?? null; + $bv = $b[$column] ?? null; + + if ($av == $bv) { + continue; + } + + $cmp = ($av < $bv) ? -1 : 1; + return $direction === Database::ORDER_ASC ? $cmp : -$cmp; + } + return 0; + }); + + return $rows; + } + + /** + * @param array> $rows + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @return array> + */ + protected function applyCursor(array $rows, array $orderAttributes, array $orderTypes, array $cursor, string $cursorDirection): array + { + if (empty($cursor)) { + return $rows; + } + + if (empty($orderAttributes)) { + $orderAttributes = ['$sequence']; + $orderTypes = [Database::ORDER_ASC]; + } + + return \array_values(\array_filter($rows, function (array $row) use ($orderAttributes, $orderTypes, $cursor, $cursorDirection) { + foreach ($orderAttributes as $i => $attribute) { + $direction = $orderTypes[$i] ?? Database::ORDER_ASC; + if ($cursorDirection === Database::CURSOR_BEFORE) { + $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + } + $column = $this->mapAttribute($attribute); + $current = $row[$column] ?? null; + $ref = $cursor[$attribute] ?? null; + + if ($current == $ref) { + continue; + } + + if ($direction === Database::ORDER_ASC) { + return $current > $ref; + } + return $current < $ref; + } + return false; + })); + } + + /** + * @param string $key + * @param Document $document + * @param string|null $previousId + */ + protected function enforceUniqueIndexes(string $key, Document $document, ?string $previousId): void + { + $indexes = $this->data[$key]['indexes'] ?? []; + foreach ($indexes as $index) { + if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) { + continue; + } + + $attributes = $index['attributes'] ?? []; + if (empty($attributes)) { + continue; + } + + $signature = []; + foreach ($attributes as $attribute) { + $column = $this->mapAttribute($attribute); + $docValue = $document->getAttribute($attribute); + if ($docValue === null) { + $docValue = $document->getAttribute($column); + } + $signature[] = \is_array($docValue) ? \json_encode($docValue) : $docValue; + } + + foreach ($this->data[$key]['documents'] as $uid => $row) { + if ($previousId !== null && $uid === $previousId) { + continue; + } + if ($uid === $document->getId() && $previousId === null) { + continue; + } + if ($this->sharedTables && ($row['_tenant'] ?? null) !== $this->getTenant()) { + continue; + } + + $rowSignature = []; + foreach ($attributes as $attribute) { + $column = $this->mapAttribute($attribute); + $rowSignature[] = $row[$column] ?? null; + } + + if ($rowSignature === $signature) { + throw new DuplicateException('Document with the requested unique attributes already exists'); + } + } + } + } +} diff --git a/tests/e2e/Adapter/MemoryTest.php b/tests/e2e/Adapter/MemoryTest.php new file mode 100644 index 000000000..db80a1eaa --- /dev/null +++ b/tests/e2e/Adapter/MemoryTest.php @@ -0,0 +1,518 @@ +authorization = new Authorization(); + $this->authorization->addRole('any'); + + $database = new Database(new Memory(), new Cache(new MemoryCache())); + $database + ->setAuthorization($this->authorization) + ->setDatabase('utopiaTests') + ->setNamespace('memory_' . \uniqid()); + + $database->create(); + + $this->database = $database; + } + + public function testDatabaseLifecycle(): void + { + $this->assertTrue($this->database->exists()); + $this->database->delete(); + $this->assertFalse($this->database->exists()); + } + + public function testCreateAndDeleteCollection(): void + { + $collection = $this->database->createCollection('posts', [ + new Document([ + '$id' => 'title', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + $this->assertEquals('posts', $collection->getId()); + $this->assertTrue($this->database->exists(null, 'posts')); + + $this->database->deleteCollection('posts'); + $this->assertFalse($this->database->exists(null, 'posts')); + } + + public function testAttributeCrud(): void + { + $this->database->createCollection('books'); + + $this->assertTrue($this->database->createAttribute('books', 'title', Database::VAR_STRING, 128, true)); + $this->assertTrue($this->database->createAttribute('books', 'pages', Database::VAR_INTEGER, 0, true)); + + $updated = $this->database->updateAttribute('books', 'title', Database::VAR_STRING, 256); + $this->assertEquals(256, $updated->getAttribute('size')); + $this->assertTrue($this->database->renameAttribute('books', 'title', 'heading')); + $this->assertTrue($this->database->deleteAttribute('books', 'heading')); + } + + public function testIndexCrud(): void + { + $this->database->createCollection('widgets'); + $this->database->createAttribute('widgets', 'name', Database::VAR_STRING, 128, true); + $this->database->createAttribute('widgets', 'count', Database::VAR_INTEGER, 0, true); + + $this->assertTrue( + $this->database->createIndex('widgets', 'idx_name', Database::INDEX_KEY, ['name']) + ); + $this->assertTrue( + $this->database->createIndex('widgets', 'unique_count', Database::INDEX_UNIQUE, ['count']) + ); + $this->assertTrue($this->database->renameIndex('widgets', 'idx_name', 'idx_name_renamed')); + $this->assertTrue($this->database->deleteIndex('widgets', 'idx_name_renamed')); + } + + public function testFulltextIndexIsNotImplemented(): void + { + $this->database->createCollection('articles'); + $this->database->createAttribute('articles', 'body', Database::VAR_STRING, 1024, true); + + $this->expectException(DatabaseException::class); + $this->database->createIndex('articles', 'body_idx', Database::INDEX_FULLTEXT, ['body']); + } + + public function testDocumentCrud(): void + { + $this->database->createCollection('notes', [ + new Document([ + '$id' => 'title', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'body', + 'type' => Database::VAR_STRING, + 'size' => 4096, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], [], [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + $created = $this->database->createDocument('notes', new Document([ + '$id' => 'note1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'title' => 'Hello', + 'body' => 'World', + ])); + + $this->assertEquals('note1', $created->getId()); + $this->assertNotEmpty($created->getSequence()); + + $fetched = $this->database->getDocument('notes', 'note1'); + $this->assertEquals('Hello', $fetched->getAttribute('title')); + + $fetched->setAttribute('title', 'Hello Updated'); + $updated = $this->database->updateDocument('notes', 'note1', $fetched); + $this->assertEquals('Hello Updated', $updated->getAttribute('title')); + + $this->assertTrue($this->database->deleteDocument('notes', 'note1')); + $this->assertTrue($this->database->getDocument('notes', 'note1')->isEmpty()); + } + + public function testDuplicateIdThrows(): void + { + $this->database->createCollection('labels'); + $this->database->createAttribute('labels', 'name', Database::VAR_STRING, 64, true); + + $this->database->createDocument('labels', new Document([ + '$id' => 'a', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'x', + ])); + + $this->expectException(DuplicateException::class); + $this->database->createDocument('labels', new Document([ + '$id' => 'a', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'y', + ])); + } + + public function testUniqueIndexEnforcement(): void + { + $this->database->createCollection('users', [ + new Document([ + '$id' => 'email', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], [ + new Document([ + '$id' => 'unique_email', + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['email'], + ]), + ]); + + $this->database->createDocument('users', new Document([ + '$id' => 'u1', + '$permissions' => [Permission::read(Role::any())], + 'email' => 'a@example.com', + ])); + + $this->expectException(DuplicateException::class); + $this->database->createDocument('users', new Document([ + '$id' => 'u2', + '$permissions' => [Permission::read(Role::any())], + 'email' => 'a@example.com', + ])); + } + + public function testFindWithBasicQueries(): void + { + $this->seedNumbers(); + + $results = $this->database->find('numbers', [Query::greaterThan('value', 5)]); + $values = \array_map(fn (Document $d) => $d->getAttribute('value'), $results); + \sort($values); + $this->assertEquals([6, 7, 8, 9, 10], $values); + + $results = $this->database->find('numbers', [Query::between('value', 3, 5)]); + $this->assertCount(3, $results); + + $results = $this->database->find('numbers', [Query::equal('category', ['even'])]); + $this->assertCount(5, $results); + + $results = $this->database->find('numbers', [Query::notEqual('category', 'even')]); + $this->assertCount(5, $results); + + $results = $this->database->find('numbers', [Query::isNull('tag')]); + $this->assertCount(10, $results); + } + + public function testFindStartsWithEndsWith(): void + { + $this->database->createCollection('names', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 64, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + foreach (['alpha', 'alphabet', 'beta', 'gamma', 'delta'] as $n) { + $this->database->createDocument('names', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'name' => $n, + ])); + } + + $starts = $this->database->find('names', [Query::startsWith('name', 'alpha')]); + $this->assertCount(2, $starts); + + $ends = $this->database->find('names', [Query::endsWith('name', 'a')]); + $this->assertCount(4, $ends); + } + + public function testOrderAndLimitAndOffset(): void + { + $this->seedNumbers(); + + $results = $this->database->find('numbers', [ + Query::orderAsc('value'), + Query::limit(3), + ]); + $this->assertEquals([1, 2, 3], \array_map(fn ($d) => $d->getAttribute('value'), $results)); + + $results = $this->database->find('numbers', [ + Query::orderDesc('value'), + Query::limit(3), + ]); + $this->assertEquals([10, 9, 8], \array_map(fn ($d) => $d->getAttribute('value'), $results)); + + $results = $this->database->find('numbers', [ + Query::orderAsc('value'), + Query::limit(3), + Query::offset(3), + ]); + $this->assertEquals([4, 5, 6], \array_map(fn ($d) => $d->getAttribute('value'), $results)); + } + + public function testCountAndSum(): void + { + $this->seedNumbers(); + + $this->assertEquals(10, $this->database->count('numbers')); + $this->assertEquals(55, $this->database->sum('numbers', 'value')); + $this->assertEquals(30, $this->database->sum('numbers', 'value', [Query::equal('category', ['even'])])); + } + + public function testBatchCreateAndDelete(): void + { + $this->database->createCollection('tags', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 64, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + $docs = []; + for ($i = 0; $i < 5; $i++) { + $docs[] = new Document([ + '$id' => "tag{$i}", + '$permissions' => [Permission::read(Role::any()), Permission::delete(Role::any())], + 'name' => "tag-{$i}", + ]); + } + $created = $this->database->createDocuments('tags', $docs); + $this->assertEquals(5, $created); + $this->assertEquals(5, $this->database->count('tags')); + + $deleted = $this->database->deleteDocuments('tags'); + $this->assertEquals(5, $deleted); + $this->assertEquals(0, $this->database->count('tags')); + } + + public function testIncreaseDocumentAttribute(): void + { + $this->database->createCollection('counters', [ + new Document([ + '$id' => 'count', + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + $this->database->createDocument('counters', new Document([ + '$id' => 'c1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 1, + ])); + + $this->database->increaseDocumentAttribute('counters', 'c1', 'count', 4); + $fetched = $this->database->getDocument('counters', 'c1'); + $this->assertEquals(5, $fetched->getAttribute('count')); + + $this->database->decreaseDocumentAttribute('counters', 'c1', 'count', 2); + $fetched = $this->database->getDocument('counters', 'c1'); + $this->assertEquals(3, $fetched->getAttribute('count')); + } + + public function testPermissionsFilterResults(): void + { + $this->database->createCollection('items', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 64, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + // Public readable + $this->database->createDocument('items', new Document([ + '$id' => 'public', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'public', + ])); + + // Only user:alice readable + $this->database->createDocument('items', new Document([ + '$id' => 'private', + '$permissions' => [Permission::read(Role::user('alice'))], + 'name' => 'private', + ])); + + // With default 'any' role we should see only the public doc + $results = $this->database->find('items'); + $this->assertCount(1, $results); + $this->assertEquals('public', $results[0]->getId()); + + // Add alice role and both docs show up + $this->authorization->addRole('user:alice'); + $results = $this->database->find('items'); + $this->assertCount(2, $results); + + // Skipping auth lists everything + $this->authorization->removeRole('user:alice'); + $results = $this->authorization->skip(fn () => $this->database->find('items')); + $this->assertCount(2, $results); + } + + public function testTransactionCommit(): void + { + $this->database->createCollection('tx', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 64, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + $this->database->withTransaction(function () { + $this->database->createDocument('tx', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'first', + ])); + }); + + $this->assertEquals(1, $this->database->count('tx')); + } + + public function testTransactionRollback(): void + { + $this->database->createCollection('txr', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 64, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + try { + $this->database->withTransaction(function () { + $this->database->createDocument('txr', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'first', + ])); + + throw new \RuntimeException('force rollback'); + }); + } catch (\RuntimeException) { + // expected + } + + $this->assertEquals(0, $this->database->count('txr')); + } + + public function testRelationshipsAreNotImplemented(): void + { + $this->database->createCollection('posts'); + $this->database->createCollection('authors'); + + $this->expectException(DatabaseException::class); + $this->database->getAdapter()->createRelationship('posts', 'authors', Database::RELATION_ONE_TO_ONE); + } + + public function testUpsertIsNotImplemented(): void + { + $collection = new Document(['$id' => 'any']); + $this->expectException(DatabaseException::class); + $this->database->getAdapter()->upsertDocuments($collection, '', []); + } + + protected function seedNumbers(): void + { + $this->database->createCollection('numbers', [ + new Document([ + '$id' => 'value', + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'category', + 'type' => Database::VAR_STRING, + 'size' => 32, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'tag', + 'type' => Database::VAR_STRING, + 'size' => 32, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]); + + for ($i = 1; $i <= 10; $i++) { + $this->database->createDocument('numbers', new Document([ + '$id' => 'n' . $i, + '$permissions' => [Permission::read(Role::any())], + 'value' => $i, + 'category' => ($i % 2 === 0) ? 'even' : 'odd', + 'tag' => null, + ])); + } + } +}