| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Whether a live index and the DDL that declares it are the same index, and the |
| 9 |
* canonical comparable form both reduce to in order to answer that. |
| 10 |
* |
| 11 |
* This is the third of three modules that together own the index picture, and |
| 12 |
* the only one that sees both halves: |
| 13 |
* |
| 14 |
* - {@see ABJ_404_Solution_CreateTableIndexParser} turns DDL SOURCE TEXT (the |
| 15 |
* plugin's own create*Table.sql templates) into specs. Regex over SQL. |
| 16 |
* - {@see ABJ_404_Solution_TableIndexDefinitions} reads the LIVE ENGINE |
| 17 |
* (SHOW INDEX) and normalizes whatever the driver reported into definitions. |
| 18 |
* - this module reduces either representation to one signature string and |
| 19 |
* compares them. |
| 20 |
* |
| 21 |
* Splitting the comparison out from the reader is what lets the reader stop |
| 22 |
* knowing that the DDL parser exists at all: the dependency runs one way, from |
| 23 |
* here down to both producers, and never back. |
| 24 |
* |
| 25 |
* THE CONTRACT, and the reason every method here can answer "no": |
| 26 |
* |
| 27 |
* An index that was not fully described has NO signature. Not an empty one, not |
| 28 |
* a placeholder -- none. "n:" looks like a signature and compares like one, but |
| 29 |
* it stands for an index with no columns, which cannot exist, so it compares |
| 30 |
* unequal to every real definition and the repair path reads that as drift. The |
| 31 |
* same is true of a live definition the engine described incompletely. Both |
| 32 |
* mean "cannot describe this index", and both keep meaning that all the way to |
| 33 |
* the caller. |
| 34 |
* |
| 35 |
* {@see isDriftedFromDdlSpec()} is where that meets the callers. It is |
| 36 |
* deliberately two-valued over a three-valued reality, because the response to |
| 37 |
* "differs" is a DROP INDEX plus ADD INDEX over the whole table (and, for the |
| 38 |
* spelling cache, emptying it first) while the response to "agrees" is nothing |
| 39 |
* at all. "Could not be established" therefore belongs with "agrees". |
| 40 |
* |
| 41 |
* Everything here is pure: two in-memory shapes in, a string or a verdict out. |
| 42 |
* No queries, no DDL, no formatting for humans. |
| 43 |
*/ |
| 44 |
class ABJ_404_Solution_IndexDefinitionComparator { |
| 45 |
|
| 46 |
/** |
| 47 |
* A canonical, comparable string for one index definition, or NULL when the |
| 48 |
* column list does not describe an index that could exist. |
| 49 |
* |
| 50 |
* Two definitions are the same index exactly when their signatures match: |
| 51 |
* same uniqueness, same columns, in the same order, with the same prefix |
| 52 |
* lengths. Example: "n:status,disabled,logshits,id" or "n:url(190),disabled". |
| 53 |
* |
| 54 |
* An empty list reaches here from both sides: ddlColumnList() answers a |
| 55 |
* fragment it only partly understands with an empty list, and a live |
| 56 |
* definition assembled from rows this version could not read carries one |
| 57 |
* too. An entry with no readable column name voids the signature rather |
| 58 |
* than being skipped, for the same reason the DDL parser refuses a partial |
| 59 |
* parse: skipping produces a SHORTER list that reads as a complete |
| 60 |
* definition, and repairing a real index to that shorter shape is how a |
| 61 |
* read gap turns into deliberate data-structure damage. |
| 62 |
* |
| 63 |
* @param array<int, array{column: string, prefix: int|null}> $columnList |
| 64 |
* @param bool $unique |
| 65 |
* @return string|null |
| 66 |
*/ |
| 67 |
public static function signature(array $columnList, bool $unique): ?string { |
| 68 |
if (empty($columnList)) { |
| 69 |
return null; |
| 70 |
} |
| 71 |
$parts = array(); |
| 72 |
foreach ($columnList as $column) { |
| 73 |
$name = isset($column['column']) && is_scalar($column['column']) |
| 74 |
? strtolower(trim((string)$column['column'])) : ''; |
| 75 |
if ($name === '') { |
| 76 |
return null; |
| 77 |
} |
| 78 |
$prefix = isset($column['prefix']) ? '(' . (int)$column['prefix'] . ')' : ''; |
| 79 |
$parts[] = $name . $prefix; |
| 80 |
} |
| 81 |
return ($unique ? 'u:' : 'n:') . implode(',', $parts); |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* The signature of a DDL spec produced by |
| 86 |
* {@see ABJ_404_Solution_CreateTableIndexParser::fromCreateTableSql()}, or |
| 87 |
* NULL when that spec's column fragment did not parse -- a truncated or |
| 88 |
* corrupted create*Table.sql, which defensive philosophy #7 says to expect. |
| 89 |
* |
| 90 |
* @param array{name: string, columns: string, unique: bool} $spec |
| 91 |
* @return string|null |
| 92 |
*/ |
| 93 |
public static function signatureOfDdlSpec(array $spec): ?string { |
| 94 |
return self::signature( |
| 95 |
ABJ_404_Solution_CreateTableIndexParser::ddlColumnList( |
| 96 |
isset($spec['columns']) ? (string)$spec['columns'] : ''), |
| 97 |
!empty($spec['unique']) |
| 98 |
); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* The signature of a live definition produced by |
| 103 |
* {@see ABJ_404_Solution_TableIndexDefinitions::readLive()}, or NULL when |
| 104 |
* the engine's description of it was incomplete. |
| 105 |
* |
| 106 |
* The describability check is repeated here rather than left to the caller |
| 107 |
* on purpose: a call site that forgets it gets a comparable string for an |
| 108 |
* index nobody described, and the only thing it can do with the resulting |
| 109 |
* mismatch is rewrite the table. Making the undescribable case impossible to |
| 110 |
* ask a comparable question about is cheaper than auditing every future |
| 111 |
* caller for the gate. |
| 112 |
* |
| 113 |
* @param array{name?: string, columns?: array<int, array{column: string, prefix: int|null}>, unique?: bool, describable?: bool} $definition |
| 114 |
* @return string|null |
| 115 |
*/ |
| 116 |
public static function signatureOfLiveDefinition(array $definition): ?string { |
| 117 |
if (!ABJ_404_Solution_TableIndexDefinitions::isDescribable($definition)) { |
| 118 |
return null; |
| 119 |
} |
| 120 |
$columns = isset($definition['columns']) && is_array($definition['columns']) |
| 121 |
? $definition['columns'] : array(); |
| 122 |
return self::signature($columns, !empty($definition['unique'])); |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* Whether a live index has been ESTABLISHED to differ from the DDL that |
| 127 |
* declares it. |
| 128 |
* |
| 129 |
* The one question the repair path acts on. Collapsing "could not be |
| 130 |
* established" into "not drifted" happens here, once, where the two |
| 131 |
* signatures are read, so that no caller has to re-derive that null is not |
| 132 |
* a difference. Comparing the signatures directly is exactly the mistake |
| 133 |
* this method exists to prevent: PHP reports null !== 'n:url(190)' as a |
| 134 |
* difference, and that difference is a table rewrite. |
| 135 |
* |
| 136 |
* @param array{columns?: array<int, array{column: string, prefix: int|null}>, unique?: bool, describable?: bool} $liveDefinition |
| 137 |
* @param array{name: string, columns: string, unique: bool} $ddlSpec |
| 138 |
* @return bool |
| 139 |
*/ |
| 140 |
public static function isDriftedFromDdlSpec(array $liveDefinition, array $ddlSpec): bool { |
| 141 |
$liveSignature = self::signatureOfLiveDefinition($liveDefinition); |
| 142 |
$goalSignature = self::signatureOfDdlSpec($ddlSpec); |
| 143 |
if ($liveSignature === null || $goalSignature === null) { |
| 144 |
return false; |
| 145 |
} |
| 146 |
return $liveSignature !== $goalSignature; |
| 147 |
} |
| 148 |
} |
| 149 |
|