1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Behat;
6:
7: use Atk4\Data\Model;
8: use Atk4\Data\Persistence;
9: use Doctrine\DBAL\Types\Type;
10:
11: trait RwDemosContextTrait
12: {
13: protected string $demosDir = __DIR__ . '/../../demos';
14:
15: protected bool $needDatabaseRestore = false;
16:
17: /** @var list<string> */
18: protected array $databaseBackupTables = [
19: 'client',
20: 'country',
21: 'file',
22: 'stat',
23: 'product_category',
24: 'product_sub_category',
25: 'product',
26: 'multiline_item',
27: 'multiline_delivery',
28: ];
29:
30: /** @var array<string, Model>|null */
31: protected ?array $databaseBackupModels = null;
32:
33: /** @var array<string, array<int, array<string, mixed>>>|null */
34: protected ?array $databaseBackupData = null;
35:
36: protected function getDemosDb(): Persistence\Sql
37: {
38: static $db = null;
39: if ($db === null) {
40: try {
41: /** @var Persistence\Sql $db */
42: require_once $this->demosDir . '/init-db.php'; // @phpstan-ignore-line
43: } catch (\Throwable $e) {
44: throw new \Exception('Database error: ' . $e->getMessage());
45: }
46: }
47:
48: return $db;
49: }
50:
51: protected function createDatabaseModelFromTable(string $table): Model
52: {
53: $db = $this->getDemosDb();
54: $schemaManager = $db->getConnection()->createSchemaManager();
55: $tableColumns = $schemaManager->listTableColumns($table);
56:
57: $model = new Model($db, ['table' => $table]);
58: $model->removeField('id');
59: foreach ($tableColumns as $tableColumn) {
60: $model->addField($tableColumn->getName(), [
61: 'type' => Type::getTypeRegistry()->lookupName($tableColumn->getType()), // TODO simplify once https://github.com/doctrine/dbal/pull/6130 is merged
62: 'nullable' => !$tableColumn->getNotnull(),
63: ]);
64: }
65: $model->idField = array_key_first($model->getFields());
66:
67: return $model;
68: }
69:
70: protected function createDatabaseModels(): void
71: {
72: $modelByTable = [];
73: foreach ($this->databaseBackupTables as $table) {
74: $modelByTable[$table] = $this->createDatabaseModelFromTable($table);
75: }
76:
77: $this->databaseBackupModels = $modelByTable;
78: }
79:
80: protected function createDatabaseBackup(): void
81: {
82: $dataByTable = [];
83: foreach ($this->databaseBackupTables as $table) {
84: $model = $this->databaseBackupModels[$table];
85:
86: $data = [];
87: foreach ($model as $entity) {
88: $data[$entity->getId()] = $entity->get();
89: }
90:
91: $dataByTable[$table] = $data;
92: }
93:
94: $this->databaseBackupData = $dataByTable;
95: }
96:
97: /**
98: * @return array<string, \stdClass&object{ addedIds: list<int>, updatedIds: list<int>, deletedIds: list<int> }>
99: */
100: protected function discoverDatabaseChanges(): array
101: {
102: $changesByTable = [];
103: foreach ($this->databaseBackupTables as $table) {
104: $model = $this->databaseBackupModels[$table];
105: $data = $this->databaseBackupData[$table];
106:
107: $changes = new \stdClass();
108: $changes->addedIds = [];
109: $changes->updatedIds = [];
110: $changes->deletedIds = array_fill_keys(array_keys($data), true);
111: foreach ($model as $entity) {
112: $id = $entity->getId();
113: if (!isset($data[$id])) {
114: $changes->addedIds[] = $id;
115: } else {
116: $isChanged = false;
117: foreach ($data[$id] as $k => $v) {
118: if (!$entity->compare($k, $v)) {
119: $isChanged = true;
120:
121: break;
122: }
123: }
124:
125: if ($isChanged) {
126: $changes->updatedIds[] = $id;
127: }
128:
129: unset($changes->deletedIds[$id]);
130: }
131: }
132: $changes->deletedIds = array_keys($changes->deletedIds);
133:
134: if (count($changes->addedIds) > 0 || count($changes->updatedIds) > 0 || count($changes->deletedIds) > 0) {
135: $changesByTable[$table] = $changes;
136: }
137: }
138:
139: return $changesByTable; // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9252
140: }
141:
142: protected function restoreDatabaseBackup(): void
143: {
144: $changesByTable = $this->discoverDatabaseChanges();
145:
146: if (count($changesByTable) > 0) {
147: // TODO disable FK checks
148: // unfortunately there is no DBAL API - https://github.com/doctrine/dbal/pull/2620
149: try {
150: $this->getDemosDb()->atomic(function () use ($changesByTable) {
151: foreach ($changesByTable as $table => $changes) {
152: $model = $this->databaseBackupModels[$table];
153: $data = $this->databaseBackupData[$table];
154:
155: foreach ($changes->addedIds as $id) {
156: $model->delete($id);
157: }
158:
159: foreach ([...$changes->updatedIds, ...$changes->deletedIds] as $id) {
160: $entity = in_array($id, $changes->updatedIds, true) ? $model->load($id) : $model->createEntity();
161: $entity->setMulti($data[$id]);
162: $entity->save();
163: }
164: }
165: });
166: } finally {
167: // TODO enable FK checks
168: }
169: }
170: }
171:
172: /**
173: * @AfterScenario
174: */
175: public function restoreDatabase(): void
176: {
177: if ($this->needDatabaseRestore) {
178: $this->needDatabaseRestore = false;
179: unlink($this->demosDir . '/db-behat-rw.txt');
180:
181: $this->restoreDatabaseBackup();
182: }
183: }
184:
185: /**
186: * @When I persist DB changes across requests
187: */
188: public function iPersistDbChangesAcrossRequests(): void
189: {
190: if ($this->databaseBackupData === null) {
191: if (file_exists($this->demosDir . '/db-behat-rw.txt')) {
192: throw new \Exception('Database was not restored cleanly');
193: }
194:
195: $this->createDatabaseModels();
196: $this->createDatabaseBackup();
197: }
198:
199: $this->needDatabaseRestore = true;
200: file_put_contents($this->demosDir . '/db-behat-rw.txt', '');
201: }
202: }
203: