Skip to content

Commit c9222c8

Browse files
committed
Fix many to many 3 level query
1 parent 49f8d40 commit c9222c8

File tree

2 files changed

+195
-24
lines changed

2 files changed

+195
-24
lines changed

src/Database/Database.php

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8982,34 +8982,55 @@ private function processNestedRelationshipPath(string $startCollection, array $q
89828982
);
89838983

89848984
if ($needsReverseLookup) {
8985-
// Need to find parents by querying children and extracting parent IDs
8986-
$childDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find(
8987-
$link['toCollection'],
8988-
[
8989-
Query::equal('$id', $matchingIds),
8990-
Query::select(['$id', $link['twoWayKey']]),
8985+
if ($relationType === self::RELATION_MANY_TO_MANY) {
8986+
// For many-to-many, query the junction table directly instead
8987+
// of resolving full relationships on the child documents.
8988+
$fromCollectionDoc = $this->silent(fn () => $this->getCollection($link['fromCollection']));
8989+
$toCollectionDoc = $this->silent(fn () => $this->getCollection($link['toCollection']));
8990+
$junction = $this->getJunctionCollection($fromCollectionDoc, $toCollectionDoc, $link['side']);
8991+
8992+
$junctionDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find($junction, [
8993+
Query::equal($link['key'], $matchingIds),
89918994
Query::limit(PHP_INT_MAX),
8992-
]
8993-
)));
8995+
])));
89948996

