packages/sql-catalog/src/Facade/Analyzer.php
1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Facade;
6
7use RuntimeException;
8use SqlCatalog\Core\Analysis\EntryFactory;
9use SqlCatalog\Core\Analysis\FunctionModel\Registry;
10use SqlCatalog\Core\Analysis\Interpreter;
11use SqlCatalog\Core\Catalog\AnalysisProblem;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Extension;
15use SqlCatalog\Core\Extension\ExtensionRegistry;
16use SqlCatalog\Core\Php\DeclaredGlobals;
17use SqlCatalog\Core\Php\ParsedFile;
18use SqlCatalog\Core\Php\ProgramIndexBuilder;
19use SqlCatalog\Core\Php\SourceParser;
20use SqlCatalog\Core\Php\SyntaxException;
21use SqlCatalog\Core\Source;
22use SqlCatalog\Core\Source\SourceFile;
23use SqlCatalog\Core\Source\SourceScanner;
24
25/**
26 * Reads PHP source and reports the SQL statements it can issue.
27 *
28 * The whole source tree is indexed before any of it is walked, so a statement
29 * assembled from a constant, an enum or a method in another file still resolves.
30 *
31 * @visibility public
32 * @example Cataloguing a query written inline
33 * $catalog = (new \SqlCatalog\Facade\Analyzer())->analyzeSource([
34 * 'users.php' => '<?php function all(PDO $db) { return $db->query("SELECT id FROM users"); }',
35 * ]);
36 * $catalog->entries()[0]->sql() // => 'SELECT id FROM users'
37 * $catalog->entries()[0]->tables // => ['users']
38 *
39 * @example Reporting a value spliced into the statement text
40 * $catalog = (new \SqlCatalog\Facade\Analyzer())->analyzeSource([
41 * 'unsafe.php' => '<?php function find(PDO $db) { return $db->query("SELECT * FROM users WHERE id = " . $_GET["id"]); }',
42 * ]);
43 * $catalog->entries()[0]->sql() // => 'SELECT * FROM users WHERE id = {$}'
44 * $catalog->entries()[0]->severity()->value // => 'high'
45 */
46final class Analyzer
47{
48 private Registry $functionModels;
49
50 private ExtensionRegistry $extensions;
51
52 private SourceParser $parser;
53
54 private ProgramIndexBuilder $indexes;
55
56 private EntryFactory $entries;
57
58 /**
59 * @param ExtensionRegistry|null $extensions The extensions available to the run, or null for the built-in ones
60 * @param Registry|null $functionModels The function interpretations, or null for the built-in models
61 */
62 public function __construct(?ExtensionRegistry $extensions = null, ?Registry $functionModels = null)
63 {
64 $this->functionModels = $functionModels ?? Registry::withBuiltins();
65 $this->extensions = $extensions ?? Builtins::extensions();
66 $this->parser = new SourceParser();
67 $this->indexes = new ProgramIndexBuilder();
68 $this->entries = new EntryFactory();
69 }
70
71 /**
72 * An independent analyzer with the function registrations from catalog settings.
73 *
74 * @throws RuntimeException When a configured function model cannot be resolved
75 */
76 public function withConfiguration(Configuration $configuration): self
77 {
78 $models = $configuration->apply($this->functionModels);
79
80 return new self($this->extensions, $models);
81 }
82
83 /**
84 * The extensions this analyzer can be asked for.
85 */
86 public function extensions(): ExtensionRegistry
87 {
88 return $this->extensions;
89 }
90
91 /**
92 * The statements the files under the given paths can issue.
93 *
94 * @param list<string> $paths Files and directories to read
95 * @param string $root The directory reported paths are relative to
96 * @param list<string> $excluded Patterns, matched against reported paths, to leave out
97 * @throws Source\SourceScanException When one of the paths cannot be read
98 * @throws Extension\UnknownExtensionException When the options name an extension that is not registered
99 */
100 public function analyzePaths(
101 array $paths,
102 ?AnalysisOptions $options = null,
103 string $root = '.',
104 array $excluded = [],
105 ): Catalog {
106 $sources = [];
107 foreach ((new SourceScanner($root, $excluded))->scan($paths) as $file) {
108 $sources[$file->path] = $file->code;
109 }
110
111 return $this->analyzeSource($sources, $options);
112 }
113
114 /**
115 * The statements the given sources can issue.
116 *
117 * @param array<string, string> $sources Source text, keyed by the path the catalog reports
118 * @throws Extension\UnknownExtensionException When the options name an extension that is not registered
119 */
120 public function analyzeSource(array $sources, ?AnalysisOptions $options = null): Catalog
121 {
122 $options ??= new AnalysisOptions();
123 $files = [];
124 $problems = [];
125 foreach ($sources as $path => $code) {
126 $parsed = $this->parse(new SourceFile($path, $code));
127 if ($parsed instanceof ParsedFile) {
128 $files[] = $parsed;
129 continue;
130 }
131 $problems[] = $parsed;
132 }
133
134 return new Catalog($this->entriesOf($files, $options), $problems, $sources);
135 }
136
137 /**
138 * The file parsed, or the problem that stopped it from being parsed.
139 */
140 public function parse(SourceFile $file): ParsedFile|AnalysisProblem
141 {
142 try {
143 return $this->parser->parse($file->path, $file->code);
144 } catch (SyntaxException $exception) {
145 return new AnalysisProblem($file->path, $exception->getMessage());
146 }
147 }
148
149 /**
150 * The catalog entries of a parsed source tree.
151 *
152 * @param list<ParsedFile> $files
153 * @return list<CatalogEntry>
154 * @throws Extension\UnknownExtensionException When the options name an extension that is not registered
155 */
156 public function entriesOf(array $files, AnalysisOptions $options): array
157 {
158 $sinks = $this->extensions->sinksOf($options->extensions);
159 $interpreter = new Interpreter(
160 $this->indexes->build($files),
161 $sinks,
162 $options->budget(),
163 new DeclaredGlobals($this->extensions->globalsOf($options->extensions)),
164 $this->functionModels,
165 $options->dialect,
166 $this->extensions->modelProvidersOf($options->extensions),
167 );
168
169 return $this->sortRecords($this->entries->build($interpreter->analyze($files)));
170 }
171
172 /**
173 * The entries in the order a report lists them.
174 *
175 * @param list<CatalogEntry> $entries
176 * @return list<CatalogEntry>
177 */
178 public function sortRecords(array $entries): array
179 {
180 return (new Catalog($entries))->sorted()->entries();
181 }
182}
183