Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
70 / 70
90.00% covered (success)
90.00%
54 / 60
3.22% covered (danger)
3.22%
14 / 435
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Script
100.00% covered (success)
100.00%
70 / 70
90.00% covered (success)
90.00%
54 / 60
3.22% covered (danger)
3.22%
14 / 435
100.00% covered (success)
100.00%
8 / 8
591.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%
8 / 8
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
4.25
 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
0.00% covered (danger)
0.00%
0 / 3
100.00% covered (success)
100.00%
1 / 1
4
 prepareTemplateVars
100.00% covered (success)
100.00%
22 / 22
80.77% covered (warning)
80.77%
21 / 26
0.48% covered (danger)
0.48%
2 / 416
100.00% covered (success)
100.00%
1 / 1
41.48
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\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            if ($args->type() === ArgType::Positional) {
49                throw new InvalidArgumentException(
50                    'Template queries `*.tpql` allow named parameters only',
51                );
52            }
53
54            $script = $this->evaluateTemplate($this->script, $args);
55
56            if ($this->compile !== null) {
57                $script = ($this->compile)($script, $this->sourcePath);
58            }
59
60            // We need to wrap the result of the prepare call in an array
61            // to get back to the format of ...$argsArray.
62            $args = new Args([$this->prepareTemplateVars($script, $args)]);
63        } else {
64            $script = $this->script;
65        }
66
67        return new Query($this->db, $script, $args, $this->sourcePath);
68    }
69
70    protected function evaluateTemplate(string $template, Args $args): string
71    {
72        $context = $this->buildTemplateContext($args);
73
74        if ($template === $this->sourcePath) {
75            if (!is_file($this->sourcePath)) {
76                return '';
77            }
78
79            return $this->renderTemplateFile($this->sourcePath, $context);
80        }
81
82        return $this->renderTemplateSource($template, $context);
83    }
84
85    /**
86     * @return array<array-key, mixed>
87     */
88    protected function buildTemplateContext(Args $args): array
89    {
90        $named = $args->getNamed();
91
92        foreach (array_keys(self::RESERVED_TEMPLATE_PARAMETERS) as $name) {
93            if (array_key_exists($name, $named)) {
94                throw new InvalidArgumentException("Template parameter '{$name}' is reserved.");
95            }
96        }
97
98        return array_merge(
99            $named,
100            ['pdodriver' => $this->db->getPdoDriver()],
101        );
102    }
103
104    /**
105     * @param string $templatePath
106     * @param array<array-key, mixed> $context
107     */
108    protected function renderTemplateFile(string $templatePath, array $context): string
109    {
110        ob_start();
111
112        try {
113            (static function (string $__templatePath, array $__context): void {
114                extract($__context, EXTR_SKIP);
115
116                /** @psalm-suppress UnresolvableInclude */
117                require $__templatePath;
118            })($templatePath, $context);
119
120            $result = ob_get_clean();
121
122            return is_string($result) ? $result : '';
123        } catch (Throwable $e) {
124            ob_end_clean();
125
126            throw $e;
127        }
128    }
129
130    /**
131     * @param array<array-key, mixed> $context
132     */
133    protected function renderTemplateSource(string $template, array $context): string
134    {
135        $templatePath = tempnam(sys_get_temp_dir(), 'quma-tpql-');
136
137        if ($templatePath === false) {
138            // tempnam() failure depends on system temp-dir failure and is not usefully reproducible.
139            throw new RuntimeException('Could not create temporary template file'); // @codeCoverageIgnore
140        }
141
142        try {
143            if (file_put_contents($templatePath, $template) === false) {
144                // This would require making the just-created temp file unwritable between calls.
145                throw new RuntimeException('Could not write temporary template file'); // @codeCoverageIgnore
146            }
147
148            return $this->renderTemplateFile($templatePath, $context);
149        } finally {
150            if (is_file($templatePath)) {
151                unlink($templatePath);
152            }
153        }
154    }
155
156    /**
157     * Removes all keys from $params which are not present
158     * in the $script.
159     *
160     * PDO does not allow unused parameters.
161     */
162    protected function prepareTemplateVars(string $script, Args $args): array
163    {
164        // Remove PostgreSQL blocks
165        $cleaned = preg_replace(Query::PATTERN_BLOCK, ' ', $script);
166        // Remove strings
167        $cleaned = preg_replace(Query::PATTERN_STRING, ' ', $cleaned ?? '');
168        // Remove /* */ comments
169        $cleaned = preg_replace(Query::PATTERN_COMMENT_MULTI, ' ', $cleaned ?? '');
170        // Remove single line comments
171        $cleaned = preg_replace(Query::PATTERN_COMMENT_SINGLE, ' ', $cleaned ?? '');
172
173        $newArgs = [];
174
175        // Match everything starting with : and a letter.
176        // Exclude multiple colons, like type casts (::text).
177        // Would not find a var if it is at the very beginning of script.
178        $matches = preg_match_all(
179            '/[^:]:[a-zA-Z][a-zA-Z0-9_]*/',
180            $cleaned ?? '',
181            $result,
182            PREG_PATTERN_ORDER,
183        );
184
185        if ($matches !== false && $matches > 0) {
186            $argsArray = $args->getNamed();
187            $namedKeys = [];
188            $newArgs = [];
189
190            foreach (array_unique($result[0]) as $arg) {
191                $a = substr($arg, 2);
192
193                if ($a !== '') {
194                    $namedKeys[$a] = true;
195                }
196            }
197
198            if (count($namedKeys) > 0) {
199                $newArgs = array_intersect_key($argsArray, $namedKeys);
200            }
201        }
202
203        return $newArgs;
204    }
205}