PluginProbe ʕ •ᴥ•ʔ
Menu Icons by Themeisle – Add Icons to Navigation Menus / 0.13.24
Menu Icons by Themeisle – Add Icons to Navigation Menus v0.13.24
0.13.24 trunk 0.1.0 0.1.1 0.1.2 0.1.3 0.1.4 0.1.5 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.11.2 0.11.3 0.11.4 0.11.5 0.12.0 0.12.1 0.12.10 0.12.11 0.12.12 0.12.2 0.12.3 0.12.4 0.12.5 0.12.6 0.12.7 0.12.8 0.12.9 0.13.0 0.13.1 0.13.10 0.13.11 0.13.12 0.13.13 0.13.14 0.13.15 0.13.16 0.13.17 0.13.18 0.13.19 0.13.2 0.13.20 0.13.21 0.13.22 0.13.23 0.13.3 0.13.4 0.13.5 0.13.6 0.13.7 0.13.8 0.13.9 0.2.0 0.2.1 0.2.2 0.2.3 0.3.0 0.3.1 0.3.2 0.4.0 0.5.0 0.5.1 0.6.0 0.7.0 0.8.0 0.8.1 0.9.0 0.9.2
menu-icons / vendor / codeinwp / themeisle-sdk / src / Modules / Migrator.php
menu-icons / vendor / codeinwp / themeisle-sdk / src / Modules Last commit date
About_us.php 5 days ago Abstract_Migration.php 4 months ago Announcements.php 4 months ago Compatibilities.php 2 years ago Crash_reporter.php 5 days ago Dashboard_widget.php 5 days ago Featured_plugins.php 5 days ago Float_widget.php 2 years ago Licenser.php 5 days ago Logger.php 5 days ago Migrator.php 5 days ago Notification.php 3 years ago Promotions.php 5 days ago Recommendation.php 3 years ago Review.php 1 year ago Rollback.php 2 years ago Script_loader.php 1 year ago Translate.php 5 years ago Translations.php 1 year ago Uninstall_feedback.php 5 days ago Welcome.php 2 years ago
Migrator.php
398 lines
1 <?php
2 /**
3 * The migrator module for ThemeIsle SDK.
4 *
5 * @package ThemeIsleSDK
6 * @subpackage Modules
7 * @copyright Copyright (c) 2024, Themeisle
8 * @license http://opensource.org/licenses/gpl-3.0.php GNU Public License
9 * @since 3.3.50
10 */
11
12 namespace ThemeisleSDK\Modules;
13
14 use ThemeisleSDK\Common\Abstract_Module;
15 use ThemeisleSDK\Product;
16
17 // Exit if accessed directly.
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Migrator module for ThemeIsle SDK.
24 *
25 * Allows products to ship PHP migration files that run automatically on the
26 * first complete request for each version. Each product opts in by registering
27 * its migrations directory via the `{product_slug}_sdk_migrations_path` filter.
28 */
29 class Migrator extends Abstract_Module {
30 /**
31 * Option key suffix used to store the list of ran migrations.
32 */
33 const OPTION_SUFFIX = '_ran_migrations';
34
35 /**
36 * Option key suffix used to store the last fully migrated product version.
37 */
38 const VERSION_OPTION_SUFFIX = '_migrated_version';
39
40 /**
41 * Option/cache key suffix used while migrations are running.
42 */
43 const LOCK_SUFFIX = '_migration_lock';
44
45 /**
46 * Cache group used for migration locks.
47 */
48 const LOCK_CACHE_GROUP = 'themeisle_sdk_migrations';
49
50 /**
51 * Maximum lock lifetime in seconds.
52 */
53 const LOCK_TTL = 300;
54
55 /**
56 * Token identifying the lock owned by this instance.
57 *
58 * @var string
59 */
60 private $lock_token = '';
61
62 /**
63 * Serialized value used by the database lock.
64 *
65 * @var string
66 */
67 private $lock_value = '';
68
69 /**
70 * Lock backend used by this instance.
71 *
72 * @var string
73 */
74 private $lock_driver = '';
75
76 /**
77 * Check if we should load the module for this product.
78 *
79 * Always returns true — the actual path check happens lazily at wp_loaded.
80 *
81 * @param Product $product Product to load the module for.
82 *
83 * @return bool
84 */
85 public function can_load( $product ) {
86 return apply_filters( $product->get_slug() . '_sdk_enable_migrator', true );
87 }
88
89 /**
90 * Load module logic.
91 *
92 * @param Product $product Product to load.
93 *
94 * @return Migrator
95 */
96 public function load( $product ) {
97 $this->product = $product;
98 add_action( 'wp_loaded', array( $this, 'run_pending' ) );
99 add_action( 'themeisle_sdk_rollback_migration_' . $product->get_slug(), array( $this, 'rollback' ) );
100 return $this;
101 }
102
103 /**
104 * Discover and run any pending migrations for the product.
105 *
106 * Runs on the first complete request for each product version. The migrated
107 * version is recorded only after all pending migrations finish successfully,
108 * so interrupted or failed runs are retried on the next request.
109 *
110 * @return void
111 */
112 public function run_pending() {
113 $path = $this->get_migrations_path();
114
115 if ( empty( $path ) || ! is_dir( $path ) ) {
116 return;
117 }
118
119 $version_key = $this->product->get_key() . self::VERSION_OPTION_SUFFIX;
120 $current_version = $this->product->get_version();
121
122 if ( get_option( $version_key, '' ) === $current_version ) {
123 return;
124 }
125
126 if ( ! $this->acquire_lock() ) {
127 return;
128 }
129
130 // Another request may have completed while this request waited for the lock.
131 if ( get_option( $version_key, '' ) === $current_version ) {
132 $this->release_lock();
133 return;
134 }
135
136 if ( $this->execute_pending( $path ) ) {
137 update_option( $version_key, $current_version );
138 }
139
140 $this->release_lock();
141 }
142
143 /**
144 * Execute all pending migration files.
145 *
146 * @param string $path Absolute migrations directory path.
147 *
148 * @return bool True when every migration was handled successfully.
149 */
150 private function execute_pending( $path ) {
151 $files = glob( trailingslashit( $path ) . '*.php' );
152
153 if ( false === $files ) {
154 $this->log_error( 'discovery', 'failed to read the migrations directory' );
155 return false;
156 }
157
158 if ( empty( $files ) ) {
159 return true;
160 }
161
162 sort( $files ); // Alphabetical order = chronological order given timestamp naming.
163
164 $option_key = $this->product->get_key() . self::OPTION_SUFFIX;
165 $ran = get_option( $option_key, array() );
166 $ran = is_array( $ran ) ? $ran : array();
167
168 foreach ( $files as $file ) {
169 $name = basename( $file, '.php' );
170
171 if ( in_array( $name, $ran, true ) ) {
172 continue;
173 }
174
175 try {
176 $migration = require $file; // Migration files return an anonymous class instance.
177
178 if ( ! ( $migration instanceof Abstract_Migration ) ) {
179 $this->log_error( $name, 'migration file must return an Abstract_Migration instance' );
180 return false;
181 }
182
183 if ( ! $migration->should_run() ) {
184 continue;
185 }
186
187 $migration->up();
188 $ran[] = $name;
189 update_option( $option_key, $ran );
190 } catch ( \Throwable $e ) {
191 // Stop and leave the product version incomplete so the next request retries.
192 $this->log_error( $name, $e->getMessage() );
193 return false;
194 }
195 }
196
197 return true;
198 }
199
200 /**
201 * Acquire a cross-request migration lock.
202 *
203 * Persistent object caches provide an atomic add operation. Sites without
204 * one use an atomic database insert/compare-and-swap fallback.
205 *
206 * @return bool True when the lock was acquired.
207 */
208 private function acquire_lock() {
209 $this->lock_token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( '', true );
210
211 if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() ) {
212 $acquired = wp_cache_add(
213 $this->get_lock_key(),
214 $this->lock_token,
215 self::LOCK_CACHE_GROUP,
216 self::LOCK_TTL
217 );
218
219 if ( $acquired ) {
220 $this->lock_driver = 'cache';
221 }
222
223 return $acquired;
224 }
225
226 return $this->acquire_database_lock();
227 }
228
229 /**
230 * Acquire the database lock used without a persistent object cache.
231 *
232 * @return bool True when the lock was acquired.
233 */
234 private function acquire_database_lock() {
235 global $wpdb;
236
237 $key = $this->get_lock_key();
238 $this->lock_value = wp_json_encode(
239 array(
240 'token' => $this->lock_token,
241 'expires' => time() + self::LOCK_TTL,
242 )
243 );
244
245 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
246 $inserted = $wpdb->query(
247 $wpdb->prepare(
248 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
249 $key,
250 $this->lock_value,
251 'no'
252 )
253 );
254
255 if ( 1 === (int) $inserted ) {
256 $this->lock_driver = 'database';
257 return true;
258 }
259
260 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
261 $existing = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) );
262 $lock = json_decode( (string) $existing, true );
263
264 if ( is_array( $lock ) && ! empty( $lock['expires'] ) && (int) $lock['expires'] >= time() ) {
265 return false;
266 }
267
268 // Replace an expired lock only if it has not changed since it was read.
269 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
270 $updated = $wpdb->query(
271 $wpdb->prepare(
272 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
273 $this->lock_value,
274 $key,
275 $existing
276 )
277 );
278
279 if ( 1 === (int) $updated ) {
280 $this->lock_driver = 'database';
281 return true;
282 }
283
284 return false;
285 }
286
287 /**
288 * Release the lock owned by this instance.
289 *
290 * @return void
291 */
292 private function release_lock() {
293 if ( 'cache' === $this->lock_driver ) {
294 $current_token = wp_cache_get( $this->get_lock_key(), self::LOCK_CACHE_GROUP );
295
296 if ( $current_token === $this->lock_token ) {
297 wp_cache_delete( $this->get_lock_key(), self::LOCK_CACHE_GROUP );
298 }
299 }
300
301 if ( 'database' === $this->lock_driver ) {
302 global $wpdb;
303
304 // Delete only the lock value created by this instance.
305 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
306 $wpdb->query(
307 $wpdb->prepare(
308 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
309 $this->get_lock_key(),
310 $this->lock_value
311 )
312 );
313 }
314
315 $this->lock_token = '';
316 $this->lock_value = '';
317 $this->lock_driver = '';
318 }
319
320 /**
321 * Get the migration lock key.
322 *
323 * @return string Lock key.
324 */
325 private function get_lock_key() {
326 return $this->product->get_key() . self::LOCK_SUFFIX;
327 }
328
329 /**
330 * Log a migration failure.
331 *
332 * @param string $name Migration name or operation.
333 * @param string $message Error message.
334 *
335 * @return void
336 */
337 private function log_error( $name, $message ) {
338 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
339 error_log( 'ThemeIsle SDK Migrator: failed to run ' . $name . ': ' . $message );
340 }
341
342 /**
343 * Roll back a single migration by name.
344 *
345 * Calls down() on the migration and removes it from the ran list so it will
346 * be picked up again on the next upgrade. This method is never called
347 * automatically — products invoke it explicitly when needed.
348 *
349 * @param string $migration_name Migration basename without .php extension.
350 *
351 * @return bool True if rolled back successfully, false if not found or not previously run.
352 */
353 public function rollback( $migration_name ) {
354 $option_key = $this->product->get_key() . self::OPTION_SUFFIX;
355 $ran = get_option( $option_key, array() );
356
357 if ( ! in_array( $migration_name, $ran, true ) ) {
358 return false;
359 }
360
361 $path = $this->get_migrations_path();
362 $file = trailingslashit( $path ) . $migration_name . '.php';
363
364 if ( ! is_file( $file ) ) {
365 return false;
366 }
367
368 try {
369 $migration = require $file;
370
371 if ( ! ( $migration instanceof Abstract_Migration ) ) {
372 return false;
373 }
374
375 $migration->down();
376 update_option( $option_key, array_values( array_diff( $ran, array( $migration_name ) ) ) );
377
378 return true;
379 } catch ( \Throwable $e ) {
380 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
381 error_log( 'ThemeIsle SDK Migrator: failed to roll back ' . $migration_name . ': ' . $e->getMessage() );
382
383 return false;
384 }
385 }
386
387 /**
388 * Get the migrations directory path for the current product.
389 *
390 * Products register their path via the `{slug}_sdk_migrations_path` filter.
391 *
392 * @return string Absolute path to the migrations directory, or empty string.
393 */
394 private function get_migrations_path() {
395 return (string) apply_filters( $this->product->get_slug() . '_sdk_migrations_path', '' );
396 }
397 }
398