@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 397 lines 11 kB view raw
1<?php 2 3final class PhabricatorStorageManagementAPI extends Phobject { 4 5 private $ref; 6 private $host; 7 private $user; 8 private $port; 9 private $password; 10 private $namespace; 11 private $conns = array(); 12 private $disableUTF8MB4; 13 14 const CHARSET_DEFAULT = 'CHARSET'; 15 const CHARSET_SORT = 'CHARSET_SORT'; 16 const CHARSET_FULLTEXT = 'CHARSET_FULLTEXT'; 17 const COLLATE_TEXT = 'COLLATE_TEXT'; 18 const COLLATE_SORT = 'COLLATE_SORT'; 19 const COLLATE_FULLTEXT = 'COLLATE_FULLTEXT'; 20 21 const TABLE_STATUS = 'patch_status'; 22 const TABLE_HOSTSTATE = 'hoststate'; 23 24 public function setDisableUTF8MB4($disable_utf8_mb4) { 25 $this->disableUTF8MB4 = $disable_utf8_mb4; 26 return $this; 27 } 28 29 public function getDisableUTF8MB4() { 30 return $this->disableUTF8MB4; 31 } 32 33 public function setNamespace($namespace) { 34 $this->namespace = $namespace; 35 PhabricatorLiskDAO::pushStorageNamespace($namespace); 36 return $this; 37 } 38 39 public function getNamespace() { 40 return $this->namespace; 41 } 42 43 public function setUser($user) { 44 $this->user = $user; 45 return $this; 46 } 47 48 public function getUser() { 49 return $this->user; 50 } 51 52 public function setPassword($password) { 53 $this->password = $password; 54 return $this; 55 } 56 57 public function getPassword() { 58 return $this->password; 59 } 60 61 public function setHost($host) { 62 $this->host = $host; 63 return $this; 64 } 65 66 public function getHost() { 67 return $this->host; 68 } 69 70 public function setPort($port) { 71 $this->port = $port; 72 return $this; 73 } 74 75 public function getPort() { 76 return $this->port; 77 } 78 79 public function setRef(PhabricatorDatabaseRef $ref) { 80 $this->ref = $ref; 81 return $this; 82 } 83 84 public function getRef() { 85 return $this->ref; 86 } 87 88 public function getDatabaseName($fragment) { 89 return $this->namespace.'_'.$fragment; 90 } 91 92 public function getInternalDatabaseName($name) { 93 $namespace = $this->getNamespace(); 94 95 $prefix = $namespace.'_'; 96 if (strncmp($name, $prefix, strlen($prefix))) { 97 return null; 98 } 99 100 return substr($name, strlen($prefix)); 101 } 102 103 public function getDisplayName() { 104 return $this->getRef()->getDisplayName(); 105 } 106 107 /** 108 * @param array<PhabricatorStoragePatch> $patches 109 * @param bool $only_living 110 */ 111 public function getDatabaseList(array $patches, $only_living = false) { 112 assert_instances_of($patches, PhabricatorStoragePatch::class); 113 114 $list = array(); 115 116 foreach ($patches as $patch) { 117 if ($patch->getType() == 'db') { 118 if ($only_living && $patch->isDead()) { 119 continue; 120 } 121 $list[] = $this->getDatabaseName($patch->getName()); 122 } 123 } 124 125 return $list; 126 } 127 128 public function getConn($fragment) { 129 $database = $this->getDatabaseName($fragment); 130 $return = &$this->conns[$this->host][$this->user][$database]; 131 if (!$return) { 132 $return = PhabricatorDatabaseRef::newRawConnection( 133 array( 134 'user' => $this->user, 135 'pass' => $this->password, 136 'host' => $this->host, 137 'port' => $this->port, 138 'database' => $fragment 139 ? $database 140 : null, 141 )); 142 } 143 return $return; 144 } 145 146 public function getAppliedPatches() { 147 try { 148 $applied = queryfx_all( 149 $this->getConn('meta_data'), 150 'SELECT patch FROM %T', 151 self::TABLE_STATUS); 152 return ipull($applied, 'patch'); 153 } catch (AphrontAccessDeniedQueryException $ex) { 154 throw new Exception( 155 pht( 156 'Failed while trying to read schema status: the database "%s" '. 157 'exists, but the current user ("%s") does not have permission to '. 158 'access it. GRANT the current user more permissions, or use a '. 159 'different user.', 160 $this->getDatabaseName('meta_data'), 161 $this->getUser()), 162 0, 163 $ex); 164 } catch (AphrontQueryException $ex) { 165 return null; 166 } 167 } 168 169 public function getPatchDurations() { 170 try { 171 $rows = queryfx_all( 172 $this->getConn('meta_data'), 173 'SELECT patch, duration FROM %T WHERE duration IS NOT NULL', 174 self::TABLE_STATUS); 175 return ipull($rows, 'duration', 'patch'); 176 } catch (AphrontQueryException $ex) { 177 return array(); 178 } 179 } 180 181 public function createDatabase($fragment) { 182 $info = $this->getCharsetInfo(); 183 184 queryfx( 185 $this->getConn(null), 186 'CREATE DATABASE IF NOT EXISTS %T COLLATE %T', 187 $this->getDatabaseName($fragment), 188 $info[self::COLLATE_TEXT]); 189 } 190 191 public function createTable($fragment, $table, array $cols) { 192 queryfx( 193 $this->getConn($fragment), 194 'CREATE TABLE IF NOT EXISTS %T.%T (%Q) '. 195 'ENGINE=InnoDB, COLLATE utf8_general_ci', 196 $this->getDatabaseName($fragment), 197 $table, 198 implode(', ', $cols)); 199 } 200 201 /** 202 * @param array<PhabricatorStoragePatch> $patches 203 */ 204 public function getLegacyPatches(array $patches) { 205 assert_instances_of($patches, PhabricatorStoragePatch::class); 206 207 try { 208 $row = queryfx_one( 209 $this->getConn('meta_data'), 210 'SELECT version FROM %T', 211 'schema_version'); 212 $version = $row['version']; 213 } catch (AphrontQueryException $ex) { 214 return array(); 215 } 216 217 $legacy = array(); 218 foreach ($patches as $key => $patch) { 219 if ($patch->getLegacy() !== false && $patch->getLegacy() <= $version) { 220 $legacy[] = $key; 221 } 222 } 223 224 return $legacy; 225 } 226 227 public function markPatchApplied($patch, $duration = null) { 228 $conn = $this->getConn('meta_data'); 229 230 queryfx( 231 $conn, 232 'INSERT INTO %T (patch, applied) VALUES (%s, %d)', 233 self::TABLE_STATUS, 234 $patch, 235 time()); 236 237 // We didn't add this column for a long time, so it may not exist yet. 238 if ($duration !== null) { 239 try { 240 queryfx( 241 $conn, 242 'UPDATE %T SET duration = %d WHERE patch = %s', 243 self::TABLE_STATUS, 244 (int)floor($duration * 1000000), 245 $patch); 246 } catch (AphrontQueryException $ex) { 247 // Just ignore this, as it almost certainly indicates that we just 248 // don't have the column yet. 249 } 250 } 251 } 252 253 public function applyPatch(PhabricatorStoragePatch $patch) { 254 $type = $patch->getType(); 255 $name = $patch->getName(); 256 switch ($type) { 257 case 'db': 258 $this->createDatabase($name); 259 break; 260 case 'sql': 261 $this->applyPatchSQL($name); 262 break; 263 case 'php': 264 $this->applyPatchPHP($name); 265 break; 266 default: 267 throw new Exception(pht("Unable to apply patch of type '%s'.", $type)); 268 } 269 } 270 271 public function applyPatchSQL($sql) { 272 $sql = Filesystem::readFile($sql); 273 $queries = preg_split('/;\s+/', $sql); 274 $queries = array_filter($queries); 275 276 $conn = $this->getConn(null); 277 278 $charset_info = $this->getCharsetInfo(); 279 foreach ($charset_info as $key => $value) { 280 $charset_info[$key] = qsprintf($conn, '%T', $value); 281 } 282 283 foreach ($queries as $query) { 284 $query = str_replace('{$NAMESPACE}', $this->namespace, $query); 285 286 foreach ($charset_info as $key => $value) { 287 $query = str_replace('{$'.$key.'}', $value, $query); 288 } 289 290 try { 291 // NOTE: We're using the unsafe "%Z" conversion here. There's no 292 // avoiding it since we're executing raw text files full of SQL. 293 queryfx($conn, '%Z', $query); 294 } catch (AphrontAccessDeniedQueryException $ex) { 295 throw new Exception( 296 pht( 297 'Unable to access a required database or table. This almost '. 298 'always means that the user you are connecting with ("%s") does '. 299 'not have sufficient permissions granted in MySQL. You can '. 300 'use `bin/storage databases` to get a list of all databases '. 301 'permission is required on.', 302 $this->getUser()), 303 0, 304 $ex); 305 } 306 } 307 } 308 309 public function applyPatchPHP($script) { 310 $schema_conn = $this->getConn(null); 311 require_once $script; 312 } 313 314 public function isCharacterSetAvailable($character_set) { 315 if ($character_set == 'utf8mb4') { 316 if ($this->getDisableUTF8MB4()) { 317 return false; 318 } 319 } 320 321 $conn = $this->getConn(null); 322 return self::isCharacterSetAvailableOnConnection($character_set, $conn); 323 } 324 325 public function getClientCharset() { 326 if ($this->isCharacterSetAvailable('utf8mb4')) { 327 return 'utf8mb4'; 328 } else { 329 return 'utf8'; 330 } 331 } 332 333 public static function isCharacterSetAvailableOnConnection( 334 $character_set, 335 AphrontDatabaseConnection $conn) { 336 $result = queryfx_one( 337 $conn, 338 'SELECT CHARACTER_SET_NAME FROM INFORMATION_SCHEMA.CHARACTER_SETS 339 WHERE CHARACTER_SET_NAME = %s', 340 $character_set); 341 342 return (bool)$result; 343 } 344 345 public function getCharsetInfo() { 346 if ($this->isCharacterSetAvailable('utf8mb4')) { 347 // If utf8mb4 is available, we use it with the utf8mb4_unicode_ci 348 // collation. This is most correct, and will sort properly. 349 350 $charset = 'utf8mb4'; 351 $charset_sort = 'utf8mb4'; 352 $charset_full = 'utf8mb4'; 353 $collate_text = 'utf8mb4_bin'; 354 $collate_sort = 'utf8mb4_unicode_ci'; 355 $collate_full = 'utf8mb4_unicode_ci'; 356 } else { 357 // If utf8mb4 is not available, we use binary for most data. This allows 358 // us to store 4-byte unicode characters. 359 // 360 // It's possible that strings will be truncated in the middle of a 361 // character on insert. We encourage users to set STRICT_ALL_TABLES 362 // to prevent this. 363 // 364 // For "fulltext" and "sort" columns, we don't use binary. 365 // 366 // With "fulltext", we can not use binary because MySQL won't let us. 367 // We use 3-byte utf8 instead and accept being unable to index 4-byte 368 // characters. 369 // 370 // With "sort", if we use binary we lose case insensitivity (for 371 // example, "ALincoln@example.com" and "alincoln@example.com" would no 372 // longer be identified as the same email address). This can be very 373 // confusing and is far worse overall than not supporting 4-byte unicode 374 // characters, so we use 3-byte utf8 and accept limited 4-byte support as 375 // a tradeoff to get sensible collation behavior. Many columns where 376 // collation is important rarely contain 4-byte characters anyway, so we 377 // are not giving up too much. 378 379 $charset = 'binary'; 380 $charset_sort = 'utf8'; 381 $charset_full = 'utf8'; 382 $collate_text = 'binary'; 383 $collate_sort = 'utf8_general_ci'; 384 $collate_full = 'utf8_general_ci'; 385 } 386 387 return array( 388 self::CHARSET_DEFAULT => $charset, 389 self::CHARSET_SORT => $charset_sort, 390 self::CHARSET_FULLTEXT => $charset_full, 391 self::COLLATE_TEXT => $collate_text, 392 self::COLLATE_SORT => $collate_sort, 393 self::COLLATE_FULLTEXT => $collate_full, 394 ); 395 } 396 397}