Skip to content

Commit b0c7456

Browse files
committed
feat: add topological sorting for entities
1 parent 818ac15 commit b0c7456

File tree

6 files changed

+243
-5
lines changed

6 files changed

+243
-5
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,29 @@ class RecentOrdersView extends View
180180
}
181181
```
182182

183+
#### ⚙️ Handling Dependencies
184+
185+
Entities may depend on one another (e.g., a view that selects from another view).
186+
To support this, each entity can declare its dependencies using the `dependencies()` method:
187+
188+
```php
189+
<?php
190+
191+
class RecentHighValueOrdersView extends View
192+
{
193+
#[Override]
194+
public function dependencies(): array
195+
{
196+
return [
197+
HighValueOrdersView::class,
198+
];
199+
}
200+
}
201+
```
202+
203+
The manager will ensure that dependencies are created in the correct order, using a topological sort behind the scenes.
204+
In the example above, `HighValueOrdersView` will be created before `RecentHighValueOrdersView` automatically.
205+
183206
#### 📑 View
184207

185208
The `View` class is used to create views in the database.

src/Concerns/DefaultSqlEntityBehaviour.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ trait DefaultSqlEntityBehaviour
1818
/** The entity name. */
1919
protected ?string $name = null;
2020

21+
/**
22+
* Any dependencies that need to be handled before this entity.
23+
*
24+
* @var array<int, class-string<SqlEntity>>
25+
*/
26+
protected array $dependencies = [];
27+
2128
#[Override]
2229
public function name(): string
2330
{
@@ -30,6 +37,12 @@ public function connectionName(): ?string
3037
return $this->connection;
3138
}
3239

40+
#[Override]
41+
public function dependencies(): array
42+
{
43+
return $this->dependencies;
44+
}
45+
3346
#[Override]
3447
public function creating(Connection $connection): bool
3548
{
@@ -39,7 +52,6 @@ public function creating(Connection $connection): bool
3952
#[Override]
4053
public function created(Connection $connection): void
4154
{
42-
return;
4355
}
4456

4557
#[Override]
@@ -51,7 +63,6 @@ public function dropping(Connection $connection): bool
5163
#[Override]
5264
public function dropped(Connection $connection): void
5365
{
54-
return;
5566
}
5667

5768
#[Override]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CalebDW\SqlEntities\Concerns;
6+
7+
use RuntimeException;
8+
9+
trait SortsTopologically
10+
{
11+
/**
12+
* Sorts the given nodes topologically.
13+
*
14+
* @template TNode
15+
*
16+
* @param iterable<TNode> $nodes The nodes to sort.
17+
* @param (callable(TNode): iterable<TNode>) $edges A function that returns the edges of a node.
18+
* @param (callable(TNode): array-key)|null $getKey A function that returns the key of a node.
19+
* @return list<TNode> The sorted nodes.
20+
* @throws RuntimeException if a circular reference is detected.
21+
*/
22+
public function sortTopologically(
23+
iterable $nodes,
24+
callable $edges,
25+
?callable $getKey = null,
26+
): array {
27+
$sorted = [];
28+
$visited = [];
29+
$getKey ??= fn ($node) => $node;
30+
31+
foreach ($nodes as $node) {
32+
$this->visit($node, $edges, $sorted, $visited, $getKey);
33+
}
34+
35+
return $sorted;
36+
}
37+
38+
/**
39+
* Visits a node and its dependencies.
40+
*
41+
* @template TNode
42+
*
43+
* @param TNode $node The node to visit.
44+
* @param (callable(TNode): iterable<TNode>) $edges A function that returns the edges of a node.
45+
* @param list<TNode> $sorted The sorted nodes.
46+
* @param-out list<TNode> $sorted The sorted nodes.
47+
* @param array<array-key, bool> $visited The visited nodes.
48+
* @param (callable(TNode): array-key) $getKey A function that returns the key of a node.
49+
* @throws RuntimeException if a circular reference is detected.
50+
*/
51+
protected function visit(
52+
mixed $node,
53+
callable $edges,
54+
array &$sorted,
55+
array &$visited,
56+
callable $getKey,
57+
): void {
58+
$key = $getKey($node);
59+
60+
if (isset($visited[$key])) {
61+
throw_if($visited[$key] === false, "Circular reference detected for [{$key}].");
62+
63+
return;
64+
}
65+
66+
$visited[$key] = false;
67+
68+
foreach ($edges($node) as $edge) {
69+
$this->visit($edge, $edges, $sorted, $visited, $getKey);
70+
}
71+
72+
$visited[$key] = true;
73+
$sorted[] = $node;
74+
}
75+
}

src/Contracts/SqlEntity.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ public function name(): string;
1919
/** The entity connection name. */
2020
public function connectionName(): ?string;
2121

22+
/**
23+
* Any dependencies that need to be handled before this entity.
24+
*
25+
* @return array<int, class-string<self>>
26+
*/
27+
public function dependencies(): array;
28+
2229
/**
2330
* Hook before creating the entity.
2431
*

src/SqlEntityManager.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace CalebDW\SqlEntities;
66

7+
use CalebDW\SqlEntities\Concerns\SortsTopologically;
78
use CalebDW\SqlEntities\Contracts\SqlEntity;
89
use CalebDW\SqlEntities\Grammars\Grammar;
910
use CalebDW\SqlEntities\Grammars\MariaDbGrammar;
@@ -19,8 +20,10 @@
1920

2021
class SqlEntityManager
2122
{
23+
use SortsTopologically;
24+
2225
/** @var Collection<class-string<SqlEntity>, SqlEntity> */
23-
public readonly Collection $entities;
26+
public Collection $entities;
2427

2528
/**
2629
* The active grammar instances.
@@ -34,8 +37,15 @@ public function __construct(
3437
Collection $entities,
3538
protected DatabaseManager $db,
3639
) {
37-
$this->entities = $entities
38-
->keyBy(fn ($entity) => $entity::class);
40+
$this->entities = $entities->keyBy(fn ($e) => $e::class);
41+
42+
$sorted = $this->sortTopologically(
43+
$this->entities,
44+
fn ($e) => collect($e->dependencies())->map($this->get(...)),
45+
fn ($e) => $e::class,
46+
);
47+
48+
$this->entities = collect($sorted)->keyBy(fn ($e) => $e::class);
3949
}
4050

4151
/**
@@ -118,6 +128,7 @@ public function createAll(?string $type = null, ?string $connection = null): voi
118128
public function dropAll(?string $type = null, ?string $connection = null): void
119129
{
120130
$this->entities
131+
->reverse()
121132
->when($connection, fn ($c) => $c->filter(
122133
fn ($e) => $e->connectionName() === $connection,
123134
))
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use CalebDW\SqlEntities\Concerns\SortsTopologically;
6+
7+
beforeEach(function () {
8+
test()->harness = new TopologicalSortTestHarness();
9+
});
10+
11+
it('sorts linear dependencies', function () {
12+
$graph = [
13+
'c' => ['b'],
14+
'b' => ['a'],
15+
'a' => [],
16+
];
17+
18+
$sorted = test()->harness->sortTopologically(
19+
array_keys($graph),
20+
fn ($node) => $graph[$node],
21+
);
22+
23+
expect($sorted)->toBe(['a', 'b', 'c']);
24+
});
25+
26+
it('sorts complex DAG with branches', function () {
27+
$graph = [
28+
'd' => ['b', 'c'],
29+
'c' => ['a'],
30+
'b' => ['a'],
31+
'a' => [],
32+
];
33+
34+
$sorted = test()->harness->sortTopologically(
35+
array_keys($graph),
36+
fn (string $node) => $graph[$node],
37+
);
38+
39+
expect($sorted)->toBe(['a', 'b', 'c', 'd']);
40+
});
41+
42+
it('handles disconnected graphs', function () {
43+
$graph = [
44+
'b' => [],
45+
'a' => [],
46+
'c' => ['a'],
47+
];
48+
49+
$sorted = test()->harness->sortTopologically(
50+
array_keys($graph),
51+
fn ($node) => $graph[$node],
52+
);
53+
54+
expect($sorted)->toContain('a', 'b', 'c');
55+
expect(array_search('a', $sorted))->toBeLessThan(array_search('c', $sorted));
56+
});
57+
58+
it('throws on circular reference', function () {
59+
$graph = [
60+
'a' => ['b'],
61+
'b' => ['a'],
62+
];
63+
64+
test()->harness->sortTopologically(
65+
array_keys($graph),
66+
fn ($node) => $graph[$node],
67+
);
68+
})->throws('Circular reference detected for [a]');
69+
70+
it('works with object nodes', function () {
71+
$a = new TestNode('a');
72+
$b = new TestNode('b', [$a]);
73+
$c = new TestNode('c', [$b]);
74+
$d = new TestNode('d', [$b, $c]);
75+
76+
$sorted = test()->harness->sortTopologically(
77+
[$d, $c, $b, $a],
78+
fn ($n) => $n->deps,
79+
fn ($n) => $n->id,
80+
);
81+
82+
expect($sorted)->toBe([$a, $b, $c, $d]);
83+
});
84+
85+
it('detects cycles with object nodes', function () {
86+
$a = new TestNode('a');
87+
$b = new TestNode('b');
88+
$a->deps = [$b];
89+
$b->deps = [$a];
90+
91+
test()->harness->sortTopologically(
92+
[$a, $b],
93+
fn (TestNode $n) => $n->deps,
94+
fn (TestNode $n) => $n->id,
95+
);
96+
})->throws('Circular reference detected for [a]');
97+
98+
class TopologicalSortTestHarness
99+
{
100+
use SortsTopologically;
101+
}
102+
103+
class TestNode
104+
{
105+
public function __construct(
106+
public string $id,
107+
/** @var list<TestNode> */
108+
public array $deps = [],
109+
) {
110+
}
111+
}

0 commit comments

Comments
 (0)