Passed
Push — main ( 6823dc...5326f7 )
by Thomas
12:29
created

Query::__toString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Quma;
6
7
use Generator;
8
use InvalidArgumentException;
9
use PDO;
10
use PDOStatement;
11
12
/** @psalm-api */
13
class Query
14
{
15
    // Matches multi line single and double quotes and handles \' \" escapes
16
    public const PATTERN_STRING = '/([\'"])(?:\\\1|[\s\S])*?\1/';
17
    // PostgreSQL blocks delimited with $$
18
    public const PATTERN_BLOCK = '/(\$\$)[\s\S]*?\1/';
19
    // Multi line comments /* */
20
    public const PATTERN_COMMENT_MULTI = '/\/\*([\s\S]*?)\*\//';
21
    // Single line comments --
22
    public const PATTERN_COMMENT_SINGLE = '/--.*$/m';
23
    protected PDOStatement $stmt;
24
    protected bool $executed = false;
25
26 59
    public function __construct(
27
        protected Database $db,
28
        protected string $query,
29
        protected Args $args
30
    ) {
31 59
        $this->stmt = $this->db->getConn()->prepare($query);
32
33 59
        if ($args->count() > 0) {
34 25
            $this->bindArgs($args->get(), $args->type());
35
        }
36
37 58
        if ($db->print()) {
38 2
            $msg = "\n\n-----------------------------------------------\n\n" .
39 2
                $this->interpolate() .
40 2
                "\n------------------------------------------------\n";
41
42 2
            if ($_SERVER['SERVER_SOFTWARE'] ?? false) {
43
                // @codeCoverageIgnoreStart
44
                error_log($msg);
45
            // @codeCoverageIgnoreEnd
46
            } else {
47 2
                echo $msg;
48
            }
49
        }
50
    }
51
52 4
    public function __toString(): string
53
    {
54 4
        return $this->interpolate();
55
    }
56
57 44
    public function one(?int $fetchMode = null): ?array
58
    {
59 44
        $this->db->connect();
60
61 44
        if (!$this->executed) {
62 44
            $this->stmt->execute();
63 44
            $this->executed = true;
64
        }
65
66 44
        return $this->nullIfNot($this->stmt->fetch($fetchMode ?? $this->db->getFetchMode()));
67
    }
68
69 30
    public function all(?int $fetchMode = null): array
70
    {
71 30
        $this->db->connect();
72 30
        $this->stmt->execute();
73
74 30
        return $this->stmt->fetchAll($fetchMode ?? $this->db->getFetchMode());
75
    }
76
77 1
    public function lazy(?int $fetchMode = null): Generator
78
    {
79 1
        $this->db->connect();
80 1
        $this->stmt->execute();
81 1
        $fetchMode = $fetchMode ?? $this->db->getFetchMode();
82
83
        /**
84
         * @psalm-suppress MixedAssignment
85
         *
86
         * As the fetch mode can be changed it is not clear
87
         * which type will be returned from `fetch`
88
         */
89 1
        while ($record = $this->stmt->fetch($fetchMode)) {
90 1
            yield $record;
91
        }
92
    }
93
94 16
    public function run(): bool
95
    {
96 16
        $this->db->connect();
97
98 16
        return $this->stmt->execute();
99
    }
100
101 1
    public function len(): int
102
    {
103 1
        $this->db->connect();
104 1
        $this->stmt->execute();
105
106 1
        return $this->stmt->rowCount();
107
    }
108
109
    /**
110
     * For debugging purposes only.
111
     *
112
     * Replaces any parameter placeholders in a query with the
113
     * value of that parameter and returns the query as string.
114
     *
115
     * Covers most of the cases but is not perfect.
116
     */
117 6
    public function interpolate(): string
118
    {
119 6
        $prep = $this->prepareQuery($this->query);
120 6
        $argsArray = $this->args->get();
121
122 6
        if ($this->args->type() === ArgType::Named) {
123
            /** @psalm-suppress InvalidArgument */
124 5
            $interpolated = $this->interpolateNamed($prep->query, $argsArray);
125
        } else {
126 1
            $interpolated = $this->interpolatePositional($prep->query, $argsArray);
127
        }
128
129 6
        return $this->restoreQuery($interpolated, $prep);
130
    }
131
132 25
    protected function bindArgs(array $args, ArgType $argType): void
133
    {
134
        /** @psalm-suppress MixedAssignment -- $value is thouroughly typechecked in the loop */
135 25
        foreach ($args as $a => $value) {
136 24
            if ($argType === ArgType::Named) {
137 21
                $arg = ':' . $a;
138
            } else {
139 7
                $arg = (int)$a + 1; // question mark placeholders ar 1-indexed
140
            }
141
142 24
            switch (gettype($value)) {
143 24
                case 'boolean':
144 1
                    $this->stmt->bindValue($arg, $value, PDO::PARAM_BOOL);
145
146 1
                    break;
147
148 23
                case 'integer':
149 12
                    $this->stmt->bindValue($arg, $value, PDO::PARAM_INT);
150
151 12
                    break;
152
153 13
                case 'string':
154 10
                    $this->stmt->bindValue($arg, $value, PDO::PARAM_STR);
155
156 10
                    break;
157
158 3
                case 'NULL':
159 1
                    $this->stmt->bindValue($arg, $value, PDO::PARAM_NULL);
160
161 1
                    break;
162
163 2
                case 'array':
164 1
                    $this->stmt->bindValue($arg, json_encode($value), PDO::PARAM_STR);
165
166 1
                    break;
167
168
                default:
169 1
                    throw new InvalidArgumentException(
170 1
                        'Only the types bool, int, string, null and array are supported'
171 1
                    );
172
            }
173
        }
174
    }
175
176 44
    protected function nullIfNot(mixed $value): ?array
177
    {
178 44
        if (is_array($value)) {
179 44
            return $value;
180
        }
181
182 1
        return null;
183
    }
184
185 6
    protected function convertValue(mixed $value): string
186
    {
187 6
        if (is_string($value)) {
188 1
            return "'" . $value . "'";
189
        }
190
191 5
        if (is_array($value)) {
192 1
            return "'" . json_encode($value) . "'";
193
        }
194
195 4
        if (is_null($value)) {
196 1
            return 'NULL';
197
        }
198
199 3
        if (is_bool($value)) {
200 1
            return $value ? 'true' : 'false';
201
        }
202
203 2
        return (string)$value;
204
    }
205
206 6
    protected function prepareQuery(string $query): PreparedQuery
207
    {
208 6
        $patterns = [
209 6
            self::PATTERN_BLOCK,
210 6
            self::PATTERN_STRING,
211 6
            self::PATTERN_COMMENT_MULTI,
212 6
            self::PATTERN_COMMENT_SINGLE,
213 6
        ];
214
215
        /** @psalm-var array<non-empty-string, non-empty-string> */
216 6
        $swaps = [];
217
218 6
        $i = 0;
219
220
        do {
221 6
            $found = false;
222
223 6
            foreach ($patterns as $pattern) {
224 6
                $matches = [];
225
226 6
                if (preg_match($pattern, $query, $matches)) {
227 2
                    $match = $matches[0];
228 2
                    $replacement = "___CHUCK_REPLACE_{$i}___";
229
                    assert(!empty($match));
230 2
                    $swaps[$replacement] = $match;
231
232 2
                    $query = preg_replace($pattern, $replacement, $query, limit: 1);
233 2
                    $found = true;
234 2
                    $i++;
235
236 2
                    break;
237
                }
238
            }
239 6
        } while ($found);
240
241 6
        return new PreparedQuery($query, $swaps);
242
    }
243
244 6
    protected function restoreQuery(string $query, PreparedQuery $prep): string
245
    {
246 6
        foreach ($prep->swaps as $swap => $replacement) {
247 2
            $query = str_replace($swap, $replacement, $query);
248
        }
249
250 6
        return $query;
251
    }
252
253
    /** @psalm-param array<non-empty-string, mixed> $args */
254 5
    protected function interpolateNamed(string $query, array $args): string
255
    {
256 5
        $map = [];
257
258
        /** @psalm-suppress MixedAssignment -- $value is checked in convertValue */
259 5
        foreach ($args as $key => $value) {
260 5
            $key = ':' . $key;
261 5
            $map[$key] = $this->convertValue($value);
262
        }
263
264 5
        return strtr($query, $map);
265
    }
266
267 1
    protected function interpolatePositional(string $query, array $args): string
268
    {
269
        /** @psalm-suppress MixedAssignment -- $value is checked in convertValue */
270 1
        foreach ($args as $value) {
271 1
            $query = preg_replace('/\\?/', $this->convertValue($value), $query, 1);
272
        }
273
274 1
        return $query;
275
    }
276
}
277