1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Schema;
6:
7: use Atk4\Core\Phpunit\TestCase as BaseTestCase;
8: use Atk4\Data\Model;
9: use Atk4\Data\Persistence;
10: use Atk4\Data\Persistence\Sql\Expression;
11: use Doctrine\DBAL\ParameterType;
12: use Doctrine\DBAL\Platforms\AbstractPlatform;
13: use Doctrine\DBAL\Platforms\MySQLPlatform;
14: use Doctrine\DBAL\Platforms\OraclePlatform;
15: use Doctrine\DBAL\Platforms\SQLitePlatform;
16: use Doctrine\DBAL\Platforms\SQLServerPlatform;
17:
18: abstract class TestCase extends BaseTestCase
19: {
20: /** @var Persistence|Persistence\Sql */
21: public $db;
22:
23: /** @var bool If true, SQL queries are dumped. */
24: public $debug = false;
25:
26: /** @var array<int, Migrator> */
27: private array $createdMigrators = [];
28:
29: /**
30: * @return static|null
31: */
32: public static function getTestFromBacktrace()
33: {
34: foreach (debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT) as $frame) {
35: if (($frame['object'] ?? null) instanceof static) {
36: return $frame['object'];
37: }
38: }
39:
40: return null;
41: }
42:
43: #[\Override]
44: protected function setUp(): void
45: {
46: parent::setUp();
47:
48: $this->db = new TestSqlPersistence();
49: }
50:
51: #[\Override]
52: protected function tearDown(): void
53: {
54: $debugOrig = $this->debug;
55: try {
56: $this->debug = false;
57: $this->dropCreatedDb();
58: } finally {
59: $this->debug = $debugOrig;
60: }
61:
62: parent::tearDown();
63: }
64:
65: protected function getConnection(): Persistence\Sql\Connection
66: {
67: return $this->db->getConnection(); // @phpstan-ignore-line
68: }
69:
70: protected function getDatabasePlatform(): AbstractPlatform
71: {
72: return $this->getConnection()->getDatabasePlatform();
73: }
74:
75: /**
76: * @param array<int|string, scalar|null> $params
77: * @param array<int|string, ParameterType::*> $types
78: */
79: protected function logQuery(string $sql, array $params, array $types): void
80: {
81: if (!$this->debug) {
82: return;
83: }
84:
85: // needed for \Atk4\Data\Persistence\Sql\*\ExpressionTrait::updateRenderBeforeExecute() fixes
86: $i = 0;
87: $sql = preg_replace_callback(
88: '~' . Expression::QUOTED_TOKEN_REGEX . '\K|(\?)|cast\((\?|:\w+) as (BOOLEAN|INTEGER|BIGINT|DOUBLE PRECISION|BINARY_DOUBLE)\)|\((\?|:\w+) \+ 0\.00\)~',
89: static function ($matches) use (&$types, &$params, &$i) {
90: if ($matches[0] === '') {
91: return '';
92: }
93:
94: if ($matches[1] === '?') {
95: ++$i;
96:
97: return $matches[0];
98: }
99:
100: $k = isset($matches[4]) ? ($matches[4] === '?' ? ++$i : $matches[4]) : ($matches[2] === '?' ? ++$i : $matches[2]);
101:
102: if ($matches[3] === 'BOOLEAN' && ($types[$k] === ParameterType::BOOLEAN || $types[$k] === ParameterType::INTEGER)
103: && (is_bool($params[$k]) || $params[$k] === '0' || $params[$k] === '1')
104: ) {
105: $types[$k] = ParameterType::BOOLEAN;
106: $params[$k] = (bool) $params[$k];
107:
108: return $matches[4] ?? $matches[2];
109: } elseif (($matches[3] === 'INTEGER' || $matches[3] === 'BIGINT') && $types[$k] === ParameterType::INTEGER && is_int($params[$k])) {
110: return $matches[4] ?? $matches[2];
111: } elseif (($matches[3] === 'DOUBLE PRECISION' || $matches[3] === 'BINARY_DOUBLE' || isset($matches[4]))
112: && $types[$k] === ParameterType::STRING && is_string($params[$k]) && is_numeric($params[$k])
113: ) {
114: // $types[$k] = ParameterType::FLOAT; is not supported yet by DBAL
115: $params[$k] = (float) $params[$k];
116:
117: return $matches[4] ?? $matches[2];
118: }
119:
120: return $matches[0];
121: },
122: $sql
123: );
124:
125: $exprNoRender = new class($sql, $params) extends Expression {
126: #[\Override]
127: public function render(): array
128: {
129: return [$this->template, $this->args['custom']];
130: }
131: };
132: $sqlWithParams = $exprNoRender->getDebugQuery();
133:
134: if (substr($sqlWithParams, -1) !== ';') {
135: $sqlWithParams .= ';';
136: }
137:
138: echo "\n" . $sqlWithParams . "\n\n";
139: }
140:
141: private function convertSqlFromSqlite(string $sql): string
142: {
143: $platform = $this->getDatabasePlatform();
144:
145: $convertedSql = preg_replace_callback(
146: '~(?![\'`])' . Expression::QUOTED_TOKEN_REGEX . '\K|' . Expression::QUOTED_TOKEN_REGEX . '|:(\w+)~',
147: static function ($matches) use ($platform) {
148: if ($matches[0] === '') {
149: return '';
150: }
151:
152: if (isset($matches[1])) {
153: return ':' . ($platform instanceof OraclePlatform ? 'xxaaa' : '') . $matches[1];
154: }
155:
156: $str = substr(preg_replace('~\\\\(.)~s', '$1', $matches[0]), 1, -1);
157: if (substr($matches[0], 0, 1) === '`') {
158: return $platform->quoteSingleIdentifier($str);
159: }
160:
161: return ($platform instanceof SQLServerPlatform ? 'N' : '') . $platform->quoteStringLiteral($str);
162: },
163: $sql
164: );
165:
166: if ($platform instanceof SQLitePlatform && $convertedSql !== $sql) {
167: self::assertSame($sql, $convertedSql);
168: }
169:
170: return $convertedSql;
171: }
172:
173: protected function assertSameSql(string $expectedSqliteSql, string $actualSql, string $message = ''): void
174: {
175: // remove once SQLite affinity of expressions is fixed natively
176: // related with Atk4\Data\Persistence\Sql\Sqlite\Query::_renderConditionBinary() fix
177: if ($this->getDatabasePlatform() instanceof SQLitePlatform) {
178: do {
179: $actualSqlPrev = $actualSql;
180: $actualSql = preg_replace('~case when typeof\((.+?)\) in \(\'integer\', \'real\'\) then cast\(\1 as numeric\) (.{1,20}?) (.+?) else \1 \2 \3 end~s', '$1 $2 $3', $actualSql);
181: $actualSql = preg_replace('~case when typeof\((.+?)\) in \(\'integer\', \'real\'\) then (.+?) (.{1,20}?) cast\(\1 as numeric\) else \2 \3 \1 end~s', '$2 $3 $1', $actualSql);
182: } while ($actualSql !== $actualSqlPrev);
183: }
184:
185: self::assertSame($this->convertSqlFromSqlite($expectedSqliteSql), $actualSql, $message);
186: }
187:
188: /**
189: * @param mixed $a
190: * @param mixed $b
191: */
192: private static function compareExportUnorderedValue($a, $b): int
193: {
194: if ($a === $b) {
195: return 0;
196: }
197:
198: $cmp = gettype($a) <=> gettype($b);
199: if ($cmp !== 0) {
200: return $cmp;
201: }
202:
203: if (is_object($a)) {
204: $cmp = gettype($a) <=> gettype($b);
205: if ($cmp !== 0) {
206: return $cmp;
207: }
208:
209: if ($a instanceof \DateTimeInterface) {
210: $format = 'Y-m-d H:i:s.u e I Z';
211:
212: return $a->format($format) <=> $b->format($format);
213: }
214: }
215:
216: if (is_array($a) && count($a) === count($b)) {
217: $is2d = true;
218: foreach ($a as $v) {
219: if (!is_array($v)) {
220: $is2d = false;
221:
222: break;
223: }
224: }
225: if ($is2d) {
226: foreach ($b as $v) {
227: if (!is_array($v)) {
228: $is2d = false;
229:
230: break;
231: }
232: }
233: }
234:
235: if ($is2d) {
236: if (array_is_list($a) && array_is_list($b)) {
237: usort($a, static fn ($a, $b) => self::compareExportUnorderedValue($a, $b));
238: usort($b, static fn ($a, $b) => self::compareExportUnorderedValue($a, $b));
239: } else {
240: uasort($a, static fn ($a, $b) => self::compareExportUnorderedValue($a, $b));
241: uasort($b, static fn ($a, $b) => self::compareExportUnorderedValue($a, $b));
242: }
243: }
244:
245: if (array_keys($a) === array_keys($b)) {
246: foreach ($a as $k => $v) {
247: $cmp = self::compareExportUnorderedValue($v, $b[$k]);
248: if ($cmp !== 0) {
249: return $cmp;
250: }
251: }
252:
253: return 0;
254: }
255: }
256:
257: return $a <=> $b;
258: }
259:
260: /**
261: * Same as self::assertSame() except:
262: * - 2D arrays (rows) are recursively compared without any order
263: * - objects implementing DateTimeInterface are compared by formatted output.
264: *
265: * @param array<mixed, mixed> $expected
266: * @param array<mixed, mixed> $actual
267: */
268: protected static function assertSameExportUnordered(array $expected, array $actual, string $message = ''): void
269: {
270: if (self::compareExportUnorderedValue($expected, $actual) === 0) {
271: self::assertTrue(true); // @phpstan-ignore-line
272:
273: return;
274: }
275:
276: self::assertSame($expected, $actual, $message);
277: }
278:
279: public function createMigrator(Model $model = null): Migrator
280: {
281: $migrator = new Migrator($model ?? $this->db);
282: $this->createdMigrators[] = $migrator;
283:
284: return $migrator;
285: }
286:
287: /**
288: * @param array<string, array<int|'_', array<string, mixed>>> $dbData
289: */
290: public function setDb(array $dbData, bool $importData = true): void
291: {
292: foreach ($dbData as $tableName => $data) {
293: $migrator = $this->createMigrator()->table($tableName);
294:
295: $fieldTypes = [];
296: foreach ($data as $row) {
297: foreach ($row as $k => $v) {
298: if (isset($fieldTypes[$k])) {
299: continue;
300: }
301:
302: if (is_bool($v)) {
303: $fieldType = 'boolean';
304: } elseif (is_int($v)) {
305: $fieldType = 'integer';
306: } elseif (is_float($v)) {
307: $fieldType = 'float';
308: } elseif ($v instanceof \DateTimeInterface) {
309: $fieldType = 'datetime';
310: } elseif ($v !== null) {
311: $fieldType = 'string';
312: } else {
313: $fieldType = null;
314: }
315:
316: $fieldTypes[$k] = $fieldType;
317: }
318: }
319: foreach ($fieldTypes as $k => $fieldType) {
320: if ($fieldType === null) {
321: $fieldTypes[$k] = 'string';
322: }
323: }
324: $idColumnName = isset($fieldTypes['_id']) ? '_id' : 'id';
325:
326: // create table
327: $migrator->id($idColumnName, isset($fieldTypes[$idColumnName]) ? ['type' => $fieldTypes[$idColumnName]] : []);
328: foreach ($fieldTypes as $k => $fieldType) {
329: if ($k === $idColumnName) {
330: continue;
331: }
332:
333: $migrator->field($k, ['type' => $fieldType]);
334: }
335: $migrator->create();
336:
337: // import data
338: if ($importData) {
339: $this->db->atomic(function () use ($tableName, $data, $idColumnName) {
340: $hasId = array_key_first($data) !== 0;
341:
342: foreach ($data as $id => $row) {
343: $query = $this->db->dsql();
344: if ($id === '_') {
345: continue;
346: }
347:
348: $query->table($tableName);
349: $query->setMulti($row);
350:
351: if (!isset($row[$idColumnName]) && $hasId) {
352: $query->set($idColumnName, $id);
353: }
354:
355: $query->mode('insert')->executeStatement();
356: }
357: });
358: }
359: }
360: }
361:
362: /**
363: * @param array<int, string>|null $tableNames
364: *
365: * @return array<string, array<int, array<string, mixed>>>
366: */
367: public function getDb(array $tableNames = null, bool $noId = false): array
368: {
369: if ($tableNames === null) {
370: $tableNames = [];
371: foreach ($this->createdMigrators as $migrator) {
372: foreach ($migrator->getCreatedTableNames() as $t) {
373: $tableNames[$t] = $t;
374: }
375: }
376: $tableNames = array_values($tableNames);
377: }
378:
379: $resAll = [];
380: foreach ($tableNames as $table) {
381: $query = $this->db->dsql();
382: $rows = $query->table($table)->getRows();
383:
384: $res = [];
385: $idColumnName = null;
386: foreach ($rows as $row) {
387: if ($idColumnName === null) {
388: $idColumnName = isset($row['_id']) ? '_id' : 'id';
389: }
390:
391: foreach ($row as $k => $v) {
392: if (preg_match('~(?:^|_)id$~', $k) && $v === (string) (int) $v) {
393: $row[$k] = (int) $v;
394: }
395: }
396:
397: if ($noId) {
398: unset($row[$idColumnName]);
399: $res[] = $row;
400: } else {
401: $res[$row[$idColumnName]] = $row;
402: }
403: }
404:
405: if (!$noId) {
406: ksort($res);
407: }
408:
409: $resAll[$table] = $res;
410: }
411:
412: return $resAll;
413: }
414:
415: public function dropCreatedDb(): void
416: {
417: while (count($this->createdMigrators) > 0) {
418: $migrator = array_pop($this->createdMigrators);
419: foreach ($migrator->getCreatedTableNames() as $t) {
420: (clone $migrator)->table($t)->dropIfExists(true);
421: }
422: }
423: }
424:
425: protected function markTestIncompleteOnMySQL56PlatformAsCreateUniqueStringIndexHasLengthLimit(): void
426: {
427: if ($this->getDatabasePlatform() instanceof MySQLPlatform) {
428: $serverVersion = $this->getConnection()->getConnection()->getWrappedConnection()->getServerVersion(); // @phpstan-ignore-line
429: if (preg_match('~^5\.6~', $serverVersion)) {
430: self::markTestIncomplete('TODO MySQL 5.6: Unique key exceed max key (767 bytes) length');
431: }
432: }
433: }
434: }
435: