db = $db; } public static function mapping(array $tables, string $tag, array $constraints): array { $map = []; foreach ($tables as $t) { $map[$t] = Naming::backup($t, $tag); } return ['tag' => $tag, 'tables' => $map, 'constraints' => $constraints, 'created_at' => gmdate('Y-m-d H:i:s')]; } /** Live tables become backups and shadow tables go live, in one atomic RENAME. */ public function swap(array $tables, string $tag, array $constraints): array { $point = self::mapping($tables, $tag, $constraints); $renames = []; foreach ($point['tables'] as $t => $backup) { $renames[] = Db::ident($t) . ' TO ' . Db::ident($backup); $renames[] = Db::ident(Naming::temp($t)) . ' TO ' . Db::ident($t); } $this->rename_and_fix_keys($renames, array_values($point['tables']), $constraints); return $point; } /** * Shadow tables go live for every table in $tables; live tables that exist become backups, ones that * don't are recorded as absent. $constraints are the incoming tables' foreign keys. */ public function swap_in(array $tables, string $tag, array $constraints): array { $existing = []; $absent = []; foreach ($tables as $t) { if ($this->db->table_exists($t)) { $existing[] = $t; } else { $absent[] = $t; } } $point = self::mapping($existing, $tag, $this->live_constraints($existing)) + ['absent' => $absent]; $renames = []; foreach ($point['tables'] as $t => $backup) { $renames[] = Db::ident($t) . ' TO ' . Db::ident($backup); } foreach ($tables as $t) { $renames[] = Db::ident(Naming::temp($t)) . ' TO ' . Db::ident($t); } $this->rename_and_fix_keys($renames, array_values($point['tables']), $constraints); return $point; } /** Backups go live again (tables that were absent are moved aside); the replaced state becomes a new restore point. */ public function restore(array $point, string $new_tag): array { $map = []; $absent = []; $renames = []; foreach ($point['tables'] as $t => $backup) { if (!$this->db->table_exists($backup)) { /* translators: %s: table name */ throw new RelayError('restore.missing_backup', esc_html(sprintf(__('The backup of %s no longer exists.', 'siterelay'), $t))); } if ($this->db->table_exists($t)) { $map[$t] = Naming::backup($t, $new_tag); $renames[] = Db::ident($t) . ' TO ' . Db::ident($map[$t]); } else { $absent[] = $t; } $renames[] = Db::ident($backup) . ' TO ' . Db::ident($t); } foreach ($point['absent'] ?? [] as $t) { if ($this->db->table_exists($t)) { $map[$t] = Naming::backup($t, $new_tag); $renames[] = Db::ident($t) . ' TO ' . Db::ident($map[$t]); } } $undo = $this->live_constraints(array_keys($map)); $this->rename_and_fix_keys($renames, array_values($map), $point['constraints'] ?? []); return ['tag' => $new_tag, 'tables' => $map, 'constraints' => $undo, 'absent' => $absent, 'created_at' => gmdate('Y-m-d H:i:s')]; } public function drop(array $point): void { foreach ($point['tables'] ?? [] as $backup) { $this->db->raw('DROP TABLE IF EXISTS ' . Db::ident($backup)); } } /** @return array table => ALTER TABLE ... ADD CONSTRAINT for tables that have foreign keys */ private function live_constraints(array $tables): array { $out = []; foreach ($tables as $t) { $alter = CreateTable::build($this->db, $t, $t, $t)['constraints']; if ($alter !== null) { $out[$t] = $alter; } } return $out; } /** * Foreign-key names are unique per database, so every foreign key is dropped from the tables that just * became backups before the incoming tables' keys are added. */ private function rename_and_fix_keys(array $renames, array $backups, array $constraints): void { if ($renames === []) { return; // a migration of files only } $this->db->raw('SET FOREIGN_KEY_CHECKS = 0'); try { $this->db->raw('RENAME TABLE ' . implode(', ', $renames)); foreach ($backups as $backup) { foreach ($this->db->foreign_keys($backup) as $name) { $this->db->raw('ALTER TABLE ' . Db::ident($backup) . ' DROP FOREIGN KEY ' . Db::ident($name)); } } foreach ($constraints as $alter) { $this->db->raw($alter); } } finally { $this->db->raw('SET FOREIGN_KEY_CHECKS = 1'); } } }