8995-
$parentIds = [];
8996-
foreach ($childDocs as $doc) {
8997-
$parentValue = $doc->getAttribute($link['twoWayKey']);
8998-
if (\is_array($parentValue)) {
8999-
foreach ($parentValue as $pId) {
9000-
if ($pId instanceof Document) {
9001-
$pId = $pId->getId();
8997+
$parentIds = [];
8998+
foreach ($junctionDocs as $jDoc) {
8999+
$pId = $jDoc->getAttribute($link['twoWayKey']);
9000+
if ($pId && !\in_array($pId, $parentIds)) {
9001+
$parentIds[] = $pId;
9002+
}
9003+
}
9004+
} else {
9005+
// Need to find parents by querying children and extracting parent IDs
9006+
$childDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find(
9007+
$link['toCollection'],
9008+
[
9009+
Query::equal('$id', $matchingIds),
9010+
Query::select(['$id', $link['twoWayKey']]),
9011+
Query::limit(PHP_INT_MAX),
9012+
]
9013+
)));
9014+
9015+
$parentIds = [];
9016+
foreach ($childDocs as $doc) {
9017+
$parentValue = $doc->getAttribute($link['twoWayKey']);
9018+
if (\is_array($parentValue)) {
9019+
foreach ($parentValue as $pId) {
9020+
if ($pId instanceof Document) {
9021+
$pId = $pId->getId();
9022+
}
9023+
if ($pId && !\in_array($pId, $parentIds)) {
9024+
$parentIds[] = $pId;
9025+
}
90029026
}
9003-
if ($pId && !\in_array($pId, $parentIds)) {
9004-
$parentIds[] = $pId;
9027+
} else {
9028+
if ($parentValue instanceof Document) {
9029+
$parentValue = $parentValue->getId();
9030+
}
9031+
if ($parentValue && !\in_array($parentValue, $parentIds)) {
9032+
$parentIds[] = $parentValue;
90059033
}
9006-
}
9007-
} else {
9008-
if ($parentValue instanceof Document) {
9009-
$parentValue = $parentValue->getId();
9010-
}
9011-
if ($parentValue && !\in_array($parentValue, $parentIds)) {
9012-
$parentIds[] = $parentValue;
90139034
}
90149035
}
90159036
}

tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2250,4 +2250,154 @@ public function testManyToManyRelationshipWithArrayOperators(): void
22502250
$database->deleteCollection('library');
22512251
$database->deleteCollection('book');
22522252
}
2253+
2254+
/**
2255+
* Regression: processNestedRelationshipPath used skipRelationships()
2256+
* for many-to-many reverse lookups, which prevented junction-table data
2257+
* (twoWayKey) from being populated, yielding empty matchingIds.
2258+
*/
2259+
public function testNestedManyToManyRelationshipQueries(): void
2260+
{
2261+
/** @var Database $database */
2262+
$database = $this->getDatabase();
2263+
2264+
if (!$database->getAdapter()->getSupportForRelationships()) {
2265+
$this->expectNotToPerformAssertions();
2266+
return;
2267+
}
2268+
2269+
// 3-level many-to-many chain: brands <-> products <-> tags
2270+
$database->createCollection('brands');
2271+
$database->createCollection('products');
2272+
$database->createCollection('tags');
2273+
2274+
$database->createAttribute('brands', 'name', Database::VAR_STRING, 255, true);
2275+
$database->createAttribute('products', 'title', Database::VAR_STRING, 255, true);
2276+
$database->createAttribute('tags', 'label', Database::VAR_STRING, 255, true);
2277+
2278+
$database->createRelationship(
2279+
collection: 'brands',
2280+
relatedCollection: 'products',
2281+
type: Database::RELATION_MANY_TO_MANY,
2282+
twoWay: true,
2283+
id: 'products',
2284+
twoWayKey: 'brands',
2285+
);
2286+
2287+
$database->createRelationship(
2288+
collection: 'products',
2289+
relatedCollection: 'tags',
2290+
type: Database::RELATION_MANY_TO_MANY,
2291+
twoWay: true,
2292+
id: 'tags',
2293+
twoWayKey: 'products',
2294+
);
2295+
2296+
// Seed data
2297+
$database->createDocument('tags', new Document([
2298+
'$id' => 'tag_eco',
2299+
'$permissions' => [Permission::read(Role::any())],
2300+
'label' => 'Eco-Friendly',
2301+
]));
2302+
$database->createDocument('tags', new Document([
2303+
'$id' => 'tag_premium',
2304+
'$permissions' => [Permission::read(Role::any())],
2305+
'label' => 'Premium',
2306+
]));
2307+
$database->createDocument('tags', new Document([
2308+
'$id' => 'tag_sale',
2309+
'$permissions' => [Permission::read(Role::any())],
2310+
'label' => 'Sale',
2311+
]));
2312+
2313+
$database->createDocument('products', new Document([
2314+
'$id' => 'prod_a',
2315+
'$permissions' => [Permission::read(Role::any())],
2316+
'title' => 'Product A',
2317+
'tags' => ['tag_eco', 'tag_premium'],
2318+
]));
2319+
$database->createDocument('products', new Document([
2320+
'$id' => 'prod_b',
2321+
'$permissions' => [Permission::read(Role::any())],
2322+
'title' => 'Product B',
2323+
'tags' => ['tag_sale'],
2324+
]));
2325+
$database->createDocument('products', new Document([
2326+
'$id' => 'prod_c',
2327+
'$permissions' => [Permission::read(Role::any())],
2328+
'title' => 'Product C',
2329+
'tags' => ['tag_eco'],
2330+
]));
2331+
2332+
$database->createDocument('brands', new Document([
2333+
'$id' => 'brand_x',
2334+
'$permissions' => [Permission::read(Role::any())],
2335+
'name' => 'BrandX',
2336+
'products' => ['prod_a', 'prod_b'],
2337+
]));
2338+
$database->createDocument('brands', new Document([
2339+
'$id' => 'brand_y',
2340+
'$permissions' => [Permission::read(Role::any())],
2341+
'name' => 'BrandY',
2342+
'products' => ['prod_c'],
2343+
]));
2344+
2345+
// --- 1-level deep: query brands by product title (many-to-many) ---
2346+
$brands = $database->find('brands', [
2347+
Query::equal('products.title', ['Product A']),
2348+
]);
2349+
$this->assertCount(1, $brands);
2350+
$this->assertEquals('brand_x', $brands[0]->getId());
2351+
2352+
// --- 2-level deep: query brands by product→tag label (many-to-many→many-to-many) ---
2353+
// "Eco-Friendly" tag is on prod_a (BrandX) and prod_c (BrandY)
2354+
$brands = $database->find('brands', [
2355+
Query::equal('products.tags.label', ['Eco-Friendly']),
2356+
]);
2357+
$this->assertCount(2, $brands);
2358+
$brandIds = \array_map(fn ($d) => $d->getId(), $brands);
2359+
$this->assertContains('brand_x', $brandIds);
2360+
$this->assertContains('brand_y', $brandIds);
2361+
2362+
// "Sale" tag is only on prod_b (BrandX)
2363+
$brands = $database->find('brands', [
2364+
Query::equal('products.tags.label', ['Sale']),
2365+
]);
2366+
$this->assertCount(1, $brands);
2367+
$this->assertEquals('brand_x', $brands[0]->getId());
2368+
2369+
// "Premium" tag is only on prod_a (BrandX)
2370+
$brands = $database->find('brands', [
2371+
Query::equal('products.tags.label', ['Premium']),
2372+
]);
2373+
$this->assertCount(1, $brands);
2374+
$this->assertEquals('brand_x', $brands[0]->getId());
2375+
2376+
// --- 2-level deep from the child side: query tags by product→brand name ---
2377+
$tags = $database->find('tags', [
2378+
Query::equal('products.brands.name', ['BrandY']),
2379+
]);
2380+
$this->assertCount(1, $tags);
2381+
$this->assertEquals('tag_eco', $tags[0]->getId());
2382+
2383+
$tags = $database->find('tags', [
2384+
Query::equal('products.brands.name', ['BrandX']),
2385+
]);
2386+
$this->assertCount(3, $tags);
2387+
$tagIds = \array_map(fn ($d) => $d->getId(), $tags);
2388+
$this->assertContains('tag_eco', $tagIds);
2389+
$this->assertContains('tag_premium', $tagIds);
2390+
$this->assertContains('tag_sale', $tagIds);
2391+
2392+
// --- No match returns empty ---
2393+
$brands = $database->find('brands', [
2394+
Query::equal('products.tags.label', ['NonExistent']),
2395+
]);
2396+
$this->assertCount(0, $brands);
2397+
2398+
// Cleanup
2399+
$database->deleteCollection('brands');
2400+
$database->deleteCollection('products');
2401+
$database->deleteCollection('tags');
2402+
}
22532403
}

0 commit comments

Comments
 (0)