packages/sql-faker/src/Grammar/Resource/SqlVersionRegistry.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlFaker\Grammar\Resource;
6
7use RuntimeException;
8
9/**
10 * Resolves supported releases and their generated grammar AST paths.
11 * Lexical definitions for these same releases are reviewed PHP declarations.
12 * @visibility parent
13 */
14final class SqlVersionRegistry
15{
16    /**
17     * Answers the artifacts generated for one release, defaulting to the newest the dialect ships.
18     *
19     * @param string $dialect Dialect the caller generates SQL for
20     * @param string|null $version Release to resolve, or null for the dialect default
21     *
22     * @return SqlVersion Artifacts committed for that release
23     *
24     * @throws RuntimeException When the dialect or the release is not one this package ships
25     */
26    public function resolve(string $dialect, ?string $version = null): SqlVersion
27    {
28        $registry = $this->entries();
29        $dialectDefinition = $registry[$dialect] ?? null;
30        if ($dialectDefinition === null) {
31            throw new RuntimeException("Unknown SQL dialect: {$dialect}");
32        }
33        $version ??= $dialectDefinition['default'];
34        $resources = $dialectDefinition['versions'][$version] ?? null;
35        if ($resources === null) {
36            throw new RuntimeException("Unsupported {$dialect} version: {$version}");
37        }
38
39        return new SqlVersion(
40            $dialect,
41            $version,
42            $this->path($resources['ast']),
43        );
44    }
45
46    /**
47     * Answers every release one dialect ships, oldest first.
48     *
49     * @param string $dialect Dialect to enumerate
50     *
51     * @return list<string> Release names in the order they were registered
52     *
53     * @throws RuntimeException When the dialect is not one this package ships
54     */
55    public function names(string $dialect): array
56    {
57        $registry = $this->entries();
58        if (!isset($registry[$dialect])) {
59            throw new RuntimeException("Unknown SQL dialect: {$dialect}");
60        }
61
62        return array_keys($registry[$dialect]['versions']);
63    }
64
65    /**
66     * Answers every release of every dialect, so a caller can act on all of them.
67     *
68     * @return list<SqlVersion> Artifacts committed for each registered release
69     *
70     * @throws RuntimeException When the record names a release it does not describe
71     */
72    public function all(): array
73    {
74        $versions = [];
75        foreach (array_keys($this->entries()) as $dialect) {
76            foreach ($this->names($dialect) as $version) {
77                $versions[] = $this->resolve($dialect, $version);
78            }
79        }
80
81        return $versions;
82    }
83
84    /**
85     * Reads the record of which releases were generated.
86     *
87     * @return array<string, array{default: string, versions: array<string, array{ast: string}>}> Releases by dialect
88     */
89    public function entries(): array
90    {
91        /**
92         * @var array<string, array{default: string, versions: array<string, array{ast: string}>}> $registry
93         */
94        $registry = require $this->directory() . '/version.php';
95
96        return $registry;
97    }
98
99    /**
100     * Resolves an artifact path recorded in the registry against the resource directory.
101     *
102     * The record is data the package reads at runtime, so a path that escapes
103     * the resource directory would let it load a file the package never
104     * generated. Only relative paths that stay inside are accepted.
105     *
106     * @param string $relativePath Path as the registry records it
107     *
108     * @return string Absolute path of the artifact
109     *
110     * @throws RuntimeException When the path is absolute, empty, or climbs out of the resource directory
111     */
112    public function path(string $relativePath): string
113    {
114        if ($relativePath === '' || str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
115            throw new RuntimeException("Invalid SQL version resource path: {$relativePath}");
116        }
117
118        return $this->directory() . '/' . $relativePath;
119    }
120
121    /**
122     * Answers the directory generated artifacts are committed under.
123     *
124     * @return string Absolute path of the resource directory
125     */
126    public function directory(): string
127    {
128        return dirname(__DIR__, 3) . '/resources';
129    }
130}
131