prefix . Plugin::_NAME; } /** * The format of what `encode()` writes now. A stored value with a higher number was written by a newer * version of the plugin (or of Pro) and is left alone: it is not read and not overwritten. */ public const SCHEMA_VERSION = 1; /** * Turn a page value (the editor data of one menu with its pages) into its stored form: JSON, with the version * of the format as its first key. * * Text that is not valid UTF-8 is substituted, not dropped: wpdb would remove those bytes anyway. */ public static function encode(array $pageValue): string { $flags = JSON_PRESERVE_ZERO_FRACTION | JSON_INVALID_UTF8_SUBSTITUTE; // Not wp_json_encode(): this class must also work without WordPress (unit tests) and the flags are set here. return (string) json_encode(['schema_version' => self::SCHEMA_VERSION] + $pageValue, $flags); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode } /** * Turn a stored page value back into an array. Reads the JSON form and the PHP-serialized form that versions * before 1.0 wrote. Never instantiates classes. */ public static function decode(string $stored): array { if (self::formatOf($stored) === 0) { // Corrupt data must not raise a notice in the admin; it reads as an empty page value. $value = @unserialize($stored, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return is_array($value) ? $value : []; } if (! self::isSupported($stored)) { return []; } $value = json_decode($stored, true); if (! is_array($value)) { return []; } unset($value['schema_version']); return $value; } /** * The format of a stored value: 0 for the PHP-serialized form of versions before 1.0, otherwise the * `schema_version` in the JSON (1 for what `encode()` writes now). */ public static function formatOf(string $stored): int { if (substr(ltrim($stored), 0, 1) !== '{') { return 0; } $value = json_decode($stored, true); return is_array($value) && isset($value['schema_version']) ? max(1, (int) $value['schema_version']) : 1; } /** * @return bool False for a value of a newer format than this version of the plugin understands. */ public static function isSupported(string $stored): bool { return self::formatOf($stored) <= self::SCHEMA_VERSION; } /** * @return bool True when the table has at least one row (an empty table counts as missing, see analyse O20). */ public static function tableExists(): bool { $sql = sprintf('select 1 from `%s` LIMIT 1', self::tableName()); self::wpdb()->hide_errors(); $result = self::wpdb()->get_var($sql); self::wpdb()->show_errors(); return $result === '1'; } /** * @return array All rows, with `page_value` still serialized. */ public static function rows(): array { return (array) self::wpdb()->get_results(sprintf('SELECT * FROM %s', self::tableName())); } /** * @return array Objects with only the serialized `page_value`. */ public static function pageValueRows(): array { return (array) self::wpdb()->get_results(sprintf('SELECT page_value FROM %s', self::tableName())); } /** * @return array Zero or one row. */ public static function rowsById(int $id): array { return (array) self::wpdb()->get_results( self::wpdb()->prepare('SELECT * FROM ' . self::tableName() . ' WHERE id = %d', $id) ); } /** * @param string $stored The value as `encode()` gives it. * * @return int The id of the new row. */ public static function insert(string $menuSlug, string $stored): int { self::wpdb()->insert( self::tableName(), [ 'time' => current_time('mysql'), 'menu_slug' => $menuSlug, 'page_value' => $stored, 'schema_version' => self::formatOf($stored), 'plugin' => Plugin::_NAME, ] ); return (int) self::wpdb()->insert_id; } public static function update(int $id, string $menuSlug, string $stored): void { self::wpdb()->update( self::tableName(), [ 'time' => current_time('mysql'), 'menu_slug' => $menuSlug, 'page_value' => $stored, 'schema_version' => self::formatOf($stored), 'plugin' => Plugin::_NAME, ], ['ID' => $id] ); } /** * Rewrite the rows that are still PHP-serialized as JSON. A row is skipped when the JSON would read back * different from what the old form reads (see `Upgrader`). * * @return array{migrated: int, skipped: int} */ public static function migrateToJson(): array { $migrated = 0; $skipped = 0; $rows = (array) self::wpdb()->get_results( sprintf('SELECT id, page_value FROM %s WHERE schema_version < 1', self::tableName()) ); foreach ($rows as $row) { $value = self::decode($row->page_value); $json = self::encode($value); // Not empty (nothing to lose when it was corrupt is not a reason to rewrite it) and the same both ways. if ($value === [] || self::decode($json) !== $value || self::isIncomplete($value)) { $skipped++; continue; } self::wpdb()->update( self::tableName(), ['page_value' => $json, 'schema_version' => self::SCHEMA_VERSION], ['id' => (int) $row->id], ['%s', '%d'], ['%d'] ); $migrated++; } return ['migrated' => $migrated, 'skipped' => $skipped]; } /** * @return bool True when the value holds an object of a class that was not allowed (it cannot be JSON). */ private static function isIncomplete($value): bool { if ($value instanceof \__PHP_Incomplete_Class || is_object($value)) { return true; } if (is_array($value)) { foreach ($value as $item) { if (self::isIncomplete($item)) { return true; } } } return false; } /** * Empty the table and put the given rows in it, in the PHP-serialized form (`schema_version` 0). For the way * back to 0.9.x, see `Upgrader::restoreBackup()`. * * @param array $rows * * @return int The number of rows put back. */ public static function replaceAllWithLegacyRows(array $rows): int { self::wpdb()->query(sprintf('DELETE FROM %s', self::tableName())); $count = 0; foreach ($rows as $row) { $count += (int) (bool) self::wpdb()->insert(self::tableName(), [ 'id' => (int) $row['id'], 'time' => $row['time'], 'menu_slug' => $row['menu_slug'], 'page_value' => $row['page_value'], 'schema_version' => 0, 'plugin' => $row['plugin'], ]); } return $count; } /** * @return bool|int Number of deleted rows or false on error. */ public static function delete(int $id) { return self::wpdb()->delete(self::tableName(), ['id' => $id], '%d'); } /** * @return array Objects with `option_name`, every row of the WordPress options table. */ public static function optionNamesInOptionsTable(): array { return (array) self::wpdb()->get_results( sprintf('SELECT option_name FROM %s', self::wpdb()->prefix . 'options') ); } }