lthn.io/app/Core/Database/Seeders/SeederDiscovery.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

546 lines
16 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Database\Seeders;
use Core\Database\Seeders\Attributes\SeederAfter;
use Core\Database\Seeders\Attributes\SeederBefore;
use Core\Database\Seeders\Attributes\SeederPriority;
use Core\Database\Seeders\Exceptions\CircularDependencyException;
use ReflectionClass;
/**
* Discovers and orders seeders from module directories.
*
* The SeederDiscovery service scans configured paths for seeder classes,
* reads their priority and dependency declarations, and produces a
* topologically sorted list of seeders ready for execution.
*
* ## Discovery
*
* Seeders are discovered by scanning for `*Seeder.php` files in
* `Database/Seeders/` subdirectories of configured paths.
*
* ## Ordering
*
* Seeders can declare ordering preferences via:
*
* 1. **Priority** (property or attribute): Higher values run first
* ```php
* public int $priority = 10;
* // or
* #[SeederPriority(10)]
* ```
*
* 2. **After** (property or attribute): Must run after specified seeders
* ```php
* public array $after = [FeatureSeeder::class];
* // or
* #[SeederAfter(FeatureSeeder::class)]
* ```
*
* 3. **Before** (property or attribute): Must run before specified seeders
* ```php
* public array $before = [PackageSeeder::class];
* // or
* #[SeederBefore(PackageSeeder::class)]
* ```
*
* Dependencies take precedence over priority. Within the same dependency
* level, seeders are sorted by priority (higher first).
*
*
* @see SeederPriority For priority configuration
* @see SeederAfter For dependency configuration
* @see SeederBefore For reverse dependency configuration
*/
class SeederDiscovery
{
/**
* Default priority for seeders.
*/
public const DEFAULT_PRIORITY = 50;
/**
* Discovered seeder metadata.
*
* @var array<string, array{priority: int, after: array<string>, before: array<string>}>
*/
private array $seeders = [];
/**
* Paths to scan for seeders.
*
* @var array<string>
*/
private array $paths = [];
/**
* Seeder classes to exclude.
*
* @var array<string>
*/
private array $excluded = [];
/**
* Whether discovery has been performed.
*/
private bool $discovered = false;
/**
* Create a new SeederDiscovery instance.
*
* @param array<string> $paths Directories to scan for modules
* @param array<string> $excluded Seeder classes to exclude
*/
public function __construct(array $paths = [], array $excluded = [])
{
$this->paths = $paths;
$this->excluded = $excluded;
}
/**
* Add paths to scan for seeders.
*
* @param array<string> $paths Directories to add
* @return $this
*/
public function addPaths(array $paths): self
{
$this->paths = array_merge($this->paths, $paths);
$this->discovered = false;
return $this;
}
/**
* Set paths to scan for seeders.
*
* @param array<string> $paths Directories to scan
* @return $this
*/
public function setPaths(array $paths): self
{
$this->paths = $paths;
$this->discovered = false;
return $this;
}
/**
* Add seeder classes to exclude.
*
* @param array<string> $classes Seeder class names to exclude
* @return $this
*/
public function exclude(array $classes): self
{
$this->excluded = array_merge($this->excluded, $classes);
return $this;
}
/**
* Discover and return ordered seeder classes.
*
* @return array<string> Ordered list of seeder class names
*
* @throws CircularDependencyException If a circular dependency is detected
*/
public function discover(): array
{
if (! $this->discovered) {
$this->scanPaths();
$this->discovered = true;
}
return $this->sort();
}
/**
* Get all discovered seeders with their metadata.
*
* @return array<string, array{priority: int, after: array<string>, before: array<string>}>
*/
public function getSeeders(): array
{
if (! $this->discovered) {
$this->scanPaths();
$this->discovered = true;
}
return $this->seeders;
}
/**
* Reset the discovery cache.
*
* @return $this
*/
public function reset(): self
{
$this->seeders = [];
$this->discovered = false;
return $this;
}
/**
* Scan configured paths for seeder classes.
*/
private function scanPaths(): void
{
$this->seeders = [];
foreach ($this->paths as $path) {
$this->scanPath($path);
}
}
/**
* Scan a single path for seeder classes.
*
* @param string $path Directory to scan
*/
private function scanPath(string $path): void
{
if (! is_dir($path)) {
return;
}
// Look for Database/Seeders directories in immediate subdirectories
$pattern = "{$path}/*/Database/Seeders/*Seeder.php";
$files = glob($pattern) ?: [];
// Also check for seeders directly in the path (for Core modules)
$directPattern = "{$path}/Database/Seeders/*Seeder.php";
$directFiles = glob($directPattern) ?: [];
$files = array_merge($files, $directFiles);
foreach ($files as $file) {
$class = $this->classFromFile($file);
if ($class && class_exists($class) && ! in_array($class, $this->excluded, true)) {
$this->seeders[$class] = $this->extractMetadata($class);
}
}
}
/**
* Derive class name from file path.
*
* @param string $file Path to the seeder file
* @return string|null Fully qualified class name, or null if not determinable
*/
private function classFromFile(string $file): ?string
{
$contents = file_get_contents($file);
if ($contents === false) {
return null;
}
// Extract namespace
if (preg_match('/namespace\s+([^;]+);/', $contents, $nsMatch)) {
$namespace = $nsMatch[1];
} else {
return null;
}
// Extract class name
if (preg_match('/class\s+(\w+)/', $contents, $classMatch)) {
$className = $classMatch[1];
} else {
return null;
}
return $namespace.'\\'.$className;
}
/**
* Extract ordering metadata from a seeder class.
*
* @param string $class Seeder class name
* @return array{priority: int, after: array<string>, before: array<string>}
*/
private function extractMetadata(string $class): array
{
$reflection = new ReflectionClass($class);
return [
'priority' => $this->extractPriority($reflection),
'after' => $this->extractAfter($reflection),
'before' => $this->extractBefore($reflection),
];
}
/**
* Extract priority from a seeder class.
*
* Checks for SeederPriority attribute first, then falls back to
* public $priority property.
*
* @param ReflectionClass $reflection Reflection of the seeder class
* @return int Priority value
*/
private function extractPriority(ReflectionClass $reflection): int
{
// Check for attribute first
$attributes = $reflection->getAttributes(SeederPriority::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance()->priority;
}
// Fall back to property
if ($reflection->hasProperty('priority')) {
$prop = $reflection->getProperty('priority');
if ($prop->isPublic() && ! $prop->isStatic()) {
$defaultProps = $reflection->getDefaultProperties();
if (isset($defaultProps['priority']) && is_int($defaultProps['priority'])) {
return $defaultProps['priority'];
}
}
}
return self::DEFAULT_PRIORITY;
}
/**
* Extract 'after' dependencies from a seeder class.
*
* Checks for SeederAfter attributes first, then falls back to
* public $after property.
*
* @param ReflectionClass $reflection Reflection of the seeder class
* @return array<string> Seeder classes that must run before this one
*/
private function extractAfter(ReflectionClass $reflection): array
{
$after = [];
// Check for attributes
$attributes = $reflection->getAttributes(SeederAfter::class);
foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$after = array_merge($after, $instance->seeders);
}
// If no attributes, check for property
if (empty($after) && $reflection->hasProperty('after')) {
$prop = $reflection->getProperty('after');
if ($prop->isPublic() && ! $prop->isStatic()) {
$defaultProps = $reflection->getDefaultProperties();
if (isset($defaultProps['after']) && is_array($defaultProps['after'])) {
$after = $defaultProps['after'];
}
}
}
return $after;
}
/**
* Extract 'before' dependencies from a seeder class.
*
* Checks for SeederBefore attributes first, then falls back to
* public $before property.
*
* @param ReflectionClass $reflection Reflection of the seeder class
* @return array<string> Seeder classes that must run after this one
*/
private function extractBefore(ReflectionClass $reflection): array
{
$before = [];
// Check for attributes
$attributes = $reflection->getAttributes(SeederBefore::class);
foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$before = array_merge($before, $instance->seeders);
}
// If no attributes, check for property
if (empty($before) && $reflection->hasProperty('before')) {
$prop = $reflection->getProperty('before');
if ($prop->isPublic() && ! $prop->isStatic()) {
$defaultProps = $reflection->getDefaultProperties();
if (isset($defaultProps['before']) && is_array($defaultProps['before'])) {
$before = $defaultProps['before'];
}
}
}
return $before;
}
/**
* Topologically sort seeders based on dependencies and priority.
*
* Lower priority values run first (e.g., priority 10 runs before priority 50).
*
* @return array<string> Ordered seeder class names
*
* @throws CircularDependencyException If a circular dependency is detected
*/
private function sort(): array
{
// Build adjacency list (seeder -> seeders that must run before it)
$dependencies = [];
foreach ($this->seeders as $seeder => $meta) {
$dependencies[$seeder] = $meta['after'];
// Process 'before' declarations (reverse dependencies)
foreach ($meta['before'] as $dependent) {
if (isset($this->seeders[$dependent])) {
$dependencies[$dependent][] = $seeder;
}
}
}
// Normalize dependencies to unique values
foreach ($dependencies as $seeder => $deps) {
$dependencies[$seeder] = array_unique($deps);
}
// Kahn's algorithm for topological sort
$inDegree = [];
$graph = [];
// Initialize
foreach ($dependencies as $seeder => $deps) {
if (! isset($inDegree[$seeder])) {
$inDegree[$seeder] = 0;
}
if (! isset($graph[$seeder])) {
$graph[$seeder] = [];
}
foreach ($deps as $dep) {
// Only count dependencies that exist in our discovered seeders
if (isset($this->seeders[$dep])) {
$inDegree[$seeder]++;
$graph[$dep][] = $seeder;
}
}
}
// Start with seeders that have no dependencies
$queue = [];
foreach ($inDegree as $seeder => $degree) {
if ($degree === 0) {
$queue[] = $seeder;
}
}
// Sort queue by priority (lower priority first - lower numbers run first)
usort($queue, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']);
$sorted = [];
$processed = 0;
while (! empty($queue)) {
$seeder = array_shift($queue);
$sorted[] = $seeder;
$processed++;
// Collect dependents that become ready
$ready = [];
foreach ($graph[$seeder] ?? [] as $dependent) {
$inDegree[$dependent]--;
if ($inDegree[$dependent] === 0) {
$ready[] = $dependent;
}
}
// Sort newly ready seeders by priority and add to queue
usort($ready, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']);
$queue = array_merge($ready, $queue);
// Re-sort the entire queue to maintain priority order
usort($queue, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']);
}
// Check for cycles
if ($processed < count($this->seeders)) {
$this->detectCycle($dependencies);
}
return $sorted;
}
/**
* Detect and report a cycle in the dependency graph.
*
* @param array<string, array<string>> $dependencies Adjacency list
*
* @throws CircularDependencyException
*/
private function detectCycle(array $dependencies): void
{
$visited = [];
$recStack = [];
$path = [];
foreach (array_keys($this->seeders) as $seeder) {
if ($this->dfsDetectCycle($seeder, $dependencies, $visited, $recStack, $path)) {
return; // Exception already thrown
}
}
// If we get here, there's a cycle but we couldn't find it
throw new CircularDependencyException(['Unknown cycle detected']);
}
/**
* DFS helper for cycle detection.
*
* @param string $seeder Current seeder being visited
* @param array<string, array<string>> $dependencies Adjacency list
* @param array<string, bool> $visited Fully processed nodes
* @param array<string, bool> $recStack Nodes in current recursion stack
* @param array<string> $path Current path for error reporting
*
* @throws CircularDependencyException If a cycle is detected
*/
private function dfsDetectCycle(
string $seeder,
array $dependencies,
array &$visited,
array &$recStack,
array &$path
): bool {
if (! isset($this->seeders[$seeder])) {
return false;
}
if (isset($recStack[$seeder])) {
throw CircularDependencyException::fromPath($path, $seeder);
}
if (isset($visited[$seeder])) {
return false;
}
$visited[$seeder] = true;
$recStack[$seeder] = true;
$path[] = $seeder;
foreach ($dependencies[$seeder] ?? [] as $dep) {
if ($this->dfsDetectCycle($dep, $dependencies, $visited, $recStack, $path)) {
return true;
}
}
array_pop($path);
unset($recStack[$seeder]);
return false;
}
}