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}