PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | database/Migrations/NotificationPrefMigrator.php +93 -10 2.9.02.10.0 View file →
@@ -2,8 +2,10 @@
2 2 // phpcs:disable
3 3
4 4 namespace FluentCommunity\Database\Migrations;
5 5
6 +use FluentCommunity\App\Services\NotificationPref;
7 +
6 8 class NotificationPrefMigrator
7 9 {
8 10 /**
9 11 * Legacy pref rows in fcom_notification_users mapped onto the
@@ -176,12 +178,34 @@
176 178 * matching on value would report false gaps forever.
177 179 */
178 180 $unmigrated = self::countUnmigratedRows();
179 181
180 - if ($unmigrated > 0) {
182 + /*
183 + * And separately: rows whose notification_type is not in $legacyMap at all.
184 + *
185 + * countUnmigratedRows() cannot see these. It joins the same map the copy
186 + * joins, so a key the map does not know about is absent from both sides of
187 + * that comparison and reads as zero - the copy skips it, the check passes
188 + * it, and an unscoped delete then removes a preference nobody carried over.
189 + * The check has to be asked about the rows the map does not cover, not only
190 + * about the rows it does.
191 + */
192 + $unmapped = self::countUnmappedRows();
193 +
194 + if ($unmigrated > 0 || $unmapped > 0) {
181 195 // Copied data stands and the new table is authoritative, so this is
182 196 // complete either way - but leave the source alone for inspection.
183 - update_option(self::ERROR_OPTION, sprintf('%d legacy rows had no counterpart; source left in place', $unmigrated), false);
197 + $problems = [];
198 +
199 + if ($unmigrated > 0) {
200 + $problems[] = sprintf('%d legacy rows had no counterpart', $unmigrated);
201 + }
202 +
203 + if ($unmapped > 0) {
204 + $problems[] = sprintf('%d legacy rows used a key this version does not map', $unmapped);
205 + }
206 +
207 + update_option(self::ERROR_OPTION, implode('; ', $problems) . '; source left in place', false);
184 208 self::markComplete();
185 209
186 210 return true;
187 211 }
@@ -207,23 +231,42 @@
207 231 * count. Not otherwise ceremonious: if the request dies partway, the done
208 232 * flag is never set, the next pass re-runs a copy that is now a no-op and
209 233 * carries on deleting.
210 234 *
235 + * Public as a test seam, for the same reason copyLegacyRange() is: it is pure
236 + * DML, and what it is scoped to is the part worth pinning down.
237 + *
211 238 * @param float $startedAt microtime this call began, for the shared budget
212 239 * @return bool true when nothing is left to delete
213 240 */
214 - private static function deleteLegacyRows($startedAt)
241 + public static function deleteLegacyRows($startedAt)
215 242 {
216 243 global $wpdb;
217 244
218 245 $legacyTable = $wpdb->prefix . 'fcom_notification_users';
219 246
247 + $keys = array_keys(self::$legacyMap);
248 + $placeholders = implode(', ', array_fill(0, count($keys), '%s'));
249 +
220 250 while (true) {
221 - // Pinned to the preference rows. This cannot reach a notification
222 - // receipt, which is live data the ticker and the toast read.
251 + /*
252 + * Pinned twice over: to the preference rows, so this cannot reach a
253 + * notification receipt (live data the ticker and the toast read), and
254 + * to the keys the copy above actually knows how to place, so a key
255 + * this version does not map survives rather than being deleted
256 + * uncopied. The guard in the caller should already have stopped us
257 + * before that could happen; this makes it true by construction
258 + * instead of by check.
259 + */
260 + $args = $keys;
261 + $args[] = self::DELETE_BATCH_SIZE;
262 +
223 263 $deleted = $wpdb->query($wpdb->prepare(
224 - "DELETE FROM {$legacyTable} WHERE `object_type` = 'notification_pref' LIMIT %d",
225 - self::DELETE_BATCH_SIZE
264 + "DELETE FROM {$legacyTable}
265 + WHERE `object_type` = 'notification_pref'
266 + AND `notification_type` IN ({$placeholders})
267 + LIMIT %d",
268 + $args
226 269 ));
227 270
228 271 if ($deleted === false) {
229 272 update_option(self::ERROR_OPTION, $wpdb->last_error, false);
@@ -337,8 +380,38 @@
337 380 return (int)$wpdb->get_var($wpdb->prepare($sql, $args));
338 381 }
339 382
340 383 /**
384 + * Legacy preference rows whose key is not in $legacyMap.
385 + *
386 + * The blind spot in countUnmigratedRows(): that query joins the map, so it can
387 + * only ever report on keys the map contains. This one asks the complement, and
388 + * a non-zero answer means the vocabulary has drifted from the frozen map and
389 + * the source must not be deleted.
390 + *
391 + * A NULL notification_type counts as unmapped. It is as unplaceable as an
392 + * unknown one, and SQL's NOT IN would otherwise return NULL and drop it.
393 + *
394 + * @return int
395 + */
396 + public static function countUnmappedRows()
397 + {
398 + global $wpdb;
399 +
400 + $legacyTable = $wpdb->prefix . 'fcom_notification_users';
401 +
402 + $keys = array_keys(self::$legacyMap);
403 + $placeholders = implode(', ', array_fill(0, count($keys), '%s'));
404 +
405 + $sql = "SELECT COUNT(*)
406 + FROM {$legacyTable}
407 + WHERE `object_type` = 'notification_pref'
408 + AND (`notification_type` IS NULL OR `notification_type` NOT IN ({$placeholders}))";
409 +
410 + return (int)$wpdb->get_var($wpdb->prepare($sql, $keys));
411 + }
412 +
413 + /**
341 414 * Continuation entry point. Deliberately not gated on the plugin's db-version
342 415 * option: boot/app.php writes that as soon as DBMigrator::run() returns, so a
343 416 * backfill that deferred work would never be reached through the migrator again.
344 417 *
@@ -371,13 +444,23 @@
371 444
372 445 /**
373 446 * @return void
374 447 */
375 - /**
376 - * @return void
377 - */
378 448 private static function markComplete()
379 449 {
380 450 update_option(self::DONE_OPTION, 'yes', false);
381 451 delete_option(self::CURSOR_OPTION);
452 +
453 + /*
454 + * Drop any aggregate computed while this table was still filling up.
455 + *
456 + * NotificationPref::hasAnyEnabled() reads a denormalized option and only
457 + * the preference write path refreshes it, so a "nobody has the digest on"
458 + * answer derived from a partial table would outlive the migration that
459 + * made it wrong - and the hourly scheduler unschedules the digest on it.
460 + * Deleting the option rather than recomputing it here keeps the migration
461 + * off the read path: the next call recomputes from a table that is now
462 + * whole.
463 + */
464 + delete_option(NotificationPref::AGGREGATE_OPTION);
382 465 }
383 466 }