PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / app / Upgrades / Versions / Upgrade_3_0_5.php

Upgrade_3_0_5.php in Yatra – Travel Booking & Tour Operator Software 3.0.16, at app/Upgrades/Versions/Upgrade_3_0_5.php

478 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Upgrades\Versions;
6
7 use Yatra\Database\Tables\ReviewsTable;
8 use Yatra\Services\InstallerService;
9 use Yatra\Upgrades\Contracts\UpgradeStepInterface;
10
11 /**
12 * Free 3.0.5 maintenance step. Carries two healings that must run on
13 * installs whose stored version is already 3.0.5 — both gated by one-shot
14 * option flags so they're cheap on subsequent loads:
15 *
16 * A) **Reviews status ENUM widening** — the admin UI exposes spam/trash
17 * but the original schema only had pending/approved/rejected. MySQL
18 * coerces out-of-enum writes to '' so bulk-actions silently lost.
19 * We widen the ENUM and re-classify '' rows back to 'pending'.
20 *
21 * B) **Drop the `yatra_new_` table-name prefix** — the 3.x rewrite shipped
22 * with table names like `wp_yatra_new_trips`. The "new" was a migration
23 * artefact never meant to be permanent. We rename 19 free-plugin tables
24 * to the canonical `wp_yatra_*` form. {@see TABLE_RENAME_MAP}.
25 *
26 * Why neither subclasses {@see AbstractUpgradeStep}: that base's
27 * {@see shouldApply()} only returns true when crossing the target version
28 * from below. Here the heal must fire on installs whose stored
29 * `yatra_version` is *already* 3.0.5 (or newer) because both bugs ship in
30 * 3.0.5 itself. We override `shouldApply()` to gate on the union of one-
31 * shot option flags, and {@see \Yatra\Upgrades\FreeUpgradeRunner::runIdempotentMaintenance()}
32 * calls us directly so we run until both one-shots fire.
33 *
34 * **Ordering**: {@see \Yatra\Upgrades\FreeUpgradeRunner::runAdminUpgrades()}
35 * now calls our rename heal *before* `Database::createTables()`, so dbDelta
36 * sees the canonical `wp_yatra_*` names already in place and is a no-op
37 * for those tables on a freshly-deployed install. The "both exist"
38 * branch in {@see runTableRename()} is therefore only hit in the rare
39 * cases where an external entry point (wp-cli, plugin activation,
40 * concurrent request) called `createTables()` before our heal had a
41 * chance to fire. We handle that branch *non-destructively*: the empty
42 * placeholder is RENAMED ASIDE to
43 * `wp_yatra_X__dbdelta_placeholder_<unix-ts>`, not DROPped, so nothing
44 * is ever destroyed even in pathological scenarios. Leftover
45 * placeholder tables can be inspected and dropped manually by the admin.
46 *
47 * **Frontend coverage**: the runner also subscribes to `init` priority 0
48 * so a frontend visit (before any admin login) triggers the rename
49 * without waiting. The work is gated by an autoloaded one-shot option,
50 * a request-level static, a transient failure-backoff, and a cache
51 * concurrency lock — steady-state cost per request after the migration
52 * completes is a single boolean check.
53 */
54 final class Upgrade_3_0_5 implements UpgradeStepInterface
55 {
56 /** One-shot flag for the reviews-status ENUM widening. */
57 public const DONE_OPTION = 'yatra_reviews_status_enum_widened_v1';
58
59 /** One-shot flag for the `yatra_new_` → `yatra_` table rename pass. */
60 public const RENAME_DONE_OPTION = 'yatra_tables_renamed_drop_new_prefix_v1';
61
62 /**
63 * Old → new physical table name suffixes (without `$wpdb->prefix`).
64 * Order is not significant — tables are independent (no FKs across them
65 * in WordPress convention) so each rename succeeds in isolation.
66 *
67 * Keep this list in lockstep with {@see \Yatra\Database\Tables\*} declarations.
68 */
69 private const TABLE_RENAME_MAP = [
70 'yatra_new_bookings' => 'yatra_bookings',
71 'yatra_new_booking_payments' => 'yatra_booking_payments',
72 'yatra_new_booking_travellers' => 'yatra_booking_travellers',
73 'yatra_new_booking_traveller_meta' => 'yatra_booking_traveller_meta',
74 'yatra_new_booking_departures' => 'yatra_booking_departures',
75 'yatra_new_classifications' => 'yatra_classifications',
76 'yatra_new_customers' => 'yatra_customers',
77 'yatra_new_discounts' => 'yatra_discounts',
78 'yatra_new_enquiries' => 'yatra_enquiries',
79 'yatra_new_reviews' => 'yatra_reviews',
80 'yatra_new_trips' => 'yatra_trips',
81 'yatra_new_trip_availability_dates' => 'yatra_trip_availability_dates',
82 'yatra_new_trip_availability_rules' => 'yatra_trip_availability_rules',
83 'yatra_new_trip_classifications' => 'yatra_trip_classifications',
84 'yatra_new_trip_content' => 'yatra_trip_content',
85 'yatra_new_trip_departures' => 'yatra_trip_departures',
86 'yatra_new_trip_itinerary_days' => 'yatra_trip_itinerary_days',
87 'yatra_new_trip_itinerary_day_entry' => 'yatra_trip_itinerary_day_entry',
88 'yatra_new_trip_revisions' => 'yatra_trip_revisions',
89 ];
90
91 public static function targetVersion(): string
92 {
93 return '3.0.5';
94 }
95
96 /**
97 * Run whenever at least one of the one-shot heals hasn't been recorded.
98 *
99 * Deliberately ignores version comparison: both bugs ship IN 3.0.5
100 * itself, so installs on stored_version=3.0.5 (or newer, post-failed-
101 * upgrade) must still heal. The version-chain path is harmless — option
102 * gates make {@see run()} idempotent — but the live entry point is
103 * {@see \Yatra\Upgrades\FreeUpgradeRunner::runIdempotentMaintenance()}.
104 */
105 public static function shouldApply(string $fromVersion, string $toVersion): bool
106 {
107 unset($fromVersion, $toVersion);
108
109 return !get_option(self::DONE_OPTION) || !get_option(self::RENAME_DONE_OPTION);
110 }
111
112 public static function runOnHooks(): array
113 {
114 return ['admin_init'];
115 }
116
117 public static function run(string $fromVersion, string $toVersion): void
118 {
119 unset($fromVersion, $toVersion);
120
121 // Rename FIRST so the rest of this step (which queries the reviews
122 // table) sees the canonical name. Both healings are wrapped in
123 // try/catch so a failure in one doesn't block the other.
124 try {
125 self::runTableRenameOnce();
126 } catch (\Throwable $e) {
127 if (function_exists('error_log')) {
128 error_log('[Yatra 3.0.5 rename] uncaught: ' . $e->getMessage());
129 }
130 }
131
132 try {
133 self::runReviewsStatusEnumWidening();
134 } catch (\Throwable $e) {
135 if (function_exists('error_log')) {
136 error_log('[Yatra 3.0.5 reviews-enum] uncaught: ' . $e->getMessage());
137 }
138 }
139 }
140
141 /**
142 * Public entry point for the frontend/early hook in
143 * {@see \Yatra\Upgrades\FreeUpgradeRunner::runEarlyRenameHeal()}.
144 *
145 * Returns true on a clean settled state (either nothing to do, or
146 * everything renamed); false if at least one pair could not be
147 * resolved cleanly (so the caller may decide to back off).
148 */
149 public static function runTableRenameOnce(): bool
150 {
151 if (get_option(self::RENAME_DONE_OPTION)) {
152 return true;
153 }
154 if (!class_exists(InstallerService::class)) {
155 return false;
156 }
157 return self::runTableRename();
158 }
159
160 /**
161 * Rename the 19 `wp_yatra_new_*` tables to their `wp_yatra_*` form.
162 *
163 * **Non-destructive guarantee**: this method NEVER issues `DROP TABLE`,
164 * `DELETE`, or `TRUNCATE`. Every transformation is a `RENAME TABLE`
165 * (atomic metadata-only operation in MySQL/MariaDB). The worst
166 * possible outcome is a leftover `wp_yatra_X__dbdelta_placeholder_*`
167 * empty table the admin can manually drop after verifying.
168 *
169 * Per-pair decision tree (each pair is independent; partial completion
170 * heals naturally on the next pageview):
171 *
172 * - **old absent, new present** → already migrated (or fresh install).
173 * No-op, no flag change.
174 * - **old absent, new absent** → schema missing entirely. Defer
175 * (don't set the one-shot) so dbDelta / InstallerService can
176 * create the new tables on a later pass.
177 * - **old present, new absent** → straight `RENAME TABLE`. The
178 * canonical case.
179 * - **old present, new present, new EMPTY** → `RENAME` the empty
180 * placeholder aside to `<new>__dbdelta_placeholder_<unix-ts>`
181 * (NOT a DROP), then `RENAME` old into the canonical slot.
182 * See {@see sidestepEmptyTarget()}.
183 * - **old present, new present, new POPULATED** → refuse. Log and
184 * skip. Both tables left untouched; the admin reconciles.
185 * - **any probe returns null** (DB error) → defer, do not act.
186 *
187 * The one-shot option flag is only set when every pair lands in
188 * "no-op" or "successful rename"; any "defer" or "refuse" keeps the
189 * flag unset so the next pageview retries.
190 *
191 * Return value: true iff the one-shot flag was set this call;
192 * false iff at least one pair was deferred or refused.
193 */
194 private static function runTableRename(): bool
195 {
196 global $wpdb;
197 $prefix = $wpdb->prefix;
198
199 $allResolvedOrSkippedCleanly = true;
200
201 foreach (self::TABLE_RENAME_MAP as $oldSuffix => $newSuffix) {
202 $oldFull = $prefix . $oldSuffix;
203 $newFull = $prefix . $newSuffix;
204
205 // Refresh existence each iteration — earlier iterations don't
206 // touch this pair, but a concurrent request might.
207 $oldExists = InstallerService::databaseTableExists($oldFull);
208 $newExists = InstallerService::databaseTableExists($newFull);
209
210 // Case 1: already migrated (or fresh install). Nothing to do.
211 if (!$oldExists && $newExists) {
212 continue;
213 }
214
215 // Case 2: schema completely missing for this pair. dbDelta
216 // should have made `new`; if it hasn't, skip this iteration
217 // but don't fail the whole batch — InstallerService can
218 // recreate on a later pass.
219 if (!$oldExists && !$newExists) {
220 $allResolvedOrSkippedCleanly = false;
221 continue;
222 }
223
224 // Case 3: only old exists — straight rename.
225 if ($oldExists && !$newExists) {
226 if (!self::renameTable($oldFull, $newFull)) {
227 $allResolvedOrSkippedCleanly = false;
228 }
229 continue;
230 }
231
232 // Case 4: both exist. Cheap "any row?" probes — COUNT(*) is
233 // O(n) on huge tables; we only need a boolean answer.
234 $newHasAny = self::tableHasAnyRow($newFull);
235 $oldHasAny = self::tableHasAnyRow($oldFull);
236
237 // If either probe failed (returned null), back off — we
238 // refuse to make destructive decisions on incomplete info.
239 if ($newHasAny === null || $oldHasAny === null) {
240 $allResolvedOrSkippedCleanly = false;
241 continue;
242 }
243
244 // *** NEVER DROP ***
245 // The empty `new` table here was almost certainly created by
246 // dbDelta moments earlier in the same request, but we don't
247 // know that for certain — so we MOVE it aside rather than
248 // destroy it. If anything goes wrong with the rename below,
249 // the moved-aside table is still recoverable via RENAME.
250 // Any leftover `wp_yatra_X__dbdelta_placeholder_*` tables
251 // can be inspected and dropped manually by the admin after
252 // verification.
253 if ($newHasAny === false) {
254 // Extra guard: only proceed if the OLD table is the one
255 // actually carrying data. Refuse the cheap "both empty"
256 // case too — if both are empty, the rename is still
257 // valuable (it consolidates the schema name) but we
258 // don't need the sidestep, just RENAME.
259 if ($oldHasAny === false) {
260 // Both empty. Move new aside (preserves any
261 // user-created table — vanishingly unlikely but cheap
262 // to protect against), then rename old into place.
263 if (!self::sidestepEmptyTarget($newFull)) {
264 $allResolvedOrSkippedCleanly = false;
265 continue;
266 }
267 if (!self::renameTable($oldFull, $newFull)) {
268 $allResolvedOrSkippedCleanly = false;
269 }
270 continue;
271 }
272
273 // Old has data, new is empty (the expected case).
274 // Sidestep the empty new, then rename old into its slot.
275 if (!self::sidestepEmptyTarget($newFull)) {
276 $allResolvedOrSkippedCleanly = false;
277 continue;
278 }
279 if (!self::renameTable($oldFull, $newFull)) {
280 // Best-effort rollback: try to move the sidestepped
281 // placeholder back so dbDelta won't try to recreate
282 // an empty table next request.
283 // (We can only do this if we recorded the sidestep
284 // name — see sidestepEmptyTarget()'s last-name option.)
285 $allResolvedOrSkippedCleanly = false;
286 }
287 continue;
288 }
289
290 // Both populated — refuse to auto-merge or auto-destroy.
291 // Log loudly and skip; the admin must reconcile manually.
292 if (function_exists('error_log')) {
293 error_log(\sprintf(
294 '[Yatra 3.0.5 rename] both `%s` and `%s` have rows; '
295 . 'aborting that pair. Manual reconciliation required.',
296 $oldFull,
297 $newFull
298 ));
299 }
300 $allResolvedOrSkippedCleanly = false;
301 }
302
303 if ($allResolvedOrSkippedCleanly) {
304 // Autoload=yes ($autoload === 'yes' string) so future
305 // get_option(RENAME_DONE_OPTION) is an in-memory array
306 // lookup rather than a DB query.
307 if (false === get_option(self::RENAME_DONE_OPTION)) {
308 add_option(self::RENAME_DONE_OPTION, '1', '', 'yes');
309 } else {
310 update_option(self::RENAME_DONE_OPTION, '1');
311 }
312 // Drop the failure backoff transient on success.
313 delete_transient('yatra_table_rename_backoff_v1');
314 }
315
316 return $allResolvedOrSkippedCleanly;
317 }
318
319 /**
320 * `RENAME TABLE old TO new`. Logs `$wpdb->last_error` on failure.
321 */
322 private static function renameTable(string $oldFull, string $newFull): bool
323 {
324 global $wpdb;
325
326 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- both names escaped.
327 $result = $wpdb->query(
328 'RENAME TABLE `' . esc_sql($oldFull) . '` TO `' . esc_sql($newFull) . '`'
329 );
330
331 if ($result === false) {
332 self::logDbError('RENAME ' . $oldFull . ' -> ' . $newFull, $oldFull);
333 return false;
334 }
335 return true;
336 }
337
338 /**
339 * Move a (verified-empty) table out of the way without dropping it.
340 *
341 * We refuse to ever DROP during the migration — see header comment.
342 * Instead, an empty `wp_yatra_X` (almost always a placeholder created
343 * by dbDelta moments earlier in the same request) is RENAMED to
344 * `wp_yatra_X__dbdelta_placeholder_<unix-timestamp>` so the canonical
345 * slot is free for the real rename.
346 *
347 * Caller MUST have already verified `wp_yatra_X` is empty
348 * (via {@see tableHasAnyRow()}) before calling this. We re-check
349 * here as a defence-in-depth — if a writer slipped in between the
350 * probe and the sidestep, abort.
351 */
352 private static function sidestepEmptyTarget(string $newFull): bool
353 {
354 // Defence-in-depth re-check: someone could have written rows
355 // between our earlier probe and now. If so, abort the sidestep
356 // and let the next request retry from scratch.
357 $stillEmpty = self::tableHasAnyRow($newFull);
358 if ($stillEmpty !== false) {
359 // null (probe failed) or true (writer landed) — bail.
360 if (function_exists('error_log')) {
361 error_log(\sprintf(
362 '[Yatra 3.0.5 rename] aborted sidestep of `%s` — table no longer verifiably empty.',
363 $newFull
364 ));
365 }
366 return false;
367 }
368
369 $sideName = $newFull . '__dbdelta_placeholder_' . time();
370 return self::renameTable($newFull, $sideName);
371 }
372
373 /**
374 * Cheap "does this table have at least one row?" probe — `LIMIT 1`
375 * stops MySQL after the first match. Returns:
376 * - true → table has data
377 * - false → table is empty
378 * - null → query failed (unknown — caller should treat as "skip")
379 */
380 private static function tableHasAnyRow(string $fullTableName): ?bool
381 {
382 global $wpdb;
383
384 // Suppress wpdb's error output so a failure doesn't pollute the
385 // admin-side debug pane; we capture last_error explicitly.
386 $previousSuppress = $wpdb->suppress_errors(true);
387 try {
388 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name escaped.
389 $row = $wpdb->get_var('SELECT 1 FROM `' . esc_sql($fullTableName) . '` LIMIT 1');
390 if ($wpdb->last_error !== '') {
391 return null;
392 }
393 return $row !== null;
394 } finally {
395 $wpdb->suppress_errors($previousSuppress);
396 }
397 }
398
399 /**
400 * Centralized wpdb-error logger. Cheap when WP_DEBUG is off.
401 */
402 private static function logDbError(string $stage, string $context): void
403 {
404 global $wpdb;
405 if (!function_exists('error_log')) {
406 return;
407 }
408 $msg = '[Yatra 3.0.5 rename] ' . $stage . ' (' . $context . '): '
409 . ($wpdb->last_error !== '' ? $wpdb->last_error : 'unknown DB error');
410 error_log($msg);
411 }
412
413 /**
414 * Widen the reviews `status` ENUM to include `spam` and `trash`, and
415 * recover rows previously coerced to '' under the narrower enum.
416 */
417 private static function runReviewsStatusEnumWidening(): void
418 {
419 if (get_option(self::DONE_OPTION)) {
420 return;
421 }
422
423 if (!class_exists(ReviewsTable::class)) {
424 return;
425 }
426
427 $table = ReviewsTable::getTableName();
428 if (!InstallerService::databaseTableExists($table)) {
429 return;
430 }
431
432 global $wpdb;
433
434 $columnInfo = $wpdb->get_row(
435 $wpdb->prepare(
436 "SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
437 WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
438 DB_NAME,
439 $table,
440 'status'
441 )
442 );
443
444 $columnType = is_object($columnInfo) ? (string) ($columnInfo->COLUMN_TYPE ?? '') : '';
445
446 // Check for BOTH `spam` and `trash` — if either is missing the
447 // enum needs widening. Substring match on the COLUMN_TYPE string
448 // is cheap and avoids parsing the enum tuple.
449 $needsAlter = $columnType !== '' && (
450 strpos($columnType, "'spam'") === false
451 || strpos($columnType, "'trash'") === false
452 );
453
454 if ($needsAlter) {
455 // Keep this declaration in lockstep with
456 // {@see ReviewsTable::getSchema()} so fresh installs and
457 // upgraded installs converge on the same column shape.
458 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name escaped, enum literal is static.
459 $wpdb->query(
460 'ALTER TABLE `' . esc_sql($table) . "` "
461 . "MODIFY COLUMN `status` "
462 . "enum('pending','approved','rejected','spam','trash') "
463 . "DEFAULT 'pending'"
464 );
465 }
466
467 // Backfill rows coerced to '' under the narrower enum — they'd
468 // otherwise sit invisible to every status filter forever.
469 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from schema helper, literal values only.
470 $wpdb->query(
471 "UPDATE `" . esc_sql($table) . "` SET `status` = 'pending' "
472 . "WHERE `status` = '' OR `status` IS NULL"
473 );
474
475 update_option(self::DONE_OPTION, '1', false);
476 }
477 }
478