option_prefix = $option_prefix; $this->hook_prefix = $hook_prefix; $this->group = $group; } /** * The uninstaller for the other flavor's build whose Flavor.php is at $path, or null when that file declares no * flavor or the same prefixes as the loaded one (then the stored data belongs to the active build, not to it). */ public static function for_flavor_file( string $path ): ?self { $flavor = self::read_flavor_file( $path ); if ( ! isset( $flavor['OPTION_PREFIX'], $flavor['HOOK_PREFIX'], $flavor['GROUP'] ) ) { return null; } if ( Flavor::OPTION_PREFIX === $flavor['OPTION_PREFIX'] || Flavor::HOOK_PREFIX === $flavor['HOOK_PREFIX'] || Flavor::GROUP === $flavor['GROUP'] ) { return null; } return new self( $flavor['OPTION_PREFIX'], $flavor['HOOK_PREFIX'], $flavor['GROUP'] ); } /** * The string constants a Flavor.php declares, read from the file without loading the class (the other flavor's * Flavor class is already loaded under the same name). * * @return array Constant name => value. */ public static function read_flavor_file( string $path ): array { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local plugin file, not a URL. $source = is_readable( $path ) ? (string) file_get_contents( $path ) : ''; preg_match_all( "/^\s*const\s+([A-Z_]+)\s*=\s*'([^'\\\\]*)';/m", $source, $matches, PREG_SET_ORDER ); $constants = array(); foreach ( $matches as $match ) { $constants[ $match[1] ] = $match[2]; } return $constants; } /** * Revokes the key and deletes the 1.0.x webhooks (only with this build's own classes loaded: they read the * stored connection through Options), unschedules every action, then deletes the state meta, transients and * options. SEO post meta is merchant content and stays. */ public function run( bool $revoke ): void { if ( $revoke ) { ( new WebhookRegistrar() )->delete_all(); ( new KeyMinter() )->revoke( Options::key_id() ); ( new KeyMinter() )->revoke_pending(); } $this->unschedule(); $this->delete_meta(); foreach ( $this->transients() as $transient ) { delete_transient( $transient ); } $this->delete_cached_lists(); foreach ( $this->options() as $option ) { delete_option( $option ); } } /** * @return array */ public function options(): array { return array_map( array( $this, 'option' ), Options::ALL ); } /** * @return array */ public function transients(): array { return array( $this->option( Options::STATS_TRANSIENT ), $this->option( Options::STATS_FAILED_TRANSIENT ), $this->option( Options::ENRICHED_FAILED_TRANSIENT ), $this->option( Options::ENRICHED_FIELD_FAILED_TRANSIENT ), $this->option( Options::PRODUCT_SYNC_LOCK ) ); } /** * The cached enriched lists have hashed names, so they are found by prefix (value and timeout rows). */ private function delete_cached_lists(): void { global $wpdb; $name = $this->option( Flavor::OPTION_PREFIX . 'enriched_' ); foreach ( array( '_transient_', '_transient_timeout_' ) as $kind ) { $like = $wpdb->esc_like( $kind . $name ) . '%'; $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } } /** * Order and product meta the plugin keeps as its own state (not merchant content). * * @return array{paid: string, attempts: string, applied: string} */ public function meta(): array { return array( 'paid' => $this->meta_key( OrderEvents::PAID_META ), 'attempts' => $this->meta_key( SyncProduct::ATTEMPTS_META ), 'applied' => $this->meta_key( SyncProduct::APPLIED_META ), ); } /** * Hook => group of every Action Scheduler action the plugin schedules. * * @return array */ public function actions(): array { return array( $this->hook( OrderEvents::HOOK ) => $this->group( OrderEvents::GROUP ), $this->hook( ProductSync::HOOK ) => $this->group( ProductSync::GROUP ), $this->hook( SyncSchedule::HOOK ) => $this->group( SyncSchedule::GROUP ), $this->hook( SyncRun::PRODUCT_HOOK ) => $this->group( SyncRun::GROUP ), $this->hook( SyncRun::FINISH_HOOK ) => $this->group( SyncRun::GROUP ), ); } private function unschedule(): void { if ( ! function_exists( 'as_unschedule_all_actions' ) ) { return; } foreach ( $this->actions() as $hook => $group ) { // The stub types $args as array, but Action Scheduler treats null (not array()) as "any args"; array() // would only cancel actions whose args are exactly empty. Mirrors OrderEvents::unschedule_all(). // @phpstan-ignore-next-line argument.type as_unschedule_all_actions( $hook, null, $group ); } } /** * WooCommerce (and any WC_Order/HPOS helper) may not be loaded during uninstall, e.g. when it was deactivated or * deleted first, so order meta goes through WordPress core and $wpdb only. delete_post_meta_by_key() also clears * the meta cache; the HPOS wc_orders_meta table has no core API, so its rows go through a prepared $wpdb delete, * only once the table is known to exist (a store not on HPOS never has it). */ private function delete_meta(): void { $meta = $this->meta(); delete_post_meta_by_key( $meta['paid'] ); delete_post_meta_by_key( $meta['attempts'] ); delete_post_meta_by_key( $meta['applied'] ); global $wpdb; $table = $wpdb->prefix . 'wc_orders_meta'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- table-exists check before touching a table HPOS may never have created. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- see above. $wpdb->delete( $table, array( 'meta_key' => $meta['paid'] ) ); } } private function option( string $name ): string { return self::rebase( $name, Flavor::OPTION_PREFIX, $this->option_prefix ); } private function meta_key( string $name ): string { return self::rebase( $name, '_' . Flavor::OPTION_PREFIX, '_' . $this->option_prefix ); } private function hook( string $name ): string { return self::rebase( $name, Flavor::HOOK_PREFIX, $this->hook_prefix ); } private function group( string $name ): string { return self::rebase( $name, Flavor::GROUP, $this->group ); } /** * $name with its leading $from replaced by $to. Every name passed here starts with $from (built from it). */ public static function rebase( string $name, string $from, string $to ): string { return 0 === strpos( $name, $from ) ? $to . substr( $name, strlen( $from ) ) : $name; } }