| 1 |
<?php |
| 2 |
if (!defined('ABSPATH')) { |
| 3 |
exit; |
| 4 |
} |
| 5 |
|
| 6 |
require_once __DIR__ . '/DatabaseMetadataLockWaitGuard.php'; |
| 7 |
|
| 8 |
/** |
| 9 |
* Exclusive occupancy of a single WordPress options row: at most one request |
| 10 |
* can hold a given option name, and the DATABASE decides which one. |
| 11 |
* |
| 12 |
* Every mutual-exclusion mechanism in this plugin that lives in the options |
| 13 |
* table goes through here, because the obvious ways to write one are both |
| 14 |
* wrong on a site with no persistent object cache (which is the default): |
| 15 |
* |
| 16 |
* - Reading the row with get_option() and then writing it. WordPress serves |
| 17 |
* get_option() from the object cache and update_option() primes that cache |
| 18 |
* with the value it just wrote, so each racing request reads back its OWN |
| 19 |
* write and every one of them concludes it won. Error report 270 |
| 20 |
* (dianthus.zuidplas.net, 4.3.3, LiteSpeed, MySQL 8.0.46) is what that |
| 21 |
* looks like in production: two requests ran the 4.3.2 to 4.3.3 database |
| 22 |
* upgrade in the same second, each holding what it believed was the |
| 23 |
* exclusive 'update_db_version' lock. |
| 24 |
* |
| 25 |
* - Trusting add_option() to return false when the row already exists. |
| 26 |
* WordPress guards it with that same cache-served get_option(), and since |
| 27 |
* 6.4 the write underneath is `INSERT ... ON DUPLICATE KEY UPDATE`, which |
| 28 |
* overwrites rather than failing. Two concurrent callers can therefore |
| 29 |
* both be told they added it. (On older WordPress the write was a plain |
| 30 |
* INSERT that a duplicate key did reject, so this is a claim that used to |
| 31 |
* hold and quietly stopped holding.) |
| 32 |
* |
| 33 |
* What does arbitrate is the options table's own UNIQUE(option_name) index: |
| 34 |
* an INSERT can satisfy it exactly once no matter how many arrive together. |
| 35 |
* So claim() is an INSERT, and every read here is direct SQL rather than |
| 36 |
* get_option(), because the answer to "who holds this" can only come from the |
| 37 |
* one store all the requests share. |
| 38 |
* |
| 39 |
* This class deliberately knows nothing about WHY a row is being held: no |
| 40 |
* timeouts, no owner ids, no stale-record policy. Those differ per caller |
| 41 |
* (ABJ_404_Solution_SynchronizationUtils breaks a lock on age and releases by |
| 42 |
* owner id; the rebuild locks expire on a TTL) and belong with the caller. |
| 43 |
*/ |
| 44 |
class ABJ_404_Solution_ExclusiveOptionRow { |
| 45 |
|
| 46 |
/** Rows live in the options table of the blog serving the request. */ |
| 47 |
const SCOPE_CURRENT_BLOG = 'current_blog'; |
| 48 |
|
| 49 |
/** Rows live in the options table of the network's MAIN SITE, so every blog |
| 50 |
* in a multisite network contends for the same row. |
| 51 |
* |
| 52 |
* This is where a network-wide claim has to go, rather than into sitemeta |
| 53 |
* where get_site_option() would put it: sitemeta indexes meta_key |
| 54 |
* non-uniquely, so it cannot arbitrate anything -- N concurrent inserts all |
| 55 |
* succeed there. The main site's options table is the one core table that |
| 56 |
* is both visible to every blog in the network and uniquely keyed. */ |
| 57 |
const SCOPE_NETWORK_MAIN_SITE = 'network_main_site'; |
| 58 |
|
| 59 |
/** @var string one of the SCOPE_* constants */ |
| 60 |
private $scope; |
| 61 |
|
| 62 |
/** |
| 63 |
* @param string $scope one of the SCOPE_* constants. Defaults to the blog |
| 64 |
* serving the request, which is what every non-network claim wants. |
| 65 |
*/ |
| 66 |
public function __construct($scope = self::SCOPE_CURRENT_BLOG) { |
| 67 |
if (!in_array($scope, array(self::SCOPE_CURRENT_BLOG, self::SCOPE_NETWORK_MAIN_SITE), true)) { |
| 68 |
throw new InvalidArgumentException('Unknown exclusive option-row scope: ' . (string)$scope); |
| 69 |
} |
| 70 |
$this->scope = $scope; |
| 71 |
} |
| 72 |
|
| 73 |
/** Mint a process- and request-specific value for a claim. |
| 74 |
* |
| 75 |
* The caller-provided prefix may carry policy data such as an acquisition |
| 76 |
* or expiry timestamp. The suffix is what makes conditional release safe |
| 77 |
* even when two requests acquire the same row during the same clock tick. |
| 78 |
* |
| 79 |
* @param string $prefix |
| 80 |
* @return string |
| 81 |
* @phpstan-impure |
| 82 |
*/ |
| 83 |
public static function uniqueClaimValue($prefix) { |
| 84 |
return $prefix . ':' . ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken() . ':' . uniqid('', true); |
| 85 |
} |
| 86 |
|
| 87 |
/** Take $optionName, but only if no row exists for it yet. |
| 88 |
* |
| 89 |
* @param array{optionName: string, value: string} $claim |
| 90 |
* @return bool true only if this call created the row. |
| 91 |
* @phpstan-impure |
| 92 |
*/ |
| 93 |
public function claim(array $claim) { |
| 94 |
$optionName = $claim['optionName']; |
| 95 |
$value = $claim['value']; |
| 96 |
$wpdb = $this->wpdbOrNull(); |
| 97 |
$table = $wpdb === null ? '' : $this->optionsTable($wpdb); |
| 98 |
if ($wpdb === null || $table === '') { |
| 99 |
return false; |
| 100 |
} |
| 101 |
|
| 102 |
$boundRow = $this->bind($wpdb, '%s, %s', array($optionName, (string)$value)); |
| 103 |
if ($boundRow === '') { |
| 104 |
return false; |
| 105 |
} |
| 106 |
|
| 107 |
// autoload 'no' on purpose: an autoloaded row is loaded into the |
| 108 |
// alloptions cache of every single request on the site, which for a |
| 109 |
// row that exists only while somebody holds it is pure overhead. |
| 110 |
// DAO-bypass-approved: this is the mutual-exclusion primitive itself; the DAO's retry-and-repair path runs INSIDE synchronized sections, and create_db_tables / update_db_version are locked while the DAO is still booting. |
| 111 |
$rowsInserted = $this->runWithBoundedMetadataLockWait($wpdb, array( |
| 112 |
'description' => 'claiming the options row "' . $optionName . '"', |
| 113 |
'operation' => function () use ($wpdb, $table, $boundRow) { |
| 114 |
// DAO-bypass-approved: guarded options-row lock claim must remain available while the DAO bootstraps. |
| 115 |
return $wpdb->query("INSERT IGNORE INTO `" . $table . "` " |
| 116 |
. "(option_name, option_value, autoload) VALUES (" . $boundRow . ", 'no')"); |
| 117 |
}, |
| 118 |
)); |
| 119 |
|
| 120 |
// IGNORE turns the duplicate-key rejection into zero affected rows, so |
| 121 |
// "somebody else got there first" arrives as data rather than as an |
| 122 |
// error the site admin would be emailed about. |
| 123 |
// |
| 124 |
// A hard failure has to be told apart from that, even though both mean |
| 125 |
// "not claimed" to the caller. Losing a race is the protocol working |
| 126 |
// and happens constantly; a statement the engine refused (a read-only |
| 127 |
// replica, a full disk, an options table missing the autoload column) |
| 128 |
// means NOBODY can ever take this lock, which silently disables every |
| 129 |
// synchronized section that depends on it. Indistinguishable in |
| 130 |
// behavior, opposite in meaning, so the second one gets logged. |
| 131 |
if ($rowsInserted === false) { |
| 132 |
$this->logStorageFailure('claim the options row "' . $optionName . '"', $wpdb); |
| 133 |
return false; |
| 134 |
} |
| 135 |
|
| 136 |
return is_numeric($rowsInserted) && ((int)$rowsInserted) === 1; |
| 137 |
} |
| 138 |
|
| 139 |
/** The value currently recorded for $optionName, read from the table and |
| 140 |
* not from WordPress's option cache. |
| 141 |
* |
| 142 |
* @param string $optionName |
| 143 |
* @return string '' when no row exists, or when the storage cannot answer. |
| 144 |
* @phpstan-impure |
| 145 |
*/ |
| 146 |
public function valueOf($optionName) { |
| 147 |
$wpdb = $this->wpdbOrNull(); |
| 148 |
$table = $wpdb === null ? '' : $this->optionsTable($wpdb); |
| 149 |
if ($wpdb === null || $table === '') { |
| 150 |
return ''; |
| 151 |
} |
| 152 |
|
| 153 |
$boundName = $this->bind($wpdb, '%s', array($optionName)); |
| 154 |
if ($boundName === '') { |
| 155 |
return ''; |
| 156 |
} |
| 157 |
|
| 158 |
// DAO-bypass-approved: this row is what the DAO's own bootstrap locks on (create_db_tables, update_db_version), and it lives in WordPress's options table, which the DAO's recovery path must never CREATE, REPAIR, or raise a missing-plugin-table notice about. |
| 159 |
$value = $this->runWithBoundedMetadataLockWait($wpdb, array( |
| 160 |
'description' => 'reading the options row "' . $optionName . '"', |
| 161 |
'operation' => function () use ($wpdb, $table, $boundName) { |
| 162 |
// DAO-bypass-approved: guarded lock-owner read bypasses WordPress's process-local option cache. |
| 163 |
return $wpdb->get_var("SELECT option_value FROM `" . $table . "` " |
| 164 |
. "WHERE option_name = " . $boundName . " LIMIT 1"); |
| 165 |
}, |
| 166 |
)); |
| 167 |
|
| 168 |
// get_var() answers null for "no such row" and for "the statement was |
| 169 |
// refused", and every other statement in this class tells those two |
| 170 |
// apart. Returning '' for both is still the right ANSWER -- a caller |
| 171 |
// reads it as "no holder" and then re-attempts the atomic claim, which |
| 172 |
// a live holder's row still refuses, so an unreadable row can never |
| 173 |
// hand out a second copy of a lock. What it must not do is happen |
| 174 |
// silently: a host that refuses this SELECT refuses the claim next to |
| 175 |
// it too, which stops every synchronized section on the site with |
| 176 |
// nothing anywhere saying why. |
| 177 |
$lastError = $this->stringPropertyOf($wpdb, 'last_error'); |
| 178 |
if ($lastError !== null && $lastError !== '') { |
| 179 |
$this->logStorageFailure('read the options row "' . $optionName . '"', $wpdb); |
| 180 |
} |
| 181 |
|
| 182 |
return is_string($value) ? $value : ''; |
| 183 |
} |
| 184 |
|
| 185 |
/** Give up $optionName, but only while it still records $value. |
| 186 |
* |
| 187 |
* Callers that mint a unique value per claim should prefer this: it makes |
| 188 |
* "delete a row somebody else now holds" impossible rather than merely |
| 189 |
* unlikely, which matters because every release decision is made on the |
| 190 |
* strength of a read that happened earlier. |
| 191 |
* |
| 192 |
* @param array{optionName: string, value: string} $claim |
| 193 |
* @return bool true if the row recording $value was removed. |
| 194 |
* @phpstan-impure |
| 195 |
*/ |
| 196 |
public function releaseIfValueIs(array $claim) { |
| 197 |
return $this->deleteRow($claim); |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Replace an owned row without opening a delete/reclaim gap. |
| 202 |
* |
| 203 |
* @param array{optionName: string, currentValue: string, replacementValue: string} $replacement |
| 204 |
* @return bool true only when the row still held currentValue and was renewed. |
| 205 |
* @phpstan-impure |
| 206 |
*/ |
| 207 |
public function replaceValueIfMatches(array $replacement) { |
| 208 |
$wpdb = $this->wpdbOrNull(); |
| 209 |
$table = $wpdb === null ? '' : $this->optionsTable($wpdb); |
| 210 |
if ($wpdb === null || $table === '') { |
| 211 |
return false; |
| 212 |
} |
| 213 |
|
| 214 |
$boundReplacement = $this->bind($wpdb, '%s', array($replacement['replacementValue'])); |
| 215 |
$boundName = $this->bind($wpdb, '%s', array($replacement['optionName'])); |
| 216 |
$boundCurrent = $this->bind($wpdb, '%s', array($replacement['currentValue'])); |
| 217 |
if ($boundReplacement === '' || $boundName === '' || $boundCurrent === '') { |
| 218 |
return false; |
| 219 |
} |
| 220 |
|
| 221 |
// DAO-bypass-approved: renewing the coordination lease must remain available while the DAO itself is rebuilding tables. |
| 222 |
$rowsUpdated = $this->runWithBoundedMetadataLockWait($wpdb, array( |
| 223 |
'description' => 'renewing the options row "' . $replacement['optionName'] . '"', |
| 224 |
'operation' => function () use ($wpdb, $table, $boundReplacement, $boundName, $boundCurrent) { |
| 225 |
// DAO-bypass-approved: guarded lease renewal must stay on the same raw coordination row. |
| 226 |
return $wpdb->query("UPDATE `" . $table . "` SET option_value = " . $boundReplacement |
| 227 |
. " WHERE option_name = " . $boundName . " AND option_value = " . $boundCurrent); |
| 228 |
}, |
| 229 |
)); |
| 230 |
if ($rowsUpdated === false) { |
| 231 |
$this->logStorageFailure('renew the options row "' . $replacement['optionName'] . '"', $wpdb); |
| 232 |
return false; |
| 233 |
} |
| 234 |
return is_numeric($rowsUpdated) && ((int)$rowsUpdated) === 1; |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* @param array{optionName: string, value: string} $claim |
| 239 |
* @return bool |
| 240 |
*/ |
| 241 |
private function deleteRow(array $claim) { |
| 242 |
$optionName = $claim['optionName']; |
| 243 |
$requiredValue = $claim['value']; |
| 244 |
$wpdb = $this->wpdbOrNull(); |
| 245 |
$table = $wpdb === null ? '' : $this->optionsTable($wpdb); |
| 246 |
if ($wpdb === null || $table === '') { |
| 247 |
return false; |
| 248 |
} |
| 249 |
|
| 250 |
$boundName = $this->bind($wpdb, '%s', array($optionName)); |
| 251 |
if ($boundName === '') { |
| 252 |
return false; |
| 253 |
} |
| 254 |
$sql = "DELETE FROM `" . $table . "` WHERE option_name = " . $boundName; |
| 255 |
|
| 256 |
$boundValue = $this->bind($wpdb, '%s', array($requiredValue)); |
| 257 |
if ($boundValue === '') { |
| 258 |
return false; |
| 259 |
} |
| 260 |
$sql .= " AND option_value = " . $boundValue; |
| 261 |
|
| 262 |
// DAO-bypass-approved: releasing a lock must not itself need one, and this runs in finally blocks and on shutdown, after the DAO may already have been torn down. |
| 263 |
$rowsDeleted = $this->runWithBoundedMetadataLockWait($wpdb, array( |
| 264 |
'description' => 'releasing the options row "' . $optionName . '"', |
| 265 |
'operation' => function () use ($wpdb, $sql) { |
| 266 |
// DAO-bypass-approved: guarded conditional release runs during finally/shutdown paths outside DAO lifetime. |
| 267 |
return $wpdb->query($sql); |
| 268 |
}, |
| 269 |
)); |
| 270 |
|
| 271 |
// Same reasoning as claim(): removing no row is the ordinary outcome of |
| 272 |
// a conditional release whose row somebody else now holds, while a |
| 273 |
// refused statement means a held lock can never be given back, and the |
| 274 |
// two must not read the same way in the log. |
| 275 |
if ($rowsDeleted === false) { |
| 276 |
$this->logStorageFailure('release the options row "' . $optionName . '"', $wpdb); |
| 277 |
return false; |
| 278 |
} |
| 279 |
|
| 280 |
return is_numeric($rowsDeleted) && ((int)$rowsDeleted) > 0; |
| 281 |
} |
| 282 |
|
| 283 |
/** Report a statement the database refused. |
| 284 |
* |
| 285 |
* Deliberately a warning rather than an error: a lock that cannot be taken |
| 286 |
* stops synchronized WORK from running, which the plugin degrades past, and |
| 287 |
* the site admin should not be emailed about their host's read-only replica. |
| 288 |
* It still has to appear, because the alternative is a plugin that quietly |
| 289 |
* stops rebuilding anything with nothing anywhere saying why. |
| 290 |
* |
| 291 |
* @param string $attempted what the statement was trying to do |
| 292 |
* @param \wpdb $wpdb the handle that refused it |
| 293 |
* @return void |
| 294 |
*/ |
| 295 |
private function logStorageFailure($attempted, $wpdb) { |
| 296 |
if (!function_exists('abj_service')) { |
| 297 |
return; |
| 298 |
} |
| 299 |
|
| 300 |
$logger = abj_service('logging'); |
| 301 |
if (!is_object($logger) || !method_exists($logger, 'warn')) { |
| 302 |
return; |
| 303 |
} |
| 304 |
|
| 305 |
$lastError = $this->stringPropertyOf($wpdb, 'last_error'); |
| 306 |
$logger->warn('Could not ' . $attempted . '; treating the lock as unavailable. ' |
| 307 |
. 'Database error: ' . ($lastError === null || $lastError === '' ? '(none reported)' : $lastError)); |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* @param \wpdb $wpdb |
| 312 |
* @param array{description: string, operation: callable(): mixed} $request |
| 313 |
* @return mixed |
| 314 |
*/ |
| 315 |
private function runWithBoundedMetadataLockWait($wpdb, array $request) { |
| 316 |
$guard = new ABJ_404_Solution_DatabaseMetadataLockWaitGuard(static function () { |
| 317 |
$candidate = function_exists('abj_service') ? abj_service('logging') : null; |
| 318 |
return $candidate instanceof ABJ_404_Solution_Logging ? $candidate : null; |
| 319 |
}); |
| 320 |
$result = $guard->runWithBoundedWait($wpdb, $request); |
| 321 |
return $result['value']; |
| 322 |
} |
| 323 |
|
| 324 |
/** Bind $values into $fragment and hand back the escaped SQL text. |
| 325 |
* |
| 326 |
* Only VALUES are ever passed through wpdb::prepare(); the table name is |
| 327 |
* concatenated by the caller, after optionsTable() has reduced it to |
| 328 |
* identifier characters. prepare() has no way to escape an identifier |
| 329 |
* before WordPress 6.2's %i, and this plugin supports 5.0, so feeding it a |
| 330 |
* query with the table already interpolated would be asking it to vouch for |
| 331 |
* something it never checked. Splitting the two makes which half is bound |
| 332 |
* and which half is validated visible at every call site. |
| 333 |
* |
| 334 |
* @param \wpdb $wpdb |
| 335 |
* @param literal-string $fragment placeholders only, no identifiers |
| 336 |
* @param array<int, string> $values |
| 337 |
* @return string '' when wpdb declined to bind, which aborts the statement. |
| 338 |
*/ |
| 339 |
private function bind($wpdb, $fragment, array $values) { |
| 340 |
// Spread rather than passing $values as one array argument: both forms |
| 341 |
// are valid for wpdb::prepare(), but the variadic one is what every |
| 342 |
// call site in the wild uses and therefore the only one a replacement |
| 343 |
// handle can be relied on to implement. |
| 344 |
// DAO-bypass-approved: value binding for the statements above; escaping has to happen on the same handle that will execute them. |
| 345 |
$prepared = $wpdb->prepare($fragment, ...array_values($values)); |
| 346 |
|
| 347 |
return is_string($prepared) ? $prepared : ''; |
| 348 |
} |
| 349 |
|
| 350 |
/** The options table this instance's scope resolves to, reduced to |
| 351 |
* characters legal in an identifier, or '' when it cannot be resolved. |
| 352 |
* |
| 353 |
* The parameter is typed in the docblock only. A native hint would enforce |
| 354 |
* `instanceof wpdb` at runtime and reject any drop-in handle that does not |
| 355 |
* extend core's class, which is a narrowing this code has no reason to |
| 356 |
* impose: everything it needs is the three methods wpdbOrNull() confirmed. |
| 357 |
* |
| 358 |
* @param \wpdb $wpdb the handle wpdbOrNull() already validated |
| 359 |
* @return string |
| 360 |
*/ |
| 361 |
private function optionsTable($wpdb) { |
| 362 |
if ($this->scope === self::SCOPE_NETWORK_MAIN_SITE) { |
| 363 |
$prefix = $this->stringPropertyOf($wpdb, 'base_prefix'); |
| 364 |
if ($prefix === null) { |
| 365 |
// Fail closed rather than quietly resolving a NETWORK-wide |
| 366 |
// claim to the current blog's table, which would narrow its |
| 367 |
// scope without anything reporting that it had. |
| 368 |
return ''; |
| 369 |
} |
| 370 |
if (function_exists('get_main_site_id') && $this->canCall($wpdb, 'get_blog_prefix')) { |
| 371 |
$mainSitePrefix = $wpdb->get_blog_prefix((int)get_main_site_id()); |
| 372 |
if (is_string($mainSitePrefix) && $mainSitePrefix !== '') { |
| 373 |
$prefix = $mainSitePrefix; |
| 374 |
} |
| 375 |
} |
| 376 |
return $this->asIdentifier($prefix . 'options'); |
| 377 |
} |
| 378 |
|
| 379 |
$table = $this->stringPropertyOf($wpdb, 'options'); |
| 380 |
if ($table !== null && $table !== '') { |
| 381 |
return $this->asIdentifier($table); |
| 382 |
} |
| 383 |
|
| 384 |
$prefix = $this->stringPropertyOf($wpdb, 'prefix'); |
| 385 |
if ($prefix === null) { |
| 386 |
return ''; |
| 387 |
} |
| 388 |
|
| 389 |
// An empty $table_prefix is unusual but legal, so this branch can |
| 390 |
// legitimately produce the bare table name 'options'. |
| 391 |
return $this->asIdentifier($prefix . 'options'); |
| 392 |
} |
| 393 |
|
| 394 |
/** The WordPress database handle, or null when there is not a usable one. |
| 395 |
* |
| 396 |
* Callers read a null handle as "not claimed" and "no value", which stops |
| 397 |
* work rather than running it without exclusion. |
| 398 |
* |
| 399 |
* @return \wpdb|null |
| 400 |
*/ |
| 401 |
private function wpdbOrNull() { |
| 402 |
global $wpdb; |
| 403 |
// PHPStan has no type for a WordPress global, so it sees `mixed` here |
| 404 |
// and every later narrowing widens to a bare `object`. This states the |
| 405 |
// one production reality -- core's wpdb, or a drop-in such as HyperDB |
| 406 |
// that extends it -- rather than overriding anything PHPStan inferred, |
| 407 |
// and mirrors how DatabaseQueryExecutor::prepareQueryParameters() |
| 408 |
// reaches the same global. The runtime checks below still do the real |
| 409 |
// work, because a test double is not an instanceof wpdb. |
| 410 |
/** @var \wpdb $wpdb */ |
| 411 |
|
| 412 |
if (!is_object($wpdb)) { |
| 413 |
return null; |
| 414 |
} |
| 415 |
|
| 416 |
foreach (array('prepare', 'query', 'get_var') as $method) { |
| 417 |
if (!$this->canCall($wpdb, $method)) { |
| 418 |
return null; |
| 419 |
} |
| 420 |
} |
| 421 |
|
| 422 |
return $wpdb; |
| 423 |
} |
| 424 |
|
| 425 |
/** Whether $method can be invoked on $object, counting methods reached |
| 426 |
* through __call() and not only declared ones. |
| 427 |
* |
| 428 |
* method_exists() alone answers "no" for anything routed through __call(), |
| 429 |
* which is how WordPress drop-in database handles and this suite's $wpdb |
| 430 |
* doubles expose most of their surface. Reading that "no" as "there is no |
| 431 |
* usable database handle" would silently disable every claim on such a |
| 432 |
* site. |
| 433 |
* |
| 434 |
* @param object $object |
| 435 |
* @param string $method |
| 436 |
* @return bool |
| 437 |
*/ |
| 438 |
private function canCall($object, $method) { |
| 439 |
return method_exists($object, $method) || method_exists($object, '__call'); |
| 440 |
} |
| 441 |
|
| 442 |
/** A string property of the database handle, or null when it is absent or |
| 443 |
* is not a string. |
| 444 |
* |
| 445 |
* isset() and ?? are deliberately not used: both consult __isset(), which |
| 446 |
* objects exposing their fields through __get() do not necessarily |
| 447 |
* implement. |
| 448 |
* |
| 449 |
* @param object $object |
| 450 |
* @param string $name |
| 451 |
* @return string|null |
| 452 |
*/ |
| 453 |
private function stringPropertyOf($object, $name) { |
| 454 |
if (!property_exists($object, $name) && !method_exists($object, '__get')) { |
| 455 |
return null; |
| 456 |
} |
| 457 |
|
| 458 |
$value = $object->$name; |
| 459 |
|
| 460 |
return is_string($value) ? $value : null; |
| 461 |
} |
| 462 |
|
| 463 |
/** Accept a resolved table name only when every character is legal in an |
| 464 |
* identifier. Rewriting is unsafe: deleting one invalid character can turn |
| 465 |
* a malformed name into the name of a different, real table. |
| 466 |
* |
| 467 |
* @param string $table |
| 468 |
* @return string the unchanged identifier, or '' when invalid |
| 469 |
*/ |
| 470 |
private function asIdentifier($table) { |
| 471 |
if ($table === '' || preg_match('/\A[A-Za-z0-9_]+\z/D', $table) !== 1) { |
| 472 |
$this->logInvalidTableIdentifier($table); |
| 473 |
return ''; |
| 474 |
} |
| 475 |
|
| 476 |
return $table; |
| 477 |
} |
| 478 |
|
| 479 |
/** Report a database table name that cannot safely be put into SQL. |
| 480 |
* |
| 481 |
* @param string $table |
| 482 |
* @return void |
| 483 |
*/ |
| 484 |
private function logInvalidTableIdentifier($table) { |
| 485 |
$visibleTable = substr(str_replace(array("\r", "\n"), array('\\r', '\\n'), $table), 0, 200); |
| 486 |
$message = 'Refusing to use an invalid options-table identifier: "' . $visibleTable . '".'; |
| 487 |
|
| 488 |
if (function_exists('abj_service')) { |
| 489 |
$logger = abj_service('logging'); |
| 490 |
if (is_object($logger) && method_exists($logger, 'warn')) { |
| 491 |
$logger->warn($message); |
| 492 |
return; |
| 493 |
} |
| 494 |
} |
| 495 |
|
| 496 |
if (function_exists('abj404_logPhpFallback')) { |
| 497 |
abj404_logPhpFallback('service-resolution-fallback', $message); |
| 498 |
} |
| 499 |
} |
| 500 |
} |
| 501 |
|