From fc6f3ee233f04440b6adafd1efe8a6cf98ec00f7 Mon Sep 17 00:00:00 2001 From: Travis Elkins Date: Fri, 31 Jul 2020 14:13:36 +0200 Subject: [PATCH] Introduced a dagRelationsOf scope to get relations (ancestors *and* descendants), added new tests, minor code/test cleanup. --- src/Models/Traits/IsDagManaged.php | 49 ++- tests/IsDagManagedTraitTest.php | 486 ++++++++++++++++++++--------- 2 files changed, 380 insertions(+), 155 deletions(-) diff --git a/src/Models/Traits/IsDagManaged.php b/src/Models/Traits/IsDagManaged.php index 905bb17..d6d246b 100644 --- a/src/Models/Traits/IsDagManaged.php +++ b/src/Models/Traits/IsDagManaged.php @@ -15,7 +15,7 @@ trait IsDagManaged */ public function scopeDagDescendantsOf($query, $modelId, string $source, ?int $maxHops = null) { - $this->scopeDagRelationsOf($query, $modelId, $source, true, $maxHops); + $this->queryDagRelations($query, $modelId, $source, true, $maxHops); } /** @@ -27,7 +27,20 @@ public function scopeDagDescendantsOf($query, $modelId, string $source, ?int $ma */ public function scopeDagAncestorsOf($query, $modelId, string $source, ?int $maxHops = null) { - $this->scopeDagRelationsOf($query, $modelId, $source, false, $maxHops); + $this->queryDagRelations($query, $modelId, $source, false, $maxHops); + } + + /** + * Scope a query to only include models that are related to (either descendants *or* ancestors) the specified model ID. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param int|array $modelId + * @param string $source + */ + public function scopeDagRelationsOf($query, $modelId, string $source, ?int $maxHops = null) + { + $this->queryDagRelations($query, $modelId, $source, false, $maxHops); + $this->queryDagRelations($query, $modelId, $source, true, $maxHops, true); } /** @@ -38,18 +51,15 @@ public function scopeDagAncestorsOf($query, $modelId, string $source, ?int $maxH * @param string $source * @param bool $down */ - public function scopeDagRelationsOf($query, $modelId, string $source, bool $down, ?int $maxHops = null) + protected function queryDagRelations($query, $modelId, string $source, bool $down, ?int $maxHops = null, bool $or = false) { - if (! is_int($modelId) && ! is_array($modelId)) { - throw new InvalidArgumentException('Argument, $modelId, must be of type integer or array.'); - } + $this->guardAgainstInvalidModelId($modelId); - $maxHopsConfig = config('laravel-dag-manager.max_hops'); - $maxHops = $maxHops ?? $maxHopsConfig; // prefer input over config - $maxHops = min($maxHops, $maxHopsConfig); // no larger than config - $maxHops = max($maxHops, 0); // no smaller than zero + $maxHops = $this->maxHops($maxHops); - $query->whereIn($this->getQualifiedKeyName(), function ($query) use ($modelId, $source, $maxHops, $down) { + $method = $or ? 'orWhereIn' : 'whereIn'; + + $query->$method($this->getQualifiedKeyName(), function ($query) use ($modelId, $source, $maxHops, $down) { $selectField = $down ? 'start_vertex' : 'end_vertex'; $whereField = $down ? 'end_vertex' : 'start_vertex'; @@ -66,4 +76,21 @@ public function scopeDagRelationsOf($query, $modelId, string $source, bool $down }); }); } + + protected function guardAgainstInvalidModelId($modelId) + { + if (! is_int($modelId) && ! is_array($modelId)) { + throw new InvalidArgumentException('Argument, $modelId, must be of type integer or array.'); + } + } + + protected function maxHops(?int $maxHops): int + { + $maxHopsConfig = config('laravel-dag-manager.max_hops'); + $maxHops = $maxHops ?? $maxHopsConfig; // prefer input over config + $maxHops = min($maxHops, $maxHopsConfig); // no larger than config + $maxHops = max($maxHops, 0); // no smaller than zero + + return $maxHops; + } } diff --git a/tests/IsDagManagedTraitTest.php b/tests/IsDagManagedTraitTest.php index 3078d0a..e0f1660 100644 --- a/tests/IsDagManagedTraitTest.php +++ b/tests/IsDagManagedTraitTest.php @@ -13,7 +13,7 @@ class IsDagManagedTraitTest extends TestCase /** * Tests: A * | - * B <-- get descendants of this entry + * B <-- get relations of this entry * | * C * | @@ -21,7 +21,7 @@ class IsDagManagedTraitTest extends TestCase * * @test */ - public function it_can_get_descendants_from_a_simple_chain() + public function it_can_get_all_relations_from_a_simple_chain() { /** * Arrange/Given: @@ -42,17 +42,19 @@ public function it_can_get_descendants_from_a_simple_chain() /** * Act/When: - * - we attempt to get DAG descendants from B + * - we attempt to get DAG relations from B */ - $results = TestModel::dagDescendantsOf($b->id, $this->source)->get(); + $results = TestModel::dagRelationsOf($b->id, $this->source)->get(); /** * Assert/Then: * - we have a collection with the following entries: + * - A (from B -> A) * - C (from C -> B) * - D (from D -> C) */ - $this->assertCount(2, $results); + $this->assertCount(3, $results); + $this->assertSame($a->id, $results->shift()->id); $this->assertSame($c->id, $results->shift()->id); $this->assertSame($d->id, $results->shift()->id); } @@ -60,7 +62,7 @@ public function it_can_get_descendants_from_a_simple_chain() /** * Tests: A * / \ - * B C <-- get descendants of entry "B" + * B C <-- get relations of entry "B" * | \ | * D E * \ / @@ -68,34 +70,275 @@ public function it_can_get_descendants_from_a_simple_chain() * * @test */ - public function it_can_get_descendants_from_a_complex_box_diamond_part_i() + public function it_can_get_all_relations_from_a_complex_box_diamond_part_i() + { + /** + * Arrange/Given: + * - we have a "complex box diamond" + */ + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); + + /** + * Act/When: + * - we attempt to get DAG relations from B + */ + $results = TestModel::dagRelationsOf($b->id, $this->source)->get(); + + /** + * Assert/Then: + * - we have a collection with the following entries: + * - A (from B -> A) + * - D (from D -> B) + * - E (from E -> B) + * - F (from F -> D -> B *and/or* F -> E -> B) + */ + $this->assertCount(4, $results); + $this->assertSame($a->id, $results->shift()->id); + $this->assertSame($d->id, $results->shift()->id); + $this->assertSame($e->id, $results->shift()->id); + $this->assertSame($f->id, $results->shift()->id); + } + + /** + * Tests: A + * / \ + * B C <-- get relations of entry "C" + * | \ | + * D E + * \ / + * F + * + * @test + */ + public function it_can_get_all_relations_from_a_complex_box_diamond_part_ii() + { + /** + * Arrange/Given: + * - we have a "complex box diamond" + */ + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); + + /** + * Act/When: + * - we attempt to get DAG relations from C + */ + $results = TestModel::dagRelationsOf($c->id, $this->source)->get(); + + /** + * Assert/Then: + * - we have a collection with the following entries: + * - A (from C -> A) + * - E (from E -> C) + * - F (from F -> E) + */ + $this->assertCount(3, $results); + $this->assertSame($a->id, $results->shift()->id); + $this->assertSame($e->id, $results->shift()->id); + $this->assertSame($f->id, $results->shift()->id); + } + + /** + * Tests: A + * | + * B <-- get relations of this entry + * | + * C + * | + * D + * + * @test + * @dataProvider provideMaxHopsForSimpleChainAllRelations + */ + public function it_can_get_all_relations_from_a_simple_chain_constrained_by_max_hops($maxHops, $expectedNames) { /** * Arrange/Given: * - we have the following test models: - * - a - f + * - a - d * - we have the following dag edge(s) in place: * - B -> A - * - D -> B - * - E -> B - * - C -> A - * - E -> C - * - F -> D - * - F -> E + * - C -> B + * - D -> C */ $a = TestModel::create(['name' => 'a']); $b = TestModel::create(['name' => 'b']); $c = TestModel::create(['name' => 'c']); $d = TestModel::create(['name' => 'd']); - $e = TestModel::create(['name' => 'e']); - $f = TestModel::create(['name' => 'f']); $this->createEdge($b->id, $a->id); - $this->createEdge($d->id, $b->id); - $this->createEdge($e->id, $b->id); - $this->createEdge($c->id, $a->id); - $this->createEdge($e->id, $c->id); - $this->createEdge($f->id, $d->id); - $this->createEdge($f->id, $e->id); + $this->createEdge($c->id, $b->id); + $this->createEdge($d->id, $c->id); + + /** + * Act/When: + * - we attempt to get all DAG relations of B, constrained by $maxHops + */ + $results = TestModel::dagRelationsOf($b->id, $this->source, $maxHops)->get(); + + /** + * Assert/Then: + * - we have a collection with the expected number of entries + * - each of the expected names can be found in the results + */ + $this->assertCount(count($expectedNames), $results); + collect($expectedNames)->each(function ($expectedName) use ($results) { + $this->assertTrue($results->pluck('name')->contains($expectedName)); + }); + } + + public function provideMaxHopsForSimpleChainAllRelations() + { + return [ + [0, ['a', 'c']], + [1, ['a', 'c', 'd']], + [2, ['a', 'c', 'd']], + [3, ['a', 'c', 'd']], + [null, ['a', 'c', 'd']], + [-1, ['a', 'c']], + ]; + } + + /** + * Tests: A + * / \ + * B C <-- get relations of entry "B" + * | \ | + * D E + * \ / + * F + * + * @test + * @dataProvider provideMaxHopsForComplexBoxDiamondAllRelations + */ + public function it_can_get_all_relations_from_a_complex_box_diamond_constrained_by_max_hops($maxHops, $expectedNames) + { + /** + * Arrange/Given: + * - we have a "complex box diamond" + */ + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); + + /** + * Act/When: + * - we attempt to get all DAG relations from B, constrained by $maxHops + */ + $results = TestModel::dagRelationsOf($b->id, $this->source, $maxHops)->get(); + + /** + * Assert/Then: + * - we have a collection with the expected number of entries + * - each of the expected names can be found in the results + */ + $this->assertCount(count($expectedNames), $results); + collect($expectedNames)->each(function ($expectedName) use ($results) { + $this->assertTrue($results->pluck('name')->contains($expectedName)); + }); + } + + public function provideMaxHopsForComplexBoxDiamondAllRelations() + { + return [ + [0, ['a', 'd', 'e']], + [1, ['a', 'd', 'e', 'f']], + [2, ['a', 'd', 'e', 'f']], + [3, ['a', 'd', 'e', 'f']], + [null, ['a', 'd', 'e', 'f']], + [-1, ['a', 'd', 'e']], + ]; + } + + /** + * Tests: A + * | + * B <-- get descendants of this entry + * | + * C + * | + * D + * + * @test + */ + public function it_can_get_descendants_from_a_simple_chain() + { + /** + * Arrange/Given: + * - we have the following test models: + * - a - d + * - we have the following dag edge(s) in place: + * - B -> A + * - C -> B + * - D -> C + */ + $a = TestModel::create(['name' => 'a']); + $b = TestModel::create(['name' => 'b']); + $c = TestModel::create(['name' => 'c']); + $d = TestModel::create(['name' => 'd']); + $this->createEdge($b->id, $a->id); + $this->createEdge($c->id, $b->id); + $this->createEdge($d->id, $c->id); + + /** + * Act/When: + * - we attempt to get DAG descendants from B + */ + $results = TestModel::dagDescendantsOf($b->id, $this->source)->get(); + + /** + * Assert/Then: + * - we have a collection with the following entries: + * - C (from C -> B) + * - D (from D -> C) + */ + $this->assertCount(2, $results); + $this->assertSame($c->id, $results->shift()->id); + $this->assertSame($d->id, $results->shift()->id); + } + + /** + * Tests: A + * / \ + * B C <-- get descendants of entry "B" + * | \ | + * D E + * \ / + * F + * + * @test + */ + public function it_can_get_descendants_from_a_complex_box_diamond_part_i() + { + /** + * Arrange/Given: + * - we have a "complex box diamond" + */ + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); /** * Act/When: @@ -131,30 +374,16 @@ public function it_can_get_descendants_from_a_complex_box_diamond_part_ii() { /** * Arrange/Given: - * - we have the following test models: - * - a - f - * - we have the following dag edge(s) in place: - * - B -> A - * - D -> B - * - E -> B - * - C -> A - * - E -> C - * - F -> D - * - F -> E + * - we have a "complex box diamond" */ - $a = TestModel::create(['name' => 'a']); - $b = TestModel::create(['name' => 'b']); - $c = TestModel::create(['name' => 'c']); - $d = TestModel::create(['name' => 'd']); - $e = TestModel::create(['name' => 'e']); - $f = TestModel::create(['name' => 'f']); - $this->createEdge($b->id, $a->id); - $this->createEdge($d->id, $b->id); - $this->createEdge($e->id, $b->id); - $this->createEdge($c->id, $a->id); - $this->createEdge($e->id, $c->id); - $this->createEdge($f->id, $d->id); - $this->createEdge($f->id, $e->id); + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); /** * Act/When: @@ -235,30 +464,16 @@ public function it_can_get_ancestors_from_a_complex_box_diamond_part_i() { /** * Arrange/Given: - * - we have the following test models: - * - a - f - * - we have the following dag edge(s) in place: - * - B -> A - * - D -> B - * - E -> B - * - C -> A - * - E -> C - * - F -> D - * - F -> E + * - we have a "complex box diamond" */ - $a = TestModel::create(['name' => 'a']); - $b = TestModel::create(['name' => 'b']); - $c = TestModel::create(['name' => 'c']); - $d = TestModel::create(['name' => 'd']); - $e = TestModel::create(['name' => 'e']); - $f = TestModel::create(['name' => 'f']); - $this->createEdge($b->id, $a->id); - $this->createEdge($d->id, $b->id); - $this->createEdge($e->id, $b->id); - $this->createEdge($c->id, $a->id); - $this->createEdge($e->id, $c->id); - $this->createEdge($f->id, $d->id); - $this->createEdge($f->id, $e->id); + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); /** * Act/When: @@ -294,30 +509,16 @@ public function it_can_get_ancestors_from_a_complex_box_diamond_part_ii() { /** * Arrange/Given: - * - we have the following test models: - * - a - f - * - we have the following dag edge(s) in place: - * - B -> A - * - D -> B - * - E -> B - * - C -> A - * - E -> C - * - F -> D - * - F -> E + * - we have a "complex box diamond" */ - $a = TestModel::create(['name' => 'a']); - $b = TestModel::create(['name' => 'b']); - $c = TestModel::create(['name' => 'c']); - $d = TestModel::create(['name' => 'd']); - $e = TestModel::create(['name' => 'e']); - $f = TestModel::create(['name' => 'f']); - $this->createEdge($b->id, $a->id); - $this->createEdge($d->id, $b->id); - $this->createEdge($e->id, $b->id); - $this->createEdge($c->id, $a->id); - $this->createEdge($e->id, $c->id); - $this->createEdge($f->id, $d->id); - $this->createEdge($f->id, $e->id); + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); /** * Act/When: @@ -380,7 +581,7 @@ public function it_can_get_descendants_from_a_simple_chain_constrained_by_max_ho */ $this->assertCount(count($expectedNames), $results); collect($expectedNames)->each(function ($expectedName) use ($results) { - $this->assertTrue(in_array($expectedName, $results->pluck('name')->all())); + $this->assertTrue($results->pluck('name')->contains($expectedName)); }); } @@ -397,7 +598,7 @@ public function provideMaxHopsForSimpleChain() } /** - * Tests: A <-- get descendants of entry "B" + * Tests: A <-- get descendants of entry "A" * / \ * B C * | \ | @@ -412,30 +613,16 @@ public function it_can_get_descendants_from_a_complex_box_diamond_constrained_by { /** * Arrange/Given: - * - we have the following test models: - * - a - f - * - we have the following dag edge(s) in place: - * - B -> A - * - D -> B - * - E -> B - * - C -> A - * - E -> C - * - F -> D - * - F -> E + * - we have a "complex box diamond" */ - $a = TestModel::create(['name' => 'a']); - $b = TestModel::create(['name' => 'b']); - $c = TestModel::create(['name' => 'c']); - $d = TestModel::create(['name' => 'd']); - $e = TestModel::create(['name' => 'e']); - $f = TestModel::create(['name' => 'f']); - $this->createEdge($b->id, $a->id); - $this->createEdge($d->id, $b->id); - $this->createEdge($e->id, $b->id); - $this->createEdge($c->id, $a->id); - $this->createEdge($e->id, $c->id); - $this->createEdge($f->id, $d->id); - $this->createEdge($f->id, $e->id); + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); /** * Act/When: @@ -450,7 +637,7 @@ public function it_can_get_descendants_from_a_complex_box_diamond_constrained_by */ $this->assertCount(count($expectedNames), $results); collect($expectedNames)->each(function ($expectedName) use ($results) { - $this->assertTrue(in_array($expectedName, $results->pluck('name')->all())); + $this->assertTrue($results->pluck('name')->contains($expectedName)); }); } @@ -510,7 +697,7 @@ public function it_can_get_ancestors_from_a_simple_chain_constrained_by_max_hops */ $this->assertCount(count($expectedNames), $results); collect($expectedNames)->each(function ($expectedName) use ($results) { - $this->assertTrue(in_array($expectedName, $results->pluck('name')->all())); + $this->assertTrue($results->pluck('name')->contains($expectedName)); }); } @@ -541,30 +728,16 @@ public function it_can_get_ancestors_from_a_complex_box_diamond_constrained_by_m { /** * Arrange/Given: - * - we have the following test models: - * - a - f - * - we have the following dag edge(s) in place: - * - B -> A - * - D -> B - * - E -> B - * - C -> A - * - E -> C - * - F -> D - * - F -> E + * - we have a "complex box diamond" */ - $a = TestModel::create(['name' => 'a']); - $b = TestModel::create(['name' => 'b']); - $c = TestModel::create(['name' => 'c']); - $d = TestModel::create(['name' => 'd']); - $e = TestModel::create(['name' => 'e']); - $f = TestModel::create(['name' => 'f']); - $this->createEdge($b->id, $a->id); - $this->createEdge($d->id, $b->id); - $this->createEdge($e->id, $b->id); - $this->createEdge($c->id, $a->id); - $this->createEdge($e->id, $c->id); - $this->createEdge($f->id, $d->id); - $this->createEdge($f->id, $e->id); + $this->buildComplexBoxDiamond(); + + $a = TestModel::where('name', 'a')->first(); + $b = TestModel::where('name', 'b')->first(); + $c = TestModel::where('name', 'c')->first(); + $d = TestModel::where('name', 'd')->first(); + $e = TestModel::where('name', 'e')->first(); + $f = TestModel::where('name', 'f')->first(); /** * Act/When: @@ -579,7 +752,7 @@ public function it_can_get_ancestors_from_a_complex_box_diamond_constrained_by_m */ $this->assertCount(count($expectedNames), $results); collect($expectedNames)->each(function ($expectedName) use ($results) { - $this->assertTrue(in_array($expectedName, $results->pluck('name')->all())); + $this->assertTrue($results->pluck('name')->contains($expectedName)); }); } @@ -629,7 +802,7 @@ public function it_can_get_descendants_with_complex_input($modelNames, $maxHops, */ $this->assertCount(count($expectedNames), $results); collect($expectedNames)->each(function ($expectedName) use ($results) { - $this->assertTrue(in_array($expectedName, $results->pluck('name')->all())); + $this->assertTrue($results->pluck('name')->contains($expectedName)); }); } @@ -694,7 +867,7 @@ public function it_can_get_ancestors_with_complex_input($modelNames, $maxHops, $ */ $this->assertCount(count($expectedNames), $results); collect($expectedNames)->each(function ($expectedName) use ($results) { - $this->assertTrue(in_array($expectedName, $results->pluck('name')->all())); + $this->assertTrue($results->pluck('name')->contains($expectedName)); }); } @@ -775,6 +948,31 @@ public function it_rejects_invalid_model_id_arguments_to_dag_ancestors_of_scope( TestModel::dagAncestorsOf($invalidArgument, $this->source); } + /** + * @test + * @dataProvider providInvalidModelIdArguments + */ + public function it_rejects_invalid_model_id_arguments_to_dag_relations_of_scope($invalidArgument) + { + /** + * Arrange/Given: + * - ... + */ + // ... + + /** + * Assert/Then: + * - the expected exception will be thrown + */ + $this->expectException(InvalidArgumentException::class); + + /** + * Act/When: + * - attempt to insert an edge that would create a circular loop + */ + TestModel::dagRelationsOf($invalidArgument, $this->source); + } + public function providInvalidModelIdArguments() { return [