diff --git a/src/Database/Database.php b/src/Database/Database.php index ea22e7392..d081ddf68 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3541,7 +3541,7 @@ public function getDocument(string $collection, string $id, array $queries = [], // Skip relationship population if we're in batch mode (relationships will be populated later) if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack, $nestedSelections)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $nestedSelections)); $document = $documents[0]; } @@ -3566,24 +3566,25 @@ public function getDocument(string $collection, string $id, array $queries = [], } /** - * Populate relationships for an array of documents (TRUE breadth-first approach) - * Completely separates fetching from relationship population for massive performance gains + * Populate relationships for an array of documents with breadth-first traversal * * @param array $documents * @param Document $collection * @param int $relationshipFetchDepth - * @param array $relationshipFetchStack * @param array> $selects * @return array * @throws DatabaseException */ - private function populateDocumentsRelationships(array $documents, Document $collection, int $relationshipFetchDepth = 0, array $relationshipFetchStack = [], array $selects = []): array - { - // Enable batch mode to prevent nested relationship population during fetches + private function populateDocumentsRelationships( + array $documents, + Document $collection, + int $relationshipFetchDepth = 0, + array $selects = [] + ): array { + // Prevent nested relationship population during fetches $this->inBatchRelationshipPopulation = true; try { - // Queue of work items: [documents, collectionDoc, depth, selects, skipKey, hasExplicitSelects] $queue = [ [ 'documents' => $documents, @@ -3597,11 +3598,9 @@ private function populateDocumentsRelationships(array $documents, Document $coll $currentDepth = $relationshipFetchDepth; - // Process queue level by level (breadth-first) while (!empty($queue) && $currentDepth < self::RELATION_MAX_DEPTH) { $nextQueue = []; - // Process ALL items at the current depth foreach ($queue as $item) { $docs = $item['documents']; $coll = $item['collection']; @@ -3613,7 +3612,6 @@ private function populateDocumentsRelationships(array $documents, Document $coll continue; } - // Get all relationship attributes for this collection $attributes = $coll->getAttribute('attributes', []); $relationships = []; @@ -3627,20 +3625,16 @@ private function populateDocumentsRelationships(array $documents, Document $coll // Include relationship if: // 1. No explicit selects (fetch all) OR // 2. Relationship is explicitly selected - if (!$parentHasExplicitSelects || array_key_exists($attribute['key'], $sels)) { + if (!$parentHasExplicitSelects || \array_key_exists($attribute['key'], $sels)) { $relationships[] = $attribute; } } } - // Process each relationship type foreach ($relationships as $relationship) { $key = $relationship['key']; - $relationType = $relationship['options']['relationType']; $queries = $sels[$key] ?? []; $relationship->setAttribute('collection', $coll->getId()); - - // Check if we're at max depth BEFORE populating $isAtMaxDepth = ($currentDepth + 1) >= self::RELATION_MAX_DEPTH; // If we're at max depth, remove this relationship from source documents and skip @@ -3651,7 +3645,6 @@ private function populateDocumentsRelationships(array $documents, Document $coll continue; } - // Fetch and populate this relationship $relatedDocs = $this->populateSingleRelationshipBatch( $docs, $relationship, @@ -3662,10 +3655,12 @@ private function populateDocumentsRelationships(array $documents, Document $coll $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; - // Queue if: (1) no explicit selects (fetch all recursively), OR - // (2) explicit nested selects for this relationship (isset($sels[$key])) + // Queue if: + // 1. No explicit selects (fetch all recursively), OR + // 2. Explicit nested selects for this relationship $hasNestedSelectsForThisRel = isset($sels[$key]); - $shouldQueue = !empty($relatedDocs) && ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); + $shouldQueue = !empty($relatedDocs) && + ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); if ($shouldQueue) { $relatedCollectionId = $relationship['options']['relatedCollection']; @@ -3673,12 +3668,11 @@ private function populateDocumentsRelationships(array $documents, Document $coll if (!$relatedCollection->isEmpty()) { // Get nested selections for this relationship - // $sels[$key] is an array of Query objects $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; // Extract nested selections for the related collection $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); - $relatedCollectionRelationships = array_filter( + $relatedCollectionRelationships = \array_filter( $relatedCollectionRelationships, fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP ); @@ -3701,7 +3695,7 @@ private function populateDocumentsRelationships(array $documents, Document $coll } // Remove back-references for two-way relationships - // Back-references are ALWAYS removed to prevent circular references + // Back-references are always removed to prevent circular references if ($twoWay && !empty($relatedDocs)) { foreach ($relatedDocs as $relatedDoc) { $relatedDoc->removeAttribute($twoWayKey); @@ -3710,12 +3704,10 @@ private function populateDocumentsRelationships(array $documents, Document $coll } } - // Move to next depth $queue = $nextQueue; $currentDepth++; } } finally { - // Always disable batch mode when done $this->inBatchRelationshipPopulation = false; } @@ -3732,23 +3724,18 @@ private function populateDocumentsRelationships(array $documents, Document $coll * @return array * @throws DatabaseException */ - private function populateSingleRelationshipBatch(array $documents, Document $relationship, array $queries): array - { - $key = $relationship['key']; - $relationType = $relationship['options']['relationType']; - - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); - case Database::RELATION_ONE_TO_MANY: - return $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries); - case Database::RELATION_MANY_TO_ONE: - return $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries); - case Database::RELATION_MANY_TO_MANY: - return $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries); - default: - return []; - } + private function populateSingleRelationshipBatch( + array $documents, + Document $relationship, + array $queries + ): array { + return match ($relationship['options']['relationType']) { + Database::RELATION_ONE_TO_ONE => $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_ONE_TO_MANY => $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_MANY_TO_ONE => $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_MANY_TO_MANY => $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries), + default => [], + }; } /** @@ -3766,14 +3753,13 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ $key = $relationship['key']; $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - // Collect all related document IDs $relatedIds = []; $documentsByRelatedId = []; foreach ($documents as $document) { $value = $document->getAttribute($key); if (!\is_null($value)) { - // Skip if value is already a Document object (already populated) + // Skip if value is already populated if ($value instanceof Document) { continue; } @@ -3791,18 +3777,17 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ return []; } - // Fetch all related documents, chunking to stay within query limits - $uniqueRelatedIds = array_unique($relatedIds); + $uniqueRelatedIds = \array_unique($relatedIds); $relatedDocuments = []; // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), Query::limit(PHP_INT_MAX), ...$queries ]); - array_push($relatedDocuments, ...$chunkDocs); + \array_push($relatedDocuments, ...$chunkDocs); } // Index related documents by ID for quick lookup @@ -3839,8 +3824,11 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ * @return array * @throws DatabaseException */ - private function populateOneToManyRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { + private function populateOneToManyRelationshipsBatch( + array $documents, + Document $relationship, + array $queries, + ): array { $key = $relationship['key']; $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; @@ -3859,15 +3847,13 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document } // Parent side - fetch multiple related documents - // Collect all parent document IDs $parentIds = []; foreach ($documents as $document) { $parentId = $document->getId(); $parentIds[] = $parentId; } - // Remove duplicates - $parentIds = array_unique($parentIds); + $parentIds = \array_unique($parentIds); if (empty($parentIds)) { return []; @@ -3885,18 +3871,15 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document } } - // Fetch all related documents for all parents, chunking to stay within query limits - // Don't apply selects yet - we need the back-reference for grouping $relatedDocuments = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($parentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($parentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, $chunk), Query::limit(PHP_INT_MAX), ...$otherQueries ]); - array_push($relatedDocuments, ...$chunkDocs); + \array_push($relatedDocuments, ...$chunkDocs); } // Group related documents by parent ID @@ -3905,17 +3888,19 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document $parentId = $related->getAttribute($twoWayKey); if (!\is_null($parentId)) { // Handle case where parentId might be a Document object instead of string - $parentKey = $parentId instanceof Document ? $parentId->getId() : $parentId; + $parentKey = $parentId instanceof Document + ? $parentId->getId() + : $parentId; + if (!isset($relatedByParentId[$parentKey])) { $relatedByParentId[$parentKey] = []; } - // Note: We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in the breadth-first traversal + // We don't remove the back-reference here because documents may be reused across fetches + // Cycles are prevented by depth limiting in breadth-first traversal $relatedByParentId[$parentKey][] = $related; } } - // Apply select filters to related documents if specified $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); // Assign related documents to their parent documents @@ -3937,8 +3922,11 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document * @return array * @throws DatabaseException */ - private function populateManyToOneRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { + private function populateManyToOneRelationshipsBatch( + array $documents, + Document $relationship, + array $queries, + ): array { $key = $relationship['key']; $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; @@ -3958,21 +3946,18 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document return []; } - // Collect all child document IDs $childIds = []; foreach ($documents as $document) { $childId = $document->getId(); $childIds[] = $childId; } - // Remove duplicates $childIds = array_unique($childIds); if (empty($childIds)) { return []; } - // Separate select queries from other queries $selectQueries = []; $otherQueries = []; foreach ($queries as $query) { @@ -3983,18 +3968,15 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document } } - // Fetch all related documents for all children, chunking to stay within query limits - // Don't apply selects yet - we need the back-reference for grouping $relatedDocuments = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($childIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($childIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, $chunk), Query::limit(PHP_INT_MAX), ...$otherQueries ]); - array_push($relatedDocuments, ...$chunkDocs); + \array_push($relatedDocuments, ...$chunkDocs); } // Group related documents by child ID @@ -4003,20 +3985,21 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document $childId = $related->getAttribute($twoWayKey); if (!\is_null($childId)) { // Handle case where childId might be a Document object instead of string - $childKey = $childId instanceof Document ? $childId->getId() : $childId; + $childKey = $childId instanceof Document + ? $childId->getId() + : $childId; + if (!isset($relatedByChildId[$childKey])) { $relatedByChildId[$childKey] = []; } - // Note: We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in the breadth-first traversal + // We don't remove the back-reference here because documents may be reused across fetches + // Cycles are prevented by depth limiting in breadth-first traversal $relatedByChildId[$childKey][] = $related; } } - // Apply select filters to related documents if specified $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - // Assign related documents to their child documents foreach ($documents as $document) { $childId = $document->getId(); $document->setAttribute($key, $relatedByChildId[$childId] ?? []); @@ -4025,51 +4008,6 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document return $relatedDocuments; } - /** - * Apply select filters to documents after fetching - * - * Filters document attributes based on select queries while preserving internal attributes. - * This is used in batch relationship population to apply selects after grouping. - * - * @param array $documents Documents to filter - * @param array $selectQueries Select query objects - * @return void - */ - private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void - { - if (empty($selectQueries)) { - return; - } - - // Collect all fields to keep from select queries - $fieldsToKeep = []; - foreach ($selectQueries as $selectQuery) { - foreach ($selectQuery->getValues() as $value) { - $fieldsToKeep[] = $value; - } - } - - // Always preserve internal attributes - $internalKeys = array_map(fn ($attr) => $attr['$id'], self::getInternalAttributes()); - $fieldsToKeep = array_merge($fieldsToKeep, $internalKeys); - - // Early return if wildcard selector present - if (in_array('*', $fieldsToKeep)) { - return; - } - - // Filter each document to only include selected fields - foreach ($documents as $doc) { - $allKeys = array_keys($doc->getArrayCopy()); - foreach ($allKeys as $attrKey) { - // Keep if: explicitly selected OR is internal attribute - if (!in_array($attrKey, $fieldsToKeep) && !str_starts_with($attrKey, '$')) { - $doc->removeAttribute($attrKey); - } - } - } - } - /** * Populate many-to-many relationships in batch * @@ -4079,8 +4017,11 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu * @return array * @throws DatabaseException */ - private function populateManyToManyRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { + private function populateManyToManyRelationshipsBatch( + array $documents, + Document $relationship, + array $queries + ): array { $key = $relationship['key']; $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; @@ -4092,14 +4033,12 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document return []; } - // Collect all document IDs $documentIds = []; foreach ($documents as $document) { $documentId = $document->getId(); $documentIds[] = $documentId; } - // Remove duplicates $documentIds = array_unique($documentIds); if (empty($documentIds)) { @@ -4108,19 +4047,16 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - // Fetch all junction records for all documents, chunking to stay within query limits $junctions = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($documentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($documentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkJunctions = $this->skipRelationships(fn () => $this->find($junction, [ Query::equal($twoWayKey, $chunk), Query::limit(PHP_INT_MAX) ])); - array_push($junctions, ...$chunkJunctions); + \array_push($junctions, ...$chunkJunctions); } - // Collect all related IDs from junctions $relatedIds = []; $junctionsByDocumentId = []; @@ -4137,26 +4073,23 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document } } - // Fetch all related documents, chunking to stay within query limits $related = []; $allRelatedDocs = []; if (!empty($relatedIds)) { $uniqueRelatedIds = array_unique($relatedIds); $foundRelated = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), Query::limit(PHP_INT_MAX), ...$queries ]); - array_push($foundRelated, ...$chunkDocs); + \array_push($foundRelated, ...$chunkDocs); } $allRelatedDocs = $foundRelated; - // Index related documents by ID for quick lookup $relatedById = []; foreach ($foundRelated as $doc) { $relatedById[$doc->getId()] = $doc; @@ -4174,7 +4107,6 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document } } - // Assign related documents to their parent documents foreach ($documents as $document) { $documentId = $document->getId(); $document->setAttribute($key, $related[$documentId] ?? []); @@ -4183,6 +4115,52 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document return $allRelatedDocs; } + /** + * Apply select filters to documents after fetching + * + * Filters document attributes based on select queries while preserving internal attributes. + * This is used in batch relationship population to apply selects after grouping. + * + * @param array $documents Documents to filter + * @param array $selectQueries Select query objects + * @return void + */ + private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void + { + if (empty($selectQueries) || empty($documents)) { + return; + } + + // Collect all fields to keep from select queries + $fieldsToKeep = []; + foreach ($selectQueries as $selectQuery) { + foreach ($selectQuery->getValues() as $value) { + $fieldsToKeep[$value] = true; + } + } + + // Early return if wildcard selector present + if (isset($fieldsToKeep['*'])) { + return; + } + + // Always preserve internal attributes (use hashmap for O(1) lookup) + $internalKeys = \array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); + foreach ($internalKeys as $key) { + $fieldsToKeep[$key] = true; + } + + foreach ($documents as $doc) { + $allKeys = \array_keys($doc->getArrayCopy()); + foreach ($allKeys as $attrKey) { + // Keep if: explicitly selected OR is internal attribute ($ prefix) + if (!isset($fieldsToKeep[$attrKey]) && !\str_starts_with($attrKey, '$')) { + $doc->removeAttribute($attrKey); + } + } + } + } + /** * Create Document * @@ -4279,7 +4257,7 @@ public function createDocument(string $collection, Document $document): Document if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { // Use the write stack depth for proper MAX_DEPTH enforcement during creation $fetchDepth = count($this->relationshipWriteStack); - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $fetchDepth, $this->relationshipFetchStack)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $fetchDepth)); $document = $documents[0]; } @@ -4380,9 +4358,8 @@ public function createDocuments( $batch = $this->adapter->getSequences($collection->getId(), $batch); - // Use batch relationship population for better performance if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack)); + $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } foreach ($batch as $document) { @@ -4934,7 +4911,7 @@ public function updateDocument(string $collection, string $id, Document $documen } if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); $document = $documents[0]; } @@ -5805,9 +5782,8 @@ public function upsertDocumentsWithIncrease( } } - // Use batch relationship population for better performance if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack)); + $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } foreach ($batch as $index => $doc) { @@ -6803,26 +6779,33 @@ public function find(string $collection, array $queries = [], string $forPermiss $selections = $this->validateSelections($collection, $selects); $nestedSelections = $this->processRelationshipQueries($relationships, $queries); - $getResults = fn () => $this->adapter->find( - $collection, - $queries, - $limit ?? 25, - $offset ?? 0, - $orderAttributes, - $orderTypes, - $cursor, - $cursorDirection, - $forPermission - ); + // Convert relationship filter queries to SQL-level subqueries + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); - $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); + // If conversion returns null, it means no documents can match (relationship filter found no matches) + if ($queriesOrNull === null) { + $results = []; + } else { + $queries = $queriesOrNull; + + $getResults = fn () => $this->adapter->find( + $collection, + $queries, + $limit ?? 25, + $offset ?? 0, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission + ); + + $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); + } - // Skip relationship population if we're in batch mode (relationships will be populated later) - // Use batch relationship population for better performance at all levels if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { if (count($results) > 0) { - // Always use batch processing for all cases (single and multiple documents, nested or top-level) - $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack, $nestedSelections)); + $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections)); } } @@ -7380,7 +7363,7 @@ private function validateSelections(Document $collection, array $queries): array // Allow querying internal attributes $keys = \array_map( fn ($attribute) => $attribute['$id'], - self::getInternalAttributes() + $this->getInternalAttributes() ); foreach ($collection->getAttribute('attributes', []) as $attribute) { @@ -7676,6 +7659,189 @@ private function processRelationshipQueries( return $nestedSelections; } + /** + * Convert relationship filter queries to SQL-safe subqueries. + * Queries like Query::equal('author.name', ['Alice']) are converted to + * Query::equal('author', []) + * + * @param array $relationships + * @param array $queries + * @return array|null Returns null if relationship filters cannot match any documents + */ + private function convertRelationshipFiltersToSubqueries( + array $relationships, + array $queries, + ): ?array { + // Early return if no dot-path queries exist + $hasDotPath = false; + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (\str_contains($attr, '.')) { + $hasDotPath = true; + break; + } + } + + if (!$hasDotPath) { + return $queries; + } + + $relationshipsByKey = []; + foreach ($relationships as $relationship) { + $relationshipsByKey[$relationship->getAttribute('key')] = $relationship; + } + + $additionalQueries = []; + $groupedQueries = []; + $indicesToRemove = []; + + // Group queries by relationship key + foreach (Query::groupByType($queries)['filters'] as $index => $query) { + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + if (!\str_contains($attribute, '.')) { + continue; + } + + // Parse the relationship path + $parts = \explode('.', $attribute); + $relationshipKey = \array_shift($parts); + $nestedField = \implode('.', $parts); + $relationship = $relationshipsByKey[$relationshipKey] ?? null; + + if (!$relationship) { + continue; + } + + // Group queries by relationship key + if (!isset($groupedQueries[$relationshipKey])) { + $groupedQueries[$relationshipKey] = [ + 'relationship' => $relationship, + 'queries' => [], + 'indices' => [] + ]; + } + + $groupedQueries[$relationshipKey]['queries'][] = [ + 'method' => $method, + 'field' => $nestedField, + 'values' => $query->getValues() + ]; + + $groupedQueries[$relationshipKey]['indices'][] = $index; + } + + // Process each relationship group + foreach ($groupedQueries as $relationshipKey => $group) { + $relationship = $group['relationship']; + $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; + $relationType = $relationship->getAttribute('options')['relationType']; + $side = $relationship->getAttribute('options')['side']; + + // Build combined queries for the related collection + $relatedQueries = []; + foreach ($group['queries'] as $queryData) { + $relatedQueries[] = new Query( + $queryData['method'], + $queryData['field'], + $queryData['values'] + ); + } + + try { + // For virtual parent relationships (where parent doesn't store child IDs), + // we need to find which parents have matching children + // - ONE_TO_MANY from parent side: parent doesn't store children + // - MANY_TO_ONE from child side: the "one" side doesn't store "many" IDs + // - MANY_TO_MANY: both sides are virtual, stored in junction table + $needsParentResolution = ( + ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || + ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || + ($relationType === self::RELATION_MANY_TO_MANY) + ); + + if ($needsParentResolution) { + $matchingDocs = $this->silent(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::limit(PHP_INT_MAX), + ]) + )); + } else { + $matchingDocs = $this->silent(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ]) + )); + } + + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); + + if ($needsParentResolution) { + // Need to find which parents have these children + $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; + + $parentIds = []; + foreach ($matchingDocs as $doc) { + $parentId = $doc->getAttribute($twoWayKey); + + // Handle MANY_TO_MANY: twoWayKey returns an array + if (\is_array($parentId)) { + foreach ($parentId as $id) { + if ($id instanceof Document) { + $id = $id->getId(); + } + if ($id && !\in_array($id, $parentIds)) { + $parentIds[] = $id; + } + } + } else { + // Handle ONE_TO_MANY/MANY_TO_ONE: single value + if ($parentId instanceof Document) { + $parentId = $parentId->getId(); + } + if ($parentId && !\in_array($parentId, $parentIds)) { + $parentIds[] = $parentId; + } + } + } + + // Add filter on current collection's $id + if (!empty($parentIds)) { + $additionalQueries[] = Query::equal('$id', $parentIds); + } else { + return null; + } + } else { + // For other types, filter by the relationship field + if (!empty($matchingIds)) { + $additionalQueries[] = Query::equal($relationshipKey, $matchingIds); + } else { + return null; + } + } + + // Remove all original relationship queries for this group + foreach ($group['indices'] as $index) { + $indicesToRemove[] = $index; + } + } catch (\Exception $e) { + return null; + } + } + + // Remove the original queries + foreach ($indicesToRemove as $index) { + unset($queries[$index]); + } + + // Merge additional queries + return \array_merge(\array_values($queries), $additionalQueries); + } + /** * Encode spatial data from array format to WKT (Well-Known Text) format * diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 32d1ddd09..5bc973f22 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -59,11 +59,6 @@ protected function isValidAttribute(string $attribute): bool // For relationships, just validate the top level. // will validate each nested level during the recursive calls. $attribute = \explode('.', $attribute)[0]; - - if (isset($this->schema[$attribute])) { - $this->message = 'Cannot query nested attribute on: ' . $attribute; - return false; - } } // Search for attribute in schema @@ -87,6 +82,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s return false; } + $originalAttribute = $attribute; // isset check if for special symbols "." in the attribute name if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) { // For relationships, just validate the top level. @@ -96,6 +92,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $attributeSchema = $this->schema[$attribute]; + // Skip value validation for nested relationship queries (e.g., author.age) + // The values will be validated when querying the related collection + if ($attributeSchema['type'] === Database::VAR_RELATIONSHIP && $originalAttribute !== $attribute) { + return true; + } + if (count($values) > $this->maxValuesCount) { $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; return false; diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 9cc520b4f..9487f55d1 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -28,6 +28,17 @@ public function __construct(array $attributes = []) */ protected function isValidAttribute(string $attribute): bool { + if (\str_contains($attribute, '.')) { + // Check for special symbol `.` + if (isset($this->schema[$attribute])) { + return true; + } + + // For relationships, just validate the top level. + // Will validate each nested level during the recursive calls. + $attribute = \explode('.', $attribute)[0]; + } + // Search for attribute in schema if (!isset($this->schema[$attribute])) { $this->message = 'Attribute not found in schema: ' . $attribute; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 1319d622e..5aed4e8cb 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3008,4 +3008,753 @@ public function testNestedDocumentCreationWithDepthHandling(): void $database->deleteCollection('product_depth_test'); $database->deleteCollection('store_depth_test'); } + + /** + * Test filtering by relationship fields using dot-path notation + */ + public function testRelationshipFiltering(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create Author -> Posts relationship + $database->createCollection('authors_filter'); + $database->createCollection('posts_filter'); + + $database->createAttribute('authors_filter', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('authors_filter', 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute('posts_filter', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('posts_filter', 'published', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'authors_filter', + relatedCollection: 'posts_filter', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'posts', + twoWayKey: 'author' + ); + + // Create test data + $author1 = $database->createDocument('authors_filter', new Document([ + '$id' => 'author1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Alice', + 'age' => 30, + ])); + + $author2 = $database->createDocument('authors_filter', new Document([ + '$id' => 'author2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob', + 'age' => 25, + ])); + + // Create posts + $database->createDocument('posts_filter', new Document([ + '$id' => 'post1', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 1', + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_filter', new Document([ + '$id' => 'post2', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 2', + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_filter', new Document([ + '$id' => 'post3', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Bob Post', + 'published' => true, + 'author' => 'author2', + ])); + + // Test: Filter posts by author name + $posts = $database->find('posts_filter', [ + Query::equal('author.name', ['Alice']), + ]); + $this->assertCount(2, $posts); + $this->assertEquals('post1', $posts[0]->getId()); + $this->assertEquals('post2', $posts[1]->getId()); + + // Test: Filter posts by author age + $posts = $database->find('posts_filter', [ + Query::lessThan('author.age', 30), + ]); + $this->assertCount(1, $posts); + $this->assertEquals('post3', $posts[0]->getId()); + + // Test: Filter authors by their posts' published status + $authors = $database->find('authors_filter', [ + Query::equal('posts.published', [true]), + ]); + $this->assertCount(2, $authors); // Both authors have published posts + + // Clean up ONE_TO_MANY test + $database->deleteCollection('authors_filter'); + $database->deleteCollection('posts_filter'); + + // ==================== Test ONE_TO_ONE relationships ==================== + $database->createCollection('users_oto'); + $database->createCollection('profiles_oto'); + + $database->createAttribute('users_oto', 'username', Database::VAR_STRING, 255, true); + $database->createAttribute('profiles_oto', 'bio', Database::VAR_STRING, 255, true); + + // ONE_TO_ONE with twoWay=true + $database->createRelationship( + collection: 'users_oto', + relatedCollection: 'profiles_oto', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + $user1 = $database->createDocument('users_oto', new Document([ + '$id' => 'user1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'username' => 'alice', + ])); + + $profile1 = $database->createDocument('profiles_oto', new Document([ + '$id' => 'profile1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'bio' => 'Software Engineer', + 'user' => 'user1', + ])); + + // Test: Filter profiles by user username + $profiles = $database->find('profiles_oto', [ + Query::equal('user.username', ['alice']), + ]); + $this->assertCount(1, $profiles); + $this->assertEquals('profile1', $profiles[0]->getId()); + + // Test: Filter users by profile bio + $users = $database->find('users_oto', [ + Query::equal('profile.bio', ['Software Engineer']), + ]); + $this->assertCount(1, $users); + $this->assertEquals('user1', $users[0]->getId()); + + // Clean up ONE_TO_ONE test + $database->deleteCollection('users_oto'); + $database->deleteCollection('profiles_oto'); + + // ==================== Test MANY_TO_ONE relationships ==================== + $database->createCollection('comments_mto'); + $database->createCollection('users_mto'); + + $database->createAttribute('comments_mto', 'content', Database::VAR_STRING, 255, true); + $database->createAttribute('users_mto', 'name', Database::VAR_STRING, 255, true); + + // MANY_TO_ONE with twoWay=true + $database->createRelationship( + collection: 'comments_mto', + relatedCollection: 'users_mto', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'commenter', + twoWayKey: 'comments' + ); + + $userA = $database->createDocument('users_mto', new Document([ + '$id' => 'userA', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Alice', + ])); + + $comment1 = $database->createDocument('comments_mto', new Document([ + '$id' => 'comment1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'content' => 'Great post!', + 'commenter' => 'userA', + ])); + + $comment2 = $database->createDocument('comments_mto', new Document([ + '$id' => 'comment2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'content' => 'Nice work!', + 'commenter' => 'userA', + ])); + + // Test: Filter comments by commenter name + $comments = $database->find('comments_mto', [ + Query::equal('commenter.name', ['Alice']), + ]); + $this->assertCount(2, $comments); + + // Test: Filter users by their comments' content + $users = $database->find('users_mto', [ + Query::equal('comments.content', ['Great post!']), + ]); + $this->assertCount(1, $users); + $this->assertEquals('userA', $users[0]->getId()); + + // Clean up MANY_TO_ONE test + $database->deleteCollection('comments_mto'); + $database->deleteCollection('users_mto'); + + // ==================== Test MANY_TO_MANY relationships ==================== + $database->createCollection('students_mtm'); + $database->createCollection('courses_mtm'); + + $database->createAttribute('students_mtm', 'studentName', Database::VAR_STRING, 255, true); + $database->createAttribute('courses_mtm', 'courseName', Database::VAR_STRING, 255, true); + + // MANY_TO_MANY + $database->createRelationship( + collection: 'students_mtm', + relatedCollection: 'courses_mtm', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'enrolledCourses', + twoWayKey: 'students' + ); + + $student1 = $database->createDocument('students_mtm', new Document([ + '$id' => 'student1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'studentName' => 'John', + ])); + + $course1 = $database->createDocument('courses_mtm', new Document([ + '$id' => 'course1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'courseName' => 'Physics', + 'students' => ['student1'], + ])); + + // Test: Filter students by enrolled course name + $students = $database->find('students_mtm', [ + Query::equal('enrolledCourses.courseName', ['Physics']), + ]); + $this->assertCount(1, $students); + $this->assertEquals('student1', $students[0]->getId()); + + // Test: Filter courses by student name + $courses = $database->find('courses_mtm', [ + Query::equal('students.studentName', ['John']), + ]); + $this->assertCount(1, $courses); + $this->assertEquals('course1', $courses[0]->getId()); + + // Clean up MANY_TO_MANY test + $database->deleteCollection('students_mtm'); + $database->deleteCollection('courses_mtm'); + } + + /** + * Comprehensive test for all query types on relationships + */ + public function testRelationshipQueryTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup test collections + $database->createCollection('products_qt'); + $database->createCollection('vendors_qt'); + + $database->createAttribute('products_qt', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('products_qt', 'price', Database::VAR_FLOAT, 0, true); + $database->createAttribute('vendors_qt', 'company', Database::VAR_STRING, 255, true); + $database->createAttribute('vendors_qt', 'rating', Database::VAR_FLOAT, 0, true); + $database->createAttribute('vendors_qt', 'email', Database::VAR_STRING, 255, true); + $database->createAttribute('vendors_qt', 'verified', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'products_qt', + relatedCollection: 'vendors_qt', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'vendor', + twoWayKey: 'products' + ); + + // Create test vendors + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'Acme Corp', + 'rating' => 4.5, + 'email' => 'sales@acme.com', + 'verified' => true, + ])); + + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'TechSupply Inc', + 'rating' => 3.8, + 'email' => 'info@techsupply.com', + 'verified' => true, + ])); + + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'Budget Vendors', + 'rating' => 2.5, + 'email' => 'contact@budget.com', + 'verified' => false, + ])); + + // Create test products + $database->createDocument('products_qt', new Document([ + '$id' => 'product1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget A', + 'price' => 19.99, + 'vendor' => 'vendor1', + ])); + + $database->createDocument('products_qt', new Document([ + '$id' => 'product2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget B', + 'price' => 29.99, + 'vendor' => 'vendor2', + ])); + + $database->createDocument('products_qt', new Document([ + '$id' => 'product3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget C', + 'price' => 9.99, + 'vendor' => 'vendor3', + ])); + + // Test: Query::equal() + $products = $database->find('products_qt', [ + Query::equal('vendor.company', ['Acme Corp']) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::notEqual() + $products = $database->find('products_qt', [ + Query::notEqual('vendor.company', ['Budget Vendors']) + ]); + $this->assertCount(2, $products); + + // Test: Query::lessThan() + $products = $database->find('products_qt', [ + Query::lessThan('vendor.rating', 4.0) + ]); + $this->assertCount(2, $products); // vendor2 (3.8) and vendor3 (2.5) + + // Test: Query::lessThanEqual() + $products = $database->find('products_qt', [ + Query::lessThanEqual('vendor.rating', 3.8) + ]); + $this->assertCount(2, $products); + + // Test: Query::greaterThan() + $products = $database->find('products_qt', [ + Query::greaterThan('vendor.rating', 4.0) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::greaterThanEqual() + $products = $database->find('products_qt', [ + Query::greaterThanEqual('vendor.rating', 3.8) + ]); + $this->assertCount(2, $products); // vendor1 (4.5) and vendor2 (3.8) + + // Test: Query::startsWith() + $products = $database->find('products_qt', [ + Query::startsWith('vendor.email', 'sales@') + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::endsWith() + $products = $database->find('products_qt', [ + Query::endsWith('vendor.email', '.com') + ]); + $this->assertCount(3, $products); + + // Test: Query::contains() + $products = $database->find('products_qt', [ + Query::contains('vendor.company', ['Corp']) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Boolean query + $products = $database->find('products_qt', [ + Query::equal('vendor.verified', [true]) + ]); + $this->assertCount(2, $products); // vendor1 and vendor2 are verified + + $products = $database->find('products_qt', [ + Query::equal('vendor.verified', [false]) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product3', $products[0]->getId()); + + // Test: Multiple conditions on same relationship (query grouping optimization) + $products = $database->find('products_qt', [ + Query::greaterThan('vendor.rating', 3.0), + Query::equal('vendor.verified', [true]), + Query::startsWith('vendor.company', 'Acme') + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Clean up + $database->deleteCollection('products_qt'); + $database->deleteCollection('vendors_qt'); + } + + /** + * Test edge cases and error scenarios for relationship queries + */ + public function testRelationshipQueryEdgeCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup test collections + $database->createCollection('orders_edge'); + $database->createCollection('customers_edge'); + + $database->createAttribute('orders_edge', 'orderNumber', Database::VAR_STRING, 255, true); + $database->createAttribute('orders_edge', 'total', Database::VAR_FLOAT, 0, true); + $database->createAttribute('customers_edge', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('customers_edge', 'age', Database::VAR_INTEGER, 0, true); + + $database->createRelationship( + collection: 'orders_edge', + relatedCollection: 'customers_edge', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'customer', + twoWayKey: 'orders' + ); + + // Create customer + $database->createDocument('customers_edge', new Document([ + '$id' => 'customer1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'John Doe', + 'age' => 30, + ])); + + // Create order + $database->createDocument('orders_edge', new Document([ + '$id' => 'order1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'orderNumber' => 'ORD001', + 'total' => 100.00, + 'customer' => 'customer1', + ])); + + // Edge Case 1: Query with no matching results + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['Jane Doe']) + ]); + $this->assertCount(0, $orders); + + // Edge Case 2: Query with impossible condition (combines to empty set) + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::equal('customer.age', [25]) // John is 30, not 25 + ]); + $this->assertCount(0, $orders); + + // Edge Case 3: Query on non-existent relationship field + try { + $orders = $database->find('orders_edge', [ + Query::equal('nonexistent.field', ['value']) + ]); + // Should return empty or throw - either is acceptable + $this->assertCount(0, $orders); + } catch (\Exception $e) { + // Expected - non-existent relationship + $this->assertTrue(true); + } + + // Edge Case 4: Empty array values (should throw exception) + try { + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', []) + ]); + $this->fail('Expected exception for empty array values'); + } catch (\Exception $e) { + // Expected - empty array values are invalid + $this->assertStringContainsString('at least one value', $e->getMessage()); + } + + // Edge Case 5: Null or missing relationship + $database->createDocument('orders_edge', new Document([ + '$id' => 'order2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'orderNumber' => 'ORD002', + 'total' => 50.00, + // No customer relationship + ])); + + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']) + ]); + $this->assertCount(1, $orders); // Only order1 has a customer + + // Edge Case 6: Combining relationship query with regular query + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::greaterThan('total', 75.00) + ]); + $this->assertCount(1, $orders); + $this->assertEquals('order1', $orders[0]->getId()); + + // Edge Case 7: Query with limit and offset + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::limit(1), + Query::offset(0) + ]); + $this->assertCount(1, $orders); + + // Clean up + $database->deleteCollection('orders_edge'); + $database->deleteCollection('customers_edge'); + } + + /** + * Test relationship queries from parent side with virtual fields + */ + public function testRelationshipQueryParentSide(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup ONE_TO_MANY relationship + $database->createCollection('teams_parent'); + $database->createCollection('members_parent'); + + $database->createAttribute('teams_parent', 'teamName', Database::VAR_STRING, 255, true); + $database->createAttribute('teams_parent', 'active', Database::VAR_BOOLEAN, 0, true); + $database->createAttribute('members_parent', 'memberName', Database::VAR_STRING, 255, true); + $database->createAttribute('members_parent', 'role', Database::VAR_STRING, 255, true); + $database->createAttribute('members_parent', 'senior', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'teams_parent', + relatedCollection: 'members_parent', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'members', + twoWayKey: 'team' + ); + + // Create teams + $database->createDocument('teams_parent', new Document([ + '$id' => 'team1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'teamName' => 'Engineering', + 'active' => true, + ])); + + $database->createDocument('teams_parent', new Document([ + '$id' => 'team2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'teamName' => 'Sales', + 'active' => true, + ])); + + // Create members + $database->createDocument('members_parent', new Document([ + '$id' => 'member1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Alice', + 'role' => 'Engineer', + 'senior' => true, + 'team' => 'team1', + ])); + + $database->createDocument('members_parent', new Document([ + '$id' => 'member2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Bob', + 'role' => 'Manager', + 'senior' => false, + 'team' => 'team2', + ])); + + $database->createDocument('members_parent', new Document([ + '$id' => 'member3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Charlie', + 'role' => 'Engineer', + 'senior' => true, + 'team' => 'team1', + ])); + + // Test: Find teams that have senior engineers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Engineer']), + Query::equal('members.senior', [true]) + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team1', $teams[0]->getId()); + + // Test: Find teams with managers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Manager']) + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team2', $teams[0]->getId()); + + // Test: Find teams with members named 'Alice' + $teams = $database->find('teams_parent', [ + Query::startsWith('members.memberName', 'A') + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team1', $teams[0]->getId()); + + // Test: No teams with junior managers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Manager']), + Query::equal('members.senior', [true]) + ]); + $this->assertCount(0, $teams); + + // Clean up + $database->deleteCollection('teams_parent'); + $database->deleteCollection('members_parent'); + } + + /** + * Test MANY_TO_MANY relationships with complex queries + */ + public function testRelationshipManyToManyComplex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup MANY_TO_MANY + $database->createCollection('developers_mtm'); + $database->createCollection('projects_mtm'); + + $database->createAttribute('developers_mtm', 'devName', Database::VAR_STRING, 255, true); + $database->createAttribute('developers_mtm', 'experience', Database::VAR_INTEGER, 0, true); + $database->createAttribute('projects_mtm', 'projectName', Database::VAR_STRING, 255, true); + $database->createAttribute('projects_mtm', 'budget', Database::VAR_FLOAT, 0, true); + $database->createAttribute('projects_mtm', 'priority', Database::VAR_STRING, 50, true); + + $database->createRelationship( + collection: 'developers_mtm', + relatedCollection: 'projects_mtm', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'assignedProjects', + twoWayKey: 'assignedDevelopers' + ); + + // Create developers + $dev1 = $database->createDocument('developers_mtm', new Document([ + '$id' => 'dev1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'devName' => 'Senior Dev', + 'experience' => 10, + ])); + + $dev2 = $database->createDocument('developers_mtm', new Document([ + '$id' => 'dev2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'devName' => 'Junior Dev', + 'experience' => 2, + ])); + + // Create projects + $project1 = $database->createDocument('projects_mtm', new Document([ + '$id' => 'proj1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'projectName' => 'High Priority Project', + 'budget' => 100000.00, + 'priority' => 'high', + 'assignedDevelopers' => ['dev1', 'dev2'], + ])); + + $project2 = $database->createDocument('projects_mtm', new Document([ + '$id' => 'proj2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'projectName' => 'Low Priority Project', + 'budget' => 25000.00, + 'priority' => 'low', + 'assignedDevelopers' => ['dev2'], + ])); + + // Test: Find developers on high priority projects + $developers = $database->find('developers_mtm', [ + Query::equal('assignedProjects.priority', ['high']) + ]); + $this->assertCount(2, $developers); // Both assigned to proj1 + + // Test: Find developers on high budget projects + $developers = $database->find('developers_mtm', [ + Query::greaterThan('assignedProjects.budget', 50000.00) + ]); + $this->assertCount(2, $developers); + + // Test: Find projects with experienced developers + $projects = $database->find('projects_mtm', [ + Query::greaterThanEqual('assignedDevelopers.experience', 10) + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // Test: Find projects with junior developers + $projects = $database->find('projects_mtm', [ + Query::lessThan('assignedDevelopers.experience', 5) + ]); + $this->assertCount(2, $projects); // Both projects have dev2 + + // Test: Combined queries + $projects = $database->find('projects_mtm', [ + Query::equal('assignedDevelopers.devName', ['Junior Dev']), + Query::equal('priority', ['low']) + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj2', $projects[0]->getId()); + + // Clean up + $database->deleteCollection('developers_mtm'); + $database->deleteCollection('projects_mtm'); + } }