Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
55 / 55
89.80% covered (warning)
89.80%
44 / 49
42.11% covered (danger)
42.11%
16 / 38
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Script
100.00% covered (success)
100.00%
55 / 55
89.80% covered (warning)
89.80%
44 / 49
42.11% covered (danger)
42.11%
16 / 38
100.00% covered (success)
100.00%
8 / 8
106.58
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invoke
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
10 / 10
71.43% covered (warning)
71.43%
5 / 7
100.00% covered (success)
100.00%
1 / 1
5.58
 evaluateTemplate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 buildTemplateContext
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 renderTemplateFile
100.00% covered (success)
100.00%
10 / 10
83.33% covered (warning)
83.33%
5 / 6
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
3.33
 renderTemplateSource
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
7 / 7
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
8.74
 prepareTemplateVars
100.00% covered (success)
100.00%
7 / 7
69.23% covered (warning)
69.23%
9 / 13
6.25% covered (danger)
6.25%
1 / 16
100.00% covered (success)
100.00%
1 / 1
1.82
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma;
6
7use Closure;
8use InvalidArgumentException;
9use RuntimeException;
10use Throwable;
11
12/** @api */
13class Script
14{
15    private const array RESERVED_TEMPLATE_PARAMETERS = [
16        'pdodriver' => true,
17    ];
18
19    protected Database $db;
20    protected string $script;
21    protected bool $isTemplate;
22    protected string $sourcePath;
23    /** @var (Closure(string, string): string)|null */
24    protected ?Closure $compile;
25
26    public function __construct(
27        Database $db,
28        LoadedScript $script,
29        bool $isTemplate,
30    ) {
31        $this->db = $db;
32        $this->script = $script->source;
33        $this->isTemplate = $isTemplate;
34        $this->sourcePath = $script->sourcePath;
35        $this->compile = $script->compile;
36    }
37
38    public function __invoke(mixed ...$args): Query
39    {
40        return $this->invoke(...$args);
41    }
42
43    public function invoke(mixed ...$argsArray): Query
44    {
45        $args = new Args($argsArray);
46
47        if ($this->isTemplate) {
48            // No arguments and an empty array classify as positional, but
49            // they mean "no parameters", which a template whose parameters
50            // are all optional must accept.
51            if ($args->type() === ArgType::Positional && $args->get() !== []) {
52                throw new InvalidArgumentException(
53                    'Template queries `*.tpql` allow named parameters only',
54                );
55            }
56
57            $script = $this->evaluateTemplate($this->script, $args);
58
59            if ($this->compile !== null) {
60                $script = ($this->compile)($script, $this->sourcePath);
61            }
62
63            // We need to wrap the result of the prepare call in an array
64            // to get back to the format of ...$argsArray.
65            $args = new Args([$this->prepareTemplateVars($script, $args)]);
66        } else {
67            $script = $this->script;
68        }
69
70        return new Query($this->db, $script, $args, $this->sourcePath);
71    }
72
73    protected function evaluateTemplate(string $template, Args $args): string
74    {
75        $context = $this->buildTemplateContext($args);
76
77        if ($template === $this->sourcePath) {
78            if (!is_file($this->sourcePath)) {
79                return '';
80            }
81
82            return $this->renderTemplateFile($this->sourcePath, $context);
83        }
84
85        return $this->renderTemplateSource($template, $context);
86    }
87
88    /**
89     * @return array<array-key, mixed>
90     */
91    protected function buildTemplateContext(Args $args): array
92    {
93        $named = $args->getNamed();
94
95        foreach (array_keys(self::RESERVED_TEMPLATE_PARAMETERS) as $name) {
96            if (array_key_exists($name, $named)) {
97                throw new InvalidArgumentException("Template parameter '{$name}' is reserved.");
98            }
99        }
100
101        return array_merge(
102            $named,
103            ['pdodriver' => $this->db->getPdoDriver()],
104        );
105    }
106
107    /**
108     * @param string $templatePath
109     * @param array<array-key, mixed> $context
110     */
111    protected function renderTemplateFile(string $templatePath, array $context): string
112    {
113        ob_start();
114
115        try {
116            (static function (string $__templatePath, array $__context): void {
117                extract($__context, EXTR_SKIP);
118
119                /** @psalm-suppress UnresolvableInclude The template path is only known at run time. */
120                require $__templatePath;
121            })($templatePath, $context);
122
123            $result = ob_get_clean();
124
125            return is_string($result) ? $result : '';
126        } catch (Throwable $e) {
127            ob_end_clean();
128
129            throw $e;
130        }
131    }
132
133    /**
134     * @param array<array-key, mixed> $context
135     */
136    protected function renderTemplateSource(string $template, array $context): string
137    {
138        $templatePath = tempnam(sys_get_temp_dir(), 'quma-tpql-');
139
140        if ($templatePath === false) {
141            // tempnam() failure depends on system temp-dir failure and is not usefully reproducible.
142            throw new RuntimeException('Could not create temporary template file'); // @codeCoverageIgnore
143        }
144
145        try {
146            if (file_put_contents($templatePath, $template) === false) {
147                // This would require making the just-created temp file unwritable between calls.
148                throw new RuntimeException('Could not write temporary template file'); // @codeCoverageIgnore
149            }
150
151            return $this->renderTemplateFile($templatePath, $context);
152        } finally {
153            if (is_file($templatePath)) {
154                unlink($templatePath);
155            }
156        }
157    }
158
159    /**
160     * Removes all keys from $params which are not present
161     * in the $script.
162     *
163     * PDO does not allow unused parameters.
164     *
165     * @return array<array-key, mixed>
166     */
167    protected function prepareTemplateVars(string $script, Args $args): array
168    {
169        // Remove PostgreSQL blocks
170        $cleaned = preg_replace(Query::PATTERN_BLOCK, ' ', $script);
171        // Remove strings
172        $cleaned = preg_replace(Query::PATTERN_STRING, ' ', $cleaned ?? '');
173        // Remove /* */ comments
174        $cleaned = preg_replace(Query::PATTERN_COMMENT_MULTI, ' ', $cleaned ?? '');
175        // Remove single line comments
176        $cleaned = preg_replace(Query::PATTERN_COMMENT_SINGLE, ' ', $cleaned ?? '');
177
178        // Match everything starting with : and a letter.
179        // Exclude multiple colons, like type casts (::text).
180        // Would not find a var if it is at the very beginning of script.
181        preg_match_all('/[^:]:[a-zA-Z][a-zA-Z0-9_]*/', $cleaned ?? '', $result);
182        // Each match starts with the character before the colon.
183        $names = array_map(static fn(string $match): string => substr($match, 2), $result[0]);
184
185        return array_intersect_key($args->getNamed(), array_flip($names));
186    }
187}