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