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