Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
98 / 98
89.06% covered (warning)
89.06%
57 / 64
36.21% covered (danger)
36.21%
21 / 58
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Placeholders
100.00% covered (success)
100.00%
98 / 98
89.06% covered (warning)
89.06%
57 / 64
36.21% covered (danger)
36.21%
21 / 58
100.00% covered (success)
100.00%
9 / 9
173.54
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
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
 compileSql
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
 normalizeConfig
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
12 / 12
60.00% covered (warning)
60.00%
6 / 10
100.00% covered (success)
100.00%
1 / 1
8.30
 normalizeValues
100.00% covered (success)
100.00%
18 / 18
88.89% covered (warning)
88.89%
16 / 18
20.00% covered (danger)
20.00%
4 / 20
100.00% covered (success)
100.00%
1 / 1
24.43
 substitute
100.00% covered (success)
100.00%
15 / 15
83.33% covered (warning)
83.33%
10 / 12
30.00% covered (danger)
30.00%
3 / 10
100.00% covered (success)
100.00%
1 / 1
6.09
 assertNoMalformedTokens
100.00% covered (success)
100.00%
7 / 7
83.33% covered (warning)
83.33%
10 / 12
20.00% covered (danger)
20.00%
2 / 10
100.00% covered (success)
100.00%
1 / 1
7.61
 unknownPlaceholder
100.00% covered (success)
100.00%
7 / 7
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
 malformedPlaceholder
100.00% covered (success)
100.00%
18 / 18
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
 location
