diff --git a/.gitignore b/.gitignore index 46daf3d31..48b8bf907 100755 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1 @@ -/vendor/ -/.idea/ -.DS_Store -mock.json -data-tests.php -loader.php -.phpunit.result.cache -.vscode -.vscode/* -database.sql -Makefile -.envrc -.vscode -tmp +vendor/ diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index c8f45b7e8..ce399f5de 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -786,55 +786,60 @@ public function updateRelationship( ?string $newKey = null, ?string $newTwoWayKey = null ): bool { - $collection = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollection = $this->getNamespace() . '_' . $this->filter($relatedCollection); + $collectionName = $this->getNamespace() . '_' . $this->filter($collection); + $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection); + + $escapedKey = $this->escapeMongoFieldName($key); + $escapedNewKey = !\is_null($newKey) ? $this->escapeMongoFieldName($newKey) : null; + $escapedTwoWayKey = $this->escapeMongoFieldName($twoWayKey); + $escapedNewTwoWayKey = !\is_null($newTwoWayKey) ? $this->escapeMongoFieldName($newTwoWayKey) : null; $renameKey = [ '$rename' => [ - $key => $newKey, + $escapedKey => $escapedNewKey, ] ]; $renameTwoWayKey = [ '$rename' => [ - $twoWayKey => $newTwoWayKey, + $escapedTwoWayKey => $escapedNewTwoWayKey, ] ]; switch ($type) { case Database::RELATION_ONE_TO_ONE: - if (!\is_null($newKey)) { - $this->getClient()->update($collection, updates: $renameKey, multi: true); + if (!\is_null($newKey) && $key !== $newKey) { + $this->getClient()->update($collectionName, updates: $renameKey, multi: true); } - if ($twoWay && !\is_null($newTwoWayKey)) { - $this->getClient()->update($relatedCollection, updates: $renameTwoWayKey, multi: true); + if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); } break; case Database::RELATION_ONE_TO_MANY: - if ($twoWay && !\is_null($newTwoWayKey)) { - $this->getClient()->update($relatedCollection, updates: $renameTwoWayKey, multi: true); + if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); } break; case Database::RELATION_MANY_TO_ONE: - if (!\is_null($newKey)) { - $this->getClient()->update($collection, updates: $renameKey, multi: true); + if (!\is_null($newKey) && $key !== $newKey) { + $this->getClient()->update($collectionName, updates: $renameKey, multi: true); } break; case Database::RELATION_MANY_TO_MANY: $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); + $collectionDoc = $this->getDocument($metadataCollection, $collection); + $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection); - if ($collection->isEmpty() || $relatedCollection->isEmpty()) { + if ($collectionDoc->isEmpty() || $relatedCollectionDoc->isEmpty()) { throw new DatabaseException('Collection or related collection not found'); } - $junction = $this->getNamespace() . '_' . $this->filter('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + $junction = $this->getNamespace() . '_' . $this->filter('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence()); - if (!\is_null($newKey)) { + if (!\is_null($newKey) && $key !== $newKey) { $this->getClient()->update($junction, updates: $renameKey, multi: true); } - if ($twoWay && !\is_null($newTwoWayKey)) { + if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { $this->getClient()->update($junction, updates: $renameTwoWayKey, multi: true); } break; @@ -866,32 +871,52 @@ public function deleteRelationship( string $twoWayKey, string $side ): bool { - $junction = $this->getNamespace() . '_' . $this->filter('_' . $collection . '_' . $relatedCollection); - $collection = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollection = $this->getNamespace() . '_' . $this->filter($relatedCollection); + $collectionName = $this->getNamespace() . '_' . $this->filter($collection); + $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection); + $escapedKey = $this->escapeMongoFieldName($key); + $escapedTwoWayKey = $this->escapeMongoFieldName($twoWayKey); switch ($type) { case Database::RELATION_ONE_TO_ONE: - $this->getClient()->update($collection, [], ['$unset' => [$key => '']], multi: true); - if ($twoWay) { - $this->getClient()->update($relatedCollection, [], ['$unset' => [$twoWayKey => '']], multi: true); + if ($side === Database::RELATION_SIDE_PARENT) { + $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); + if ($twoWay) { + $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); + } + } elseif ($side === Database::RELATION_SIDE_CHILD) { + $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); + if ($twoWay) { + $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); + } } break; case Database::RELATION_ONE_TO_MANY: if ($side === Database::RELATION_SIDE_PARENT) { - $this->getClient()->update($collection, [], ['$unset' => [$key => '']], multi: true); + $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); } else { - $this->getClient()->update($relatedCollection, [], ['$unset' => [$twoWayKey => '']], multi: true); + $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); } break; case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - $this->getClient()->update($collection, [], ['$unset' => [$key => '']], multi: true); + if ($side === Database::RELATION_SIDE_PARENT) { + $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); } else { - $this->getClient()->update($relatedCollection, [], ['$unset' => [$twoWayKey => '']], multi: true); + $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); } break; case Database::RELATION_MANY_TO_MANY: + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collectionDoc = $this->getDocument($metadataCollection, $collection); + $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection); + + if ($collectionDoc->isEmpty() || $relatedCollectionDoc->isEmpty()) { + throw new DatabaseException('Collection or related collection not found'); + } + + $junction = $side === Database::RELATION_SIDE_PARENT + ? $this->getNamespace() . '_' . $this->filter('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence()) + : $this->getNamespace() . '_' . $this->filter('_' . $relatedCollectionDoc->getSequence() . '_' . $collectionDoc->getSequence()); + $this->getClient()->dropCollection($junction); break; default: @@ -1073,7 +1098,7 @@ public function renameIndex(string $collection, string $old, string $new): bool $index = null; foreach ($indexes as $node) { - if ($node['key'] === $old) { + if (($node['$id'] ?? $node['key'] ?? '') === $old) { $index = $node; break; } @@ -1097,13 +1122,16 @@ public function renameIndex(string $collection, string $old, string $new): bool } try { + if (!$index) { + throw new DatabaseException('Index not found: ' . $old); + } $deletedindex = $this->deleteIndex($collection, $old); $createdindex = $this->createIndex($collection, $new, $index['type'], $index['attributes'], $index['lengths'] ?? [], $index['orders'] ?? [], $indexAttributeTypes, [], $index['ttl'] ?? 0); } catch (\Exception $e) { throw $this->processException($e); } - if ($index && $deletedindex && $createdindex) { + if ($deletedindex && $createdindex) { return true; } @@ -1152,8 +1180,9 @@ public function getDocument(Document $collection, string $id, array $queries = [ $options = $this->getTransactionOptions(); $selections = $this->getAttributeSelections($queries); + $hasProjection = !empty($selections) && !\in_array('*', $selections); - if (!empty($selections) && !\in_array('*', $selections)) { + if ($hasProjection) { $options['projection'] = $this->getAttributeProjection($selections); } @@ -1172,6 +1201,11 @@ public function getDocument(Document $collection, string $id, array $queries = [ $document = new Document($result); $document = $this->castingAfter($collection, $document); + // Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields) + if (!$hasProjection) { + $this->ensureRelationshipDefaults($collection, $document); + } + return $document; } @@ -1972,6 +2006,10 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $queries = array_map(fn ($query) => clone $query, $queries); + // Escape query attribute names that contain dots and match collection attributes + // (to distinguish from nested object paths like profile.level1.value) + $this->escapeQueryAttributes($collection, $queries); + $filters = $this->buildFilters($queries); if ($this->sharedTables) { @@ -1998,7 +2036,8 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 } $selections = $this->getAttributeSelections($queries); - if (!empty($selections) && !\in_array('*', $selections)) { + $hasProjection = !empty($selections) && !\in_array('*', $selections); + if ($hasProjection) { $options['projection'] = $this->getAttributeProjection($selections); } @@ -2127,6 +2166,13 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $found = array_reverse($found); } + // Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields) + if (!$hasProjection) { + foreach ($found as $document) { + $this->ensureRelationshipDefaults($collection, $document); + } + } + return $found; } @@ -2210,6 +2256,9 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $queries = array_map(fn ($query) => clone $query, $queries); + // Escape query attribute names that contain dots and match collection attributes + $this->escapeQueryAttributes($collection, $queries); + $filters = []; $options = []; @@ -2360,6 +2409,91 @@ protected function getClient(): Client return $this->client; } + /** + * Escape a field name for MongoDB storage. + * MongoDB field names cannot start with $ or contain dots. + * + * @param string $name + * @return string + */ + protected function escapeMongoFieldName(string $name): string + { + if (\str_starts_with($name, '$')) { + $name = '_' . \substr($name, 1); + } + if (\str_contains($name, '.')) { + $name = \str_replace('.', '__dot__', $name); + } + return $name; + } + + /** + * Escape query attribute names that contain dots and match known collection attributes. + * This distinguishes field names with dots (like 'collectionSecurity.Parent') from + * nested object paths (like 'profile.level1.value'). + * + * @param Document $collection + * @param array $queries + */ + protected function escapeQueryAttributes(Document $collection, array $queries): void + { + $attributes = $collection->getAttribute('attributes', []); + $dotAttributes = []; + foreach ($attributes as $attribute) { + $key = $attribute['$id'] ?? ''; + if (\str_contains($key, '.') || \str_starts_with($key, '$')) { + $dotAttributes[$key] = $this->escapeMongoFieldName($key); + } + } + + if (empty($dotAttributes)) { + return; + } + + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (isset($dotAttributes[$attr])) { + $query->setAttribute($dotAttributes[$attr]); + } + } + } + + /** + * Ensure relationship attributes have default null values in MongoDB documents. + * MongoDB doesn't store null fields, so we need to add them for schema compatibility. + * + * @param Document $collection + * @param Document $document + */ + protected function ensureRelationshipDefaults(Document $collection, Document $document): void + { + $attributes = $collection->getAttribute('attributes', []); + foreach ($attributes as $attribute) { + $key = $attribute['$id'] ?? ''; + $type = $attribute['type'] ?? ''; + if ($type === Database::VAR_RELATIONSHIP && !$document->offsetExists($key)) { + $options = $attribute['options'] ?? []; + $twoWay = $options['twoWay'] ?? false; + $side = $options['side'] ?? ''; + $relationType = $options['relationType'] ?? ''; + + // Determine if this relationship stores data on this collection's documents + // Only set null defaults for relationships that would have a column in SQL + $storesData = match ($relationType) { + Database::RELATION_ONE_TO_ONE => $side === Database::RELATION_SIDE_PARENT || $twoWay, + Database::RELATION_ONE_TO_MANY => $side === Database::RELATION_SIDE_CHILD, + Database::RELATION_MANY_TO_ONE => $side === Database::RELATION_SIDE_PARENT, + Database::RELATION_MANY_TO_MANY => false, + default => false, + }; + + if ($storesData) { + $document->setAttribute($key, null); + } + } + } + } + /** * Keys cannot begin with $ in MongoDB * Convert $ prefix to _ on $id, $permissions, and $collection @@ -2385,13 +2519,26 @@ protected function replaceChars(string $from, string $to, array $array): array $array[$k] = $this->replaceChars($from, $to, $v); } + $newKey = $k; + // Handle key replacement for filtered attributes $clean_key = str_replace($from, "", $k); if (in_array($clean_key, $filter)) { $newKey = str_replace($from, $to, $k); - if ($newKey !== $k) { - $keysToRename[$k] = $newKey; - } + } elseif (\is_string($k) && \str_starts_with($k, $from) && !in_array($k, ['$id', '$sequence', '$tenant', '_uid', '_id', '_tenant'])) { + // Handle any other key starting with the 'from' char (e.g. user-defined $-prefixed keys) + $newKey = $to . \substr($k, \strlen($from)); + } + + // Handle dot escaping in MongoDB field names + if ($from === '$' && \is_string($k) && \str_contains($newKey, '.')) { + $newKey = \str_replace('.', '__dot__', $newKey); + } elseif ($from === '_' && \is_string($k) && \str_contains($k, '__dot__')) { + $newKey = \str_replace('__dot__', '.', $newKey); + } + + if ($newKey !== $k) { + $keysToRename[$k] = $newKey; } } @@ -2486,6 +2633,9 @@ protected function buildFilter(Query $query): array $query->setAttribute('_createdAt'); } elseif ($query->getAttribute() === '$updatedAt') { $query->setAttribute('_updatedAt'); + } elseif (\str_starts_with($query->getAttribute(), '$')) { + // Escape $ prefix and dots in user-defined $-prefixed attribute names for MongoDB + $query->setAttribute($this->escapeMongoFieldName($query->getAttribute())); } $attribute = $query->getAttribute(); @@ -2700,13 +2850,11 @@ protected function getQueryValue(string $method, mixed $value): mixed switch ($method) { case Query::TYPE_STARTS_WITH: $value = preg_quote($value, '/'); - $value = str_replace(['\\', '$'], ['\\\\', '\\$'], $value); return $value . '.*'; case Query::TYPE_NOT_STARTS_WITH: return $value; case Query::TYPE_ENDS_WITH: $value = preg_quote($value, '/'); - $value = str_replace(['\\', '$'], ['\\\\', '\\$'], $value); return '.*' . $value; case Query::TYPE_NOT_ENDS_WITH: return $value; @@ -2956,7 +3104,7 @@ public function getSupportForTimeouts(): bool public function getSupportForRelationships(): bool { - return false; + return true; } public function getSupportForUpdateLock(): bool diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index be4b74a6f..1d23e3f2c 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -1104,6 +1104,12 @@ public function testStructureValidationAfterRelationsAttribute(): void return; } + if (!$database->getAdapter()->getSupportForAttributes()) { + // Schemaless mode allows unknown attributes, so structure validation won't reject them + $this->expectNotToPerformAssertions(); + return; + } + $database->createCollection("structure_1", [], [], [Permission::create(Role::any())]); $database->createCollection("structure_2", [], [], [Permission::create(Role::any())]);