1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace EdmondsCommerce\DoctrineStaticMeta\Entity\Savers; |
||
6 | |||
7 | use Doctrine\ORM\EntityManagerInterface; |
||
8 | use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\EntityInterface; |
||
9 | use EdmondsCommerce\DoctrineStaticMeta\Entity\Savers\BulkEntityUpdater\BulkEntityUpdateHelper; |
||
10 | use EdmondsCommerce\DoctrineStaticMeta\Schema\MysqliConnectionFactory; |
||
11 | use EdmondsCommerce\DoctrineStaticMeta\Schema\UuidFunctionPolyfill; |
||
12 | use mysqli; |
||
13 | use Ramsey\Uuid\Doctrine\UuidBinaryOrderedTimeType; |
||
14 | use Ramsey\Uuid\UuidInterface; |
||
15 | use RuntimeException; |
||
16 | |||
17 | use function get_class; |
||
18 | |||
19 | class BulkEntityUpdater extends AbstractBulkProcess |
||
20 | { |
||
21 | /** |
||
22 | * @var BulkEntityUpdateHelper |
||
23 | */ |
||
24 | private $extractor; |
||
25 | /** |
||
26 | * @var string |
||
27 | */ |
||
28 | private $tableName; |
||
29 | /** |
||
30 | * @var string |
||
31 | */ |
||
32 | private $entityFqn; |
||
33 | /** |
||
34 | * @var mysqli |
||
35 | */ |
||
36 | private $mysqli; |
||
37 | /** |
||
38 | * This holds the bulk SQL query |
||
39 | * |
||
40 | * @var string |
||
41 | */ |
||
42 | private $query; |
||
43 | |||
44 | /** |
||
45 | * @var float |
||
46 | */ |
||
47 | private $requireAffectedRatio = 1.0; |
||
48 | |||
49 | /** |
||
50 | * @var int |
||
51 | */ |
||
52 | private $totalAffectedRows = 0; |
||
53 | /** |
||
54 | * @var UuidFunctionPolyfill |
||
55 | */ |
||
56 | private $uuidFunctionPolyfill; |
||
57 | |||
58 | /** |
||
59 | * Is the UUID binary |
||
60 | * |
||
61 | * @var bool |
||
62 | */ |
||
63 | private $isBinaryUuid = true; |
||
64 | |||
65 | 6 | public function __construct( |
|
66 | EntityManagerInterface $entityManager, |
||
67 | UuidFunctionPolyfill $uuidFunctionPolyfill, |
||
68 | MysqliConnectionFactory $mysqliConnectionFactory |
||
69 | ) { |
||
70 | 6 | parent::__construct($entityManager); |
|
71 | 6 | $this->uuidFunctionPolyfill = $uuidFunctionPolyfill; |
|
72 | 6 | $this->mysqli = $mysqliConnectionFactory->createFromEntityManager($entityManager); |
|
73 | 6 | } |
|
74 | |||
75 | /** |
||
76 | * @param float $requireAffectedRatio |
||
77 | * |
||
78 | * @return BulkEntityUpdater |
||
79 | */ |
||
80 | 2 | public function setRequireAffectedRatio(float $requireAffectedRatio): BulkEntityUpdater |
|
81 | { |
||
82 | 2 | $this->requireAffectedRatio = $requireAffectedRatio; |
|
83 | |||
84 | 2 | return $this; |
|
85 | } |
||
86 | |||
87 | public function addEntityToSave(EntityInterface $entity) |
||
88 | { |
||
89 | if (false === $entity instanceof $this->entityFqn) { |
||
90 | throw new RuntimeException('You can only bulk save a single entity type, currently saving ' . |
||
91 | $this->entityFqn . |
||
92 | ' but you are trying to save ' . |
||
93 | get_class($entity)); |
||
94 | } |
||
95 | parent::addEntityToSave($entity); |
||
96 | } |
||
97 | |||
98 | 4 | public function setExtractor(BulkEntityUpdateHelper $extractor): void |
|
99 | { |
||
100 | 4 | $this->extractor = $extractor; |
|
101 | 4 | $this->tableName = $extractor->getTableName(); |
|
102 | 4 | $this->entityFqn = $extractor->getEntityFqn(); |
|
103 | 4 | $this->isBinaryUuid = $this->isBinaryUuid(); |
|
104 | 4 | $this->runPolyfillIfRequired(); |
|
105 | 4 | } |
|
106 | |||
107 | 4 | private function isBinaryUuid(): bool |
|
108 | { |
||
109 | 4 | $meta = $this->entityManager->getClassMetadata($this->entityFqn); |
|
110 | 4 | $idMapping = $meta->getFieldMapping($meta->getSingleIdentifierFieldName()); |
|
111 | |||
112 | 4 | return $idMapping['type'] === UuidBinaryOrderedTimeType::NAME; |
|
113 | } |
||
114 | |||
115 | 4 | private function runPolyfillIfRequired(): void |
|
116 | { |
||
117 | 4 | if (false === $this->isBinaryUuid) { |
|
118 | return; |
||
119 | } |
||
120 | 4 | $this->uuidFunctionPolyfill->run(); |
|
121 | 4 | } |
|
122 | |||
123 | 2 | public function startBulkProcess(): AbstractBulkProcess |
|
124 | { |
||
125 | 2 | if (!$this->extractor instanceof BulkEntityUpdateHelper) { |
|
0 ignored issues
–
show
introduced
by
![]() |
|||
126 | throw new RuntimeException( |
||
127 | 'You must call setExtractor with your extractor logic before starting the process. ' |
||
128 | . 'Note - a small anonymous class would be ideal' |
||
129 | ); |
||
130 | } |
||
131 | 2 | $this->resetQuery(); |
|
132 | |||
133 | 2 | return parent::startBulkProcess(); |
|
134 | } |
||
135 | |||
136 | 3 | private function resetQuery(): void |
|
137 | { |
||
138 | 3 | $this->query = ''; |
|
139 | 3 | } |
|
140 | |||
141 | /** |
||
142 | * @return int |
||
143 | */ |
||
144 | 1 | public function getTotalAffectedRows(): int |
|
145 | { |
||
146 | 1 | return $this->totalAffectedRows; |
|
147 | } |
||
148 | |||
149 | 4 | protected function doSave(): void |
|
150 | { |
||
151 | 4 | foreach ($this->entitiesToSave as $entity) { |
|
152 | 4 | if (!$entity instanceof $this->entityFqn || !$entity instanceof EntityInterface) { |
|
153 | throw new RuntimeException( |
||
154 | 'You can only bulk save a single entity type, currently saving ' . $this->entityFqn . |
||
155 | ' but you are trying to save ' . get_class($entity) |
||
156 | ); |
||
157 | } |
||
158 | 4 | $this->appendToQuery( |
|
159 | 4 | $this->convertExtractedToSqlRow( |
|
160 | 4 | $this->extractor->extract($entity) |
|
161 | ) |
||
162 | ); |
||
163 | } |
||
164 | 4 | $this->runQuery(); |
|
165 | 2 | $this->resetQuery(); |
|
166 | 2 | } |
|
167 | |||
168 | 4 | private function appendToQuery(string $sql): void |
|
169 | { |
||
170 | 4 | $this->query .= "\n$sql"; |
|
171 | 4 | } |
|
172 | |||
173 | /** |
||
174 | * Take the extracted array and build an update query |
||
175 | * |
||
176 | * @param array $extracted |
||
177 | * |
||
178 | * @return string |
||
179 | */ |
||
180 | 4 | private function convertExtractedToSqlRow(array $extracted): string |
|
181 | { |
||
182 | 4 | if ([] === $extracted) { |
|
183 | throw new RuntimeException('Extracted array is empty in ' . __METHOD__); |
||
184 | } |
||
185 | 4 | $primaryKeyCol = null; |
|
186 | 4 | $primaryKey = null; |
|
187 | 4 | $sql = "update `{$this->tableName}` set "; |
|
188 | 4 | $sqls = []; |
|
189 | 4 | foreach ($extracted as $key => $value) { |
|
190 | 4 | if (null === $primaryKeyCol) { |
|
191 | 4 | $primaryKeyCol = $key; |
|
192 | 4 | $primaryKey = $this->convertUuidToSqlString($value); |
|
193 | 4 | continue; |
|
194 | } |
||
195 | 4 | $value = $this->mysqli->escape_string((string)$value); |
|
196 | 4 | $sqls[] = "`$key` = '$value'"; |
|
197 | } |
||
198 | 4 | $sql .= implode(",\n", $sqls); |
|
199 | 4 | $sql .= " where `$primaryKeyCol` = $primaryKey; "; |
|
200 | |||
201 | 4 | return $sql; |
|
202 | } |
||
203 | |||
204 | 4 | private function convertUuidToSqlString(UuidInterface $uuid): string |
|
205 | { |
||
206 | 4 | $uuidString = (string)$uuid; |
|
207 | 4 | if (false === $this->isBinaryUuid) { |
|
208 | return "'$uuidString'"; |
||
209 | } |
||
210 | |||
211 | 4 | return UuidFunctionPolyfill::UUID_TO_BIN . "('$uuidString', true)"; |
|
212 | } |
||
213 | |||
214 | 4 | private function runQuery(): void |
|
215 | { |
||
216 | 4 | if ('' === $this->query) { |
|
217 | return; |
||
218 | } |
||
219 | 4 | $this->query = " |
|
220 | START TRANSACTION; |
||
221 | SET FOREIGN_KEY_CHECKS = 0; |
||
222 | SET UNIQUE_CHECKS = 0; |
||
223 | 4 | {$this->query} |
|
224 | SET FOREIGN_KEY_CHECKS = 1; |
||
225 | SET UNIQUE_CHECKS = 1; |
||
226 | COMMIT;"; |
||
227 | 4 | $result = $this->mysqli->multi_query($this->query); |
|
228 | 4 | if (true !== $result) { |
|
229 | throw new RuntimeException( |
||
230 | 'Multi Query returned false which means the first statement failed: ' . |
||
231 | $this->mysqli->error |
||
232 | ); |
||
233 | } |
||
234 | 4 | $affectedRows = 0; |
|
235 | 4 | $queryCount = 0; |
|
236 | do { |
||
237 | 4 | $queryCount++; |
|
238 | 4 | if (0 !== $this->mysqli->errno) { |
|
239 | 1 | throw new RuntimeException( |
|
240 | 1 | 'Query #' . $queryCount . |
|
241 | 1 | ' got MySQL Error #' . $this->mysqli->errno . |
|
242 | 1 | ': ' . $this->mysqli->error |
|
243 | 1 | . "\nQuery: " . $this->getQueryLine($queryCount) . "'\n" |
|
244 | ); |
||
245 | } |
||
246 | 4 | $affectedRows += max($this->mysqli->affected_rows, 0); |
|
247 | 4 | if (false === $this->mysqli->more_results()) { |
|
248 | 3 | break; |
|
249 | } |
||
250 | 4 | $this->mysqli->next_result(); |
|
251 | 4 | } while (true); |
|
252 | 3 | if ($affectedRows < count($this->entitiesToSave) * $this->requireAffectedRatio) { |
|
253 | 1 | throw new RuntimeException( |
|
254 | 1 | 'Affected rows count of ' . $affectedRows . |
|
255 | 1 | ' does match the expected count of entitiesToSave ' . count($this->entitiesToSave) |
|
256 | ); |
||
257 | } |
||
258 | 2 | $this->totalAffectedRows += $affectedRows; |
|
259 | 2 | $this->mysqli->commit(); |
|
260 | 2 | } |
|
261 | |||
262 | 1 | private function getQueryLine(int $line): string |
|
263 | { |
||
264 | 1 | $lines = explode(';', $this->query); |
|
265 | |||
266 | 1 | return $lines[$line + 1]; |
|
267 | } |
||
268 | } |
||
269 |