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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Script->__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    }
Script->__invoke
38    public function __invoke(mixed ...$args): Query
39    {
40        return $this->invoke(...$args);
41    }
Script->buildTemplateContext
91    protected function buildTemplateContext(Args $args): array
92    {
93        $named = $args->getNamed();
94
95        foreach (array_keys(self::RESERVED_TEMPLATE_PARAMETERS) as $name) {
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.");
95        foreach (array_keys(self::RESERVED_TEMPLATE_PARAMETERS) as $name) {
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    }
Script->evaluateTemplate
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 '';
82            return $this->renderTemplateFile($this->sourcePath, $context);
85        return $this->renderTemplateSource($template, $context);
86    }
Script->invoke
43    public function invoke(mixed ...$argsArray): Query
44    {
45        $args = new Args($argsArray);
46
47        if ($this->isTemplate) {
51            if ($args->type() === ArgType::Positional && $args->get() !== []) {
51            if ($args->type() === ArgType::Positional && $args->get() !== []) {
51            if ($args->type() === ArgType::Positional && $args->get() !== []) {
52                throw new InvalidArgumentException(
53                    'Template queries `*.tpql` allow named parameters only',
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)]);
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)]);
67            $script = $this->script;
68        }
69
70        return new Query($this->db, $script, $args, $this->sourcePath);
70        return new Query($this->db, $script, $args, $this->sourcePath);
71    }
Script->prepareTemplateVars
167    protected function prepareTemplateVars(string $script, Args $args): array
168    {
169        // Remove PostgreSQL blocks
170        $cleaned = preg_replace(Query::PATTERN_BLOCK, ' ', $script);
170        $cleaned = preg_replace(Query::PATTERN_BLOCK, ' ', $script);
170        $cleaned = preg_replace(Query::PATTERN_BLOCK, ' ', $script);
170        $cleaned = preg_replace(Query::PATTERN_BLOCK, ' ', $script);
171        // Remove strings
172        $cleaned = preg_replace(Query::PATTERN_STRING, ' ', $cleaned ?? '');
172        $cleaned = preg_replace(Query::PATTERN_STRING, ' ', $cleaned ?? '');
172        $cleaned = preg_replace(Query::PATTERN_STRING, ' ', $cleaned ?? '');
172        $cleaned = preg_replace(Query::PATTERN_STRING, ' ', $cleaned ?? '');
173        // Remove /* */ comments
174        $cleaned = preg_replace(Query::PATTERN_COMMENT_MULTI, ' ', $cleaned ?? '');
174        $cleaned = preg_replace(Query::PATTERN_COMMENT_MULTI, ' ', $cleaned ?? '');
174        $cleaned = preg_replace(Query::PATTERN_COMMENT_MULTI, ' ', $cleaned ?? '');
174        $cleaned = preg_replace(Query::PATTERN_COMMENT_MULTI, ' ', $cleaned ?? '');
175        // Remove single line comments
176        $cleaned = preg_replace(Query::PATTERN_COMMENT_SINGLE, ' ', $cleaned ?? '');
176        $cleaned = preg_replace(Query::PATTERN_COMMENT_SINGLE, ' ', $cleaned ?? '');
176        $cleaned = preg_replace(Query::PATTERN_COMMENT_SINGLE, ' ', $cleaned ?? '');
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    }
Script->renderTemplateFile
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 : '';
125            return is_string($result) ? $result : '';
125            return is_string($result) ? $result : '';
125            return is_string($result) ? $result : '';
126        } catch (Throwable $e) {
127            ob_end_clean();
128
129            throw $e;
130        }
131    }
Script->renderTemplateSource
136    protected function renderTemplateSource(string $template, array $context): string
137    {
138        $templatePath = tempnam(sys_get_temp_dir(), 'quma-tpql-');
139
140        if ($templatePath === false) {
145        try {
146            if (file_put_contents($templatePath, $template) === false) {
151            return $this->renderTemplateFile($templatePath, $context);
151            return $this->renderTemplateFile($templatePath, $context);
153            if (is_file($templatePath)) {
154                unlink($templatePath);
154                unlink($templatePath);
155            }
156        }
157    }
{closure:/workspace/celema/quma/src/Script.php:116-121}
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);
{closure:/workspace/celema/quma/src/Script.php:183-183}
183        $names = array_map(static fn(string $match): string => substr($match, 2), $result[0]);
183        $names = array_map(static fn(string $match): string => substr($match, 2), $result[0]);
183        $names = array_map(static fn(string $match): string => substr($match, 2), $result[0]);
183        $names = array_map(static fn(string $match): string => substr($match, 2), $result[0]);