100.00% covered (success)
100.00%
7 / 7
83.33% covered (warning)
83.33%
5 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma;
6
7use InvalidArgumentException;
8use RuntimeException;
9
10final class Placeholders
11{
12    public const string NAME_PATTERN = '[A-Za-z_][A-Za-z0-9_.:-]*';
13
14    /** @var non-empty-string */
15    private readonly string $tokenPattern;
16
17    /** @var non-empty-string */
18    private readonly string $tokenStartPattern;
19
20    /** @var array<string, string> */
21    private readonly array $values;
22
23    /** @param array<array-key, mixed> $config */
24    public function __construct(
25        private readonly string $driver,
26        array $config,
27        private readonly Delimiters $delimiters,
28    ) {
29        $open = preg_quote($this->delimiters->open, '/');
30        $close = preg_quote($this->delimiters->close, '/');
31        $this->tokenPattern = '/' . $open . '(' . self::NAME_PATTERN . ')' . $close . '/';
32        $this->tokenStartPattern = '/^' . $open . self::NAME_PATTERN . $close . '/';
33
34        $normalized = $this->normalizeConfig($config);
35        $this->values = array_replace(
36            $normalized['all'] ?? [],
37            $normalized[$this->driver] ?? [],
38        );
39    }
40
41    public function compileSql(string $source, string $path): string
42    {
43        return $this->substitute($source, $path);
44    }
45
46    /**
47     * @param array<array-key, mixed> $config
48     *
49     * @return array<string, array<string, string>>
50     */
51    private function normalizeConfig(array $config): array
52    {
53        $normalized = [];
54
55        foreach ($config as $scope => $values) {
56            if (!is_string($scope) || $scope === '') {
57                throw new InvalidArgumentException(
58                    'Static placeholder scopes must be non-empty strings.',
59                );
60            }
61
62            if ($scope === 'default') {
63                throw new InvalidArgumentException(
64                    "Static placeholders use the shared scope 'all'. Replace placeholders['default'] with placeholders['all'].",
65                );
66            }
67
68            if (!is_array($values)) {
69                throw new InvalidArgumentException(
70                    "Static placeholders for scope '{$scope}' must be an array of string values.",
71                );
72            }
73
74            $normalized[$scope] = $this->normalizeValues($scope, $values);
75        }
76
77        return $normalized;
78    }
79
80    /**
81     * @param array<array-key, mixed> $values
82     *
83     * @return array<string, string>
84     */
85    private function normalizeValues(string $scope, array $values): array
86    {
87        $normalized = [];
88
89        foreach ($values as $name => $value) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
91                throw new InvalidArgumentException(
92                    "Invalid static placeholder name in scope '{$scope}'. Names must match "
93                        . self::NAME_PATTERN
94                        . '.',
95                );
96            }
97
98            if (!is_string($value)) {
99                throw new InvalidArgumentException(
100                    "Static placeholder '{$name}' in scope '{$scope}' must be a string.",
101                );
102            }
103
104            if (str_contains($value, $this->delimiters->open)) {
105                throw new InvalidArgumentException(
106                    "Static placeholder '{$name}' in scope '{$scope}' must not contain another static placeholder.",
107                );
108            }
109
110            $normalized[$name] = $value;
111        }
112
113        return $normalized;
114    }
115
116    private function substitute(string $source, string $path): string
117    {
118        $this->assertNoMalformedTokens($source, $path);
119
120        $matches = [];
121        preg_match_all($this->tokenPattern, $source, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
122
123        $compiled = '';
124        $cursor = 0;
125
126        foreach ($matches as $match) {
127            $placeholder = $match[0][0];
128            $offset = $match[0][1];
129            $name = $match[1][0];
130
131            $compiled .= substr($source, $cursor, $offset - $cursor);
132
133            if (!array_key_exists($name, $this->values)) {
134                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $offset);
135            }
136
137            $compiled .= $this->values[$name];
138            $cursor = $offset + strlen($placeholder);
139        }
140
141        return $compiled . substr($source, $cursor);
142    }
143
144    private function assertNoMalformedTokens(string $source, string $path): void
145    {
146        $offset = 0;
147
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
149            $matches = [];
150            $tail = substr($source, $position);
151
152            if (preg_match($this->tokenStartPattern, $tail, $matches) !== 1) {
153                throw $this->malformedPlaceholder($path, $source, $position);
154            }
155
156            $offset = $position + strlen($matches[0]);
157        }
158    }
159
160    private function unknownPlaceholder(
161        string $placeholder,
162        string $name,
163        string $path,
164        string $source,
165        int $offset,
166    ): RuntimeException {
167        [$line, $column] = $this->location($source, $offset);
168
169        return new RuntimeException(
170            "Unknown static placeholder {$placeholder} in {$path}:{$line}:{$column} for driver \"{$this->driver}\".\n"
171                . "No value was configured for \"{$name}\".\n"
172                . "Add placeholders['all']['{$name}'] or placeholders['{$this->driver}']['{$name}'].\n"
173                . 'Static placeholders are raw SQL fragments. Use them only for trusted configuration, never for user input.',
174        );
175    }
176
177    private function malformedPlaceholder(string $path, string $source, int $offset): RuntimeException
178    {
179        [$line, $column] = $this->location($source, $offset);
180
181        return new RuntimeException(
182            "Malformed static placeholder in {$path}:{$line}:{$column}.\n"
183                . 'Expected '
184                . $this->delimiters->token('name')
185                . ' where name matches: '
186                . self::NAME_PATTERN
187                . ".\n"
188                . 'Examples: '
189                . $this->delimiters->token('prefix')
190                . ', '
191                . $this->delimiters->token('schema.name')
192                . ', '
193                . $this->delimiters->token('tenant-prefix')
194                . ', '
195                . $this->delimiters->token('cms:prefix')
196                . '.',
197        );
198    }
199
200    /** @return array{0: int, 1: int} */
201    private function location(string $source, int $offset): array
202    {
203        $before = substr($source, 0, $offset);
204        $line = substr_count($before, "\n") + 1;
205        $lineStart = strrpos($before, "\n");
206        $column = $offset + 1;
207
208        if ($lineStart !== false) {
209            $column = $offset - $lineStart;
210        }
211
212        return [$line, $column];
213    }
214}

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.

