@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
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}