Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
105 / 105
89.71% covered (warning)
89.71%
61 / 68
30.00% covered (danger)
30.00%
21 / 70
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Placeholders
100.00% covered (success)
100.00%
105 / 105
89.71% covered (warning)
89.71%
61 / 68
30.00% covered (danger)
30.00%
21 / 70
100.00% covered (success)
100.00%
9 / 9
257.87
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
 substituteFragment
100.00% covered (success)
100.00%
22 / 22
87.50% covered (warning)
87.50%
14 / 16
13.64% covered (danger)
13.64%
3 / 22
100.00% covered (success)
100.00%
1 / 1
21.10
 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 Celemas\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->substituteFragment($source, $path, $source, 0);
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 substituteFragment(
117        string $fragment,
118        string $path,
119        string $source,
120        int $baseOffset,
121    ): string {
122        $this->assertNoMalformedTokens($fragment, $path, $source, $baseOffset);
123
124        $matches = [];
125        $result = preg_match_all(
126            $this->tokenPattern,
127            $fragment,
128            $matches,
129            PREG_SET_ORDER | PREG_OFFSET_CAPTURE,
130        );
131
132        if ($result === false || $result === 0) {
133            return $fragment;
134        }
135
136        $compiled = '';
137        $cursor = 0;
138
139        foreach ($matches as $match) {
140            $placeholder = $match[0][0];
141            $offset = $match[0][1];
142            $name = $match[1][0];
143
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
145
146            if (!array_key_exists($name, $this->values)) {
147                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $baseOffset + $offset);
148            }
149
150            $compiled .= $this->values[$name];
151            $cursor = $offset + strlen($placeholder);
152        }
153
154        return $compiled . substr($fragment, $cursor);
155    }
156
157    private function assertNoMalformedTokens(
158        string $fragment,
159        string $path,
160        string $source,
161        int $baseOffset,
162    ): void {
163        $offset = 0;
164
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
166            $matches = [];
167            $tail = substr($fragment, $position);
168
169            if (preg_match($this->tokenStartPattern, $tail, $matches) !== 1) {
170                throw $this->malformedPlaceholder($path, $source, $baseOffset + $position);
171            }
172
173            $offset = $position + strlen($matches[0]);
174        }
175    }
176
177    private function unknownPlaceholder(
178        string $placeholder,
179        string $name,
180        string $path,
181        string $source,
182        int $offset,
183    ): RuntimeException {
184        [$line, $column] = $this->location($source, $offset);
185
186        return new RuntimeException(
187            "Unknown static placeholder {$placeholder} in {$path}:{$line}:{$column} for driver \"{$this->driver}\".\n"
188            . "No value was configured for \"{$name}\".\n"
189            . "Add placeholders['all']['{$name}'] or placeholders['{$this->driver}']['{$name}'].\n"
190            . 'Static placeholders are raw SQL fragments. Use them only for trusted configuration, never for user input.',
191        );
192    }
193
194    private function malformedPlaceholder(string $path, string $source, int $offset): RuntimeException
195    {
196        [$line, $column] = $this->location($source, $offset);
197
198        return new RuntimeException(
199            "Malformed static placeholder in {$path}:{$line}:{$column}.\n"
200            . 'Expected '
201            . $this->delimiters->token('name')
202            . ' where name matches: '
203            . self::NAME_PATTERN
204            . ".\n"
205            . 'Examples: '
206            . $this->delimiters->token('prefix')
207            . ', '
208            . $this->delimiters->token('schema.name')
209            . ', '
210            . $this->delimiters->token('tenant-prefix')
211            . ', '
212            . $this->delimiters->token('cms:prefix')
213            . '.',
214        );
215    }
216
217    /** @return array{0: int, 1: int} */
218    private function location(string $source, int $offset): array
219    {
220        $before = substr($source, 0, $offset);
221        $line = substr_count($before, "\n") + 1;
222        $lineStart = strrpos($before, "\n");
223        $column = $offset + 1;
224
225        if ($lineStart !== false) {
226            $column = $offset - $lineStart;
227        }
228
229        return [$line, $column];
230    }
231}

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
158        string $fragment,
159        string $path,
160        string $source,
161        int $baseOffset,
162    ): void {
163        $offset = 0;
164
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
166            $matches = [];
167            $tail = substr($fragment, $position);
167            $tail = substr($fragment, $position);
167            $tail = substr($fragment, $position);
167            $tail = substr($fragment, $position);
168
169            if (preg_match($this->tokenStartPattern, $tail, $matches) !== 1) {
170                throw $this->malformedPlaceholder($path, $source, $baseOffset + $position);
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
166            $matches = [];
167            $tail = substr($fragment, $position);
168
169            if (preg_match($this->tokenStartPattern, $tail, $matches) !== 1) {
170                throw $this->malformedPlaceholder($path, $source, $baseOffset + $position);
171            }
172
173            $offset = $position + strlen($matches[0]);
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
165        while (($position = strpos($fragment, $this->delimiters->open, $offset)) !== false) {
175    }
Placeholders->compileSql
41    public function compileSql(string $source, string $path): string
42    {
43        return $this->substituteFragment($source, $path, $source, 0);
44    }
Placeholders->location
218    private function location(string $source, int $offset): array
219    {
220        $before = substr($source, 0, $offset);
220        $before = substr($source, 0, $offset);
220        $before = substr($source, 0, $offset);
220        $before = substr($source, 0, $offset);
221        $line = substr_count($before, "\n") + 1;
222        $lineStart = strrpos($before, "\n");
223        $column = $offset + 1;
224
225        if ($lineStart !== false) {
226            $column = $offset - $lineStart;
227        }
228
229        return [$line, $column];
229        return [$line, $column];
230    }
Placeholders->malformedPlaceholder
194    private function malformedPlaceholder(string $path, string $source, int $offset): RuntimeException
195    {
196        [$line, $column] = $this->location($source, $offset);
197
198        return new RuntimeException(
199            "Malformed static placeholder in {$path}:{$line}:{$column}.\n"
200            . 'Expected '
201            . $this->delimiters->token('name')
202            . ' where name matches: '
203            . self::NAME_PATTERN
204            . ".\n"
205            . 'Examples: '
206            . $this->delimiters->token('prefix')
207            . ', '
208            . $this->delimiters->token('schema.name')
209            . ', '
210            . $this->delimiters->token('tenant-prefix')
211            . ', '
212            . $this->delimiters->token('cms:prefix')
213            . '.',
214        );
215    }
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->substituteFragment
117        string $fragment,
118        string $path,
119        string $source,
120        int $baseOffset,
121    ): string {
122        $this->assertNoMalformedTokens($fragment, $path, $source, $baseOffset);
123
124        $matches = [];
125        $result = preg_match_all(
126            $this->tokenPattern,
127            $fragment,
128            $matches,
129            PREG_SET_ORDER | PREG_OFFSET_CAPTURE,
130        );
131
132        if ($result === false || $result === 0) {
132        if ($result === false || $result === 0) {
132        if ($result === false || $result === 0) {
133            return $fragment;
136        $compiled = '';
137        $cursor = 0;
138
139        foreach ($matches as $match) {
139        foreach ($matches as $match) {
140            $placeholder = $match[0][0];
141            $offset = $match[0][1];
142            $name = $match[1][0];
143
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
145
146            if (!array_key_exists($name, $this->values)) {
147                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $baseOffset + $offset);
139        foreach ($matches as $match) {
140            $placeholder = $match[0][0];
141            $offset = $match[0][1];
142            $name = $match[1][0];
143
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
145
146            if (!array_key_exists($name, $this->values)) {
147                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $baseOffset + $offset);
148            }
149
150            $compiled .= $this->values[$name];
139        foreach ($matches as $match) {
140            $placeholder = $match[0][0];
141            $offset = $match[0][1];
142            $name = $match[1][0];
143
144            $compiled .= substr($fragment, $cursor, $offset - $cursor);
145
146            if (!array_key_exists($name, $this->values)) {
147                throw $this->unknownPlaceholder($placeholder, $name, $path, $source, $baseOffset + $offset);
148            }
149
150            $compiled .= $this->values[$name];
151            $cursor = $offset + strlen($placeholder);
152        }
153
154        return $compiled . substr($fragment, $cursor);
154        return $compiled . substr($fragment, $cursor);
154        return $compiled . substr($fragment, $cursor);
154        return $compiled . substr($fragment, $cursor);
155    }
Placeholders->unknownPlaceholder
178        string $placeholder,
179        string $name,
180        string $path,
181        string $source,
182        int $offset,
183    ): RuntimeException {
184        [$line, $column] = $this->location($source, $offset);
185
186        return new RuntimeException(
187            "Unknown static placeholder {$placeholder} in {$path}:{$line}:{$column} for driver \"{$this->driver}\".\n"
188            . "No value was configured for \"{$name}\".\n"
189            . "Add placeholders['all']['{$name}'] or placeholders['{$this->driver}']['{$name}'].\n"
190            . 'Static placeholders are raw SQL fragments. Use them only for trusted configuration, never for user input.',
191        );
192    }