Placeholders->__construct
25        private readonly string $driver,
26        array $config,
27        private readonly Delimiters $delimiters,
28    ) {
29        $open = preg_quote($this->delimiters->open, '/');
30        $close = preg_quote($this->delimiters->close, '/');
31        $this->tokenPattern = '/' . $open . '(' . self::NAME_PATTERN . ')' . $close . '/';
32        $this->tokenStartPattern = '/^' . $open . self::NAME_PATTERN . $close . '/';
33
34        $normalized = $this->normalizeConfig($config);
35        $this->values = array_replace(
36            $normalized['all'] ?? [],
37            $normalized[$this->driver] ?? [],
38        );
39    }
Placeholders->assertNoMalformedTokens
144    private function assertNoMalformedTokens(string $source, string $path): void
145    {
146        $offset = 0;
147
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
149            $matches = [];
150            $tail = substr($source, $position);
150            $tail = substr($source, $position);
150            $tail = substr($source, $position);
150            $tail = substr($source, $position);
151
152            if (preg_match($this->tokenStartPattern, $tail, $matches) !== 1) {
153                throw $this->malformedPlaceholder($path, $source, $position);
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
149            $matches = [];
150            $tail = substr($source, $position);
151
152            if (preg_match($this->tokenStartPattern, $tail, $matches) !== 1) {
153                throw $this->malformedPlaceholder($path, $source, $position);
154            }
155
156            $offset = $position + strlen($matches[0]);
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
148        while (($position = strpos($source, $this->delimiters->open, $offset)) !== false) {
158    }
Placeholders->compileSql
41    public function compileSql(string $source, string $path): string
42    {
43        return $this->substitute($source, $path);
44    }
Placeholders->location
201    private function location(string $source, int $offset): array
202    {
203        $before = substr($source, 0, $offset);
203        $before = substr($source, 0, $offset);
203        $before = substr($source, 0, $offset);
203        $before = substr($source, 0, $offset);
204        $line = substr_count($before, "\n") + 1;
205        $lineStart = strrpos($before, "\n");
206        $column = $offset + 1;
207
208        if ($lineStart !== false) {
209            $column = $offset - $lineStart;
210        }
211
212        return [$line, $column];
212        return [$line, $column];
213    }
Placeholders->malformedPlaceholder
177    private function malformedPlaceholder(string $path, string $source, int $offset): RuntimeException
178    {
179        [$line, $column] = $this->location($source, $offset);
180
181        return new RuntimeException(
182            "Malformed static placeholder in {$path}:{$line}:{$column}.\n"
183                . 'Expected '
184                . $this->delimiters->token('name')
185                . ' where name matches: '
186                . self::NAME_PATTERN
187                . ".\n"
188                . 'Examples: '
189                . $this->delimiters->token('prefix')
190                . ', '
191                . $this->delimiters->token('schema.name')
192                . ', '
193                . $this->delimiters->token('tenant-prefix')
194                . ', '
195                . $this->delimiters->token('cms:prefix')
196                . '.',
197        );
198    }
Placeholders->normalizeConfig
51    private function normalizeConfig(array $config): array
52    {
53        $normalized = [];
54
55        foreach ($config as $scope => $values) {
55        foreach ($config as $scope => $values) {
55        foreach ($config as $scope => $values) {
56            if (!is_string($scope) || $scope === '') {
56            if (!is_string($scope) || $scope === '') {
56            if (!is_string($scope) || $scope === '') {
57                throw new InvalidArgumentException(
58                    'Static placeholder scopes must be non-empty strings.',
62            if ($scope === 'default') {
63                throw new InvalidArgumentException(
64                    "Static placeholders use the shared scope 'all'. Replace placeholders['default'] with placeholders['all'].",
68            if (!is_array($values)) {
69                throw new InvalidArgumentException(
70                    "Static placeholders for scope '{$scope}' must be an array of string values.",
55        foreach ($config as $scope => $values) {
56            if (!is_string($scope) || $scope === '') {
57                throw new InvalidArgumentException(
58                    'Static placeholder scopes must be non-empty strings.',
59                );
60            }
61
62            if ($scope === 'default') {
63                throw new InvalidArgumentException(
64                    "Static placeholders use the shared scope 'all'. Replace placeholders['default'] with placeholders['all'].",
65                );
66            }
67
68            if (!is_array($values)) {
69                throw new InvalidArgumentException(
70                    "Static placeholders for scope '{$scope}' must be an array of string values.",
71                );
72            }
73
74            $normalized[$scope] = $this->normalizeValues($scope, $values);
55        foreach ($config as $scope => $values) {
56            if (!is_string($scope) || $scope === '') {
57                throw new InvalidArgumentException(
58                    'Static placeholder scopes must be non-empty strings.',
59                );
60            }
61
62            if ($scope === 'default') {
63                throw new InvalidArgumentException(
64                    "Static placeholders use the shared scope 'all'. Replace placeholders['default'] with placeholders['all'].",
65                );
66            }
67
68            if (!is_array($values)) {
69                throw new InvalidArgumentException(
70                    "Static placeholders for scope '{$scope}' must be an array of string values.",
71                );
72            }
73
74            $normalized[$scope] = $this->normalizeValues($scope, $values);
75        }
76
77        return $normalized;
78    }
Placeholders->normalizeValues
85    private function normalizeValues(string $scope, array $values): array
86    {
87        $normalized = [];
88
89        foreach ($values as $name => $value) {
89        foreach ($values as $name => $value) {
89        foreach ($values as $name => $value) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
91                throw new InvalidArgumentException(
92                    "Invalid static placeholder name in scope '{$scope}'. Names must match "
93                        . self::NAME_PATTERN
94                        . '.',
98            if (!is_string($value)) {
99                throw new InvalidArgumentException(
100                    "Static placeholder '{$name}' in scope '{$scope}' must be a string.",
104            if (str_contains($value, $this->delimiters->open)) {
104            if (str_contains($value, $this->delimiters->open)) {
104            if (str_contains($value, $this->delimiters->open)) {
104            if (str_contains($value, $this->delimiters->open)) {
105                throw new InvalidArgumentException(
106                    "Static placeholder '{$name}' in scope '{$scope}' must not contain another static placeholder.",
89        foreach ($values as $name => $value) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
91                throw new InvalidArgumentException(
92                    "Invalid static placeholder name in scope '{$scope}'. Names must match "
93                        . self::NAME_PATTERN
94                        . '.',
95                );
96            }
97
98            if (!is_string($value)) {
99                throw new InvalidArgumentException(
100                    "Static placeholder '{$name}' in scope '{$scope}' must be a string.",
101                );
102            }
103
104            if (str_contains($value, $this->delimiters->open)) {
105                throw new InvalidArgumentException(
106                    "Static placeholder '{$name}' in scope '{$scope}' must not contain another static placeholder.",
107                );
108            }
109
110            $normalized[$name] = $value;
89        foreach ($values as $name => $value) {
90            if (!is_string($name) || !preg_match('/^' . self::NAME_PATTERN . '$/', $name)) {
91                throw new InvalidArgumentException(
92                    "Invalid static placeholder name in scope '{$scope}'. Names must match "
93                        . self::NAME_PATTERN
94                        . '.',
95                );
96            }
97
98            if (!is_string($value)) {
99                throw new InvalidArgumentException(
100                    "Static placeholder '{$name}' in scope '{$scope}' must be a string.",
101                );
102            }
103
104            if (str_contains($value, $this->delimiters->open)) {
105                throw new InvalidArgumentException(
106                    "Static placeholder '{$name}' in scope '{$scope}' must not contain another static placeholder.",
107                );
108            }
109
110            $normalized[$name] = $value;
111        }
112
113        return $normalized;
114    }
Placeholders->substitute
116    private function substitute(string $source, string $path): string
117    {
118        $this->assertNoMalformedTokens($source, $path);
119
120        $matches = [];
121        preg_match_all($this->tokenPattern, $source, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
122
123        $compiled = '';
124        $cursor = 0;
125
126        foreach ($matches as $match) {
126        foreach ($matches as $match) {
127            $placeholder = $match[0][0];
128            $offset = $match[0][1];
129            $name = $match[1][0];
130
131            $compiled .= substr($source, $cursor, $offset - $cursor);
131            $compiled .= substr($source, $cursor, $offset - $cursor);
131            $compiled .= substr($source, $cursor, $offset - $cursor);
131            $compiled .= substr($source, $cursor, $offset - $cursor);
132
133            if (!array_key_exists($name, $this->values)) {
134                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $offset);
126        foreach ($matches as $match) {
127            $placeholder = $match[0][0];
128            $offset = $match[0][1];
129            $name = $match[1][0];
130
131            $compiled .= substr($source, $cursor, $offset - $cursor);
132
133            if (!array_key_exists($name, $this->values)) {
134                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $offset);
135            }
136
137            $compiled .= $this->values[$name];
126        foreach ($matches as $match) {
127            $placeholder = $match[0][0];
128            $offset = $match[0][1];
129            $name = $match[1][0];
130
131            $compiled .= substr($source, $cursor, $offset - $cursor);
132
133            if (!array_key_exists($name, $this->values)) {
134                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $offset);
135            }
136
137            $compiled .= $this->values[$name];
138            $cursor = $offset + strlen($placeholder);
139        }
140
141        return $compiled . substr($source, $cursor);
141        return $compiled . substr($source, $cursor);
141        return $compiled . substr($source, $cursor);
141        return $compiled . substr($source, $cursor);
142    }
Placeholders->unknownPlaceholder
161        string $placeholder,
162        string $name,
163        string $path,
164        string $source,
165        int $offset,
166    ): RuntimeException {
167        [$line, $column] = $this->location($source, $offset);
168
169        return new RuntimeException(
170            "Unknown static placeholder {$placeholder} in {$path}:{$line}:{$column} for driver \"{$this->driver}\".\n"
171                . "No value was configured for \"{$name}\".\n"
172                . "Add placeholders['all']['{$name}'] or placeholders['{$this->driver}']['{$name}'].\n"
173                . 'Static placeholders are raw SQL fragments. Use them only for trusted configuration, never for user input.',
174        );
175    }