isset($charsetMatch[1]) ? $charsetMatch[1] : null, 'collation' => isset($collationMatch[1]) ? $collationMatch[1] : null, ); } /** * Whether the statement declares a charset or a collation AS A TABLE * OPTION, i.e. whether it already carries a table-level default. * * The question a producer asks before appending one of its own, and it has * to be asked of the options section alone. A per-column * `CHARACTER SET x COLLATE y` inside the body answers a DIFFERENT question, * so a whole-statement scan reads one column's override as proof the whole * table is covered and skips the default the table still needs. The plugin's * own staging templates carry per-column charsets, so the two are not * hypothetically distinguishable -- they routinely differ. * * A statement with no readable table-options section declares no table-level * default, which is the answer a producer needs: append one. * * @param string $createTableSql Raw SHOW CREATE TABLE output or a DDL template. * @return bool */ public static function declaresTableCharsetOrCollation($createTableSql): bool { $declared = self::tableCharsetAndCollation($createTableSql); return $declared !== null && ($declared['charset'] !== null || $declared['collation'] !== null); } /** * The storage engine the statement declares, or null when it declares none * (or the statement has no readable table-options section). * * @param string $createTableSql Raw SHOW CREATE TABLE output or a DDL template. * @return string|null */ public static function tableEngine($createTableSql): ?string { $options = self::tableOptionsSection($createTableSql); if ($options === null) { return null; } $engineMatch = array(); preg_match('/ENGINE(?:\s*=\s*|\s+)([\w]+)/i', $options, $engineMatch); return isset($engineMatch[1]) ? $engineMatch[1] : null; } /** * Replace every quoted run and every SQL comment with an equal number of * spaces, leaving the rest of the statement untouched. * * Quoted runs go because a table COMMENT is free text that may quote a * charset the table no longer uses, and table options are order-independent * in SQL, so that COMMENT is free to sit before the real ones. Comments go * because prose is not SQL: the header of the plugin's own * createLookupTable.sql ends "...(Armed Forces Europe, Middle East, & * Canada)." -- an opening paren that would otherwise open the "body" before * the real one does. * * Blanking rather than deleting keeps every remaining character at its * original offset, so the paren scan reads the same structure the engine * wrote. * * @param string $sql * @return string Same length as the input. */ private static function blankQuotedRunsAndComments($sql) { $sql = (string)$sql; $length = strlen($sql); $out = ''; for ($i = 0; $i < $length; $i++) { $char = $sql[$i]; $end = null; if ($char === '`' || $char === "'" || $char === '"') { $end = self::endOfQuotedRun($sql, $i); } else if ($char === '/' && $i + 1 < $length && $sql[$i + 1] === '*') { // Block comment, including the /*! ... */ version-gated form an // engine can emit in SHOW CREATE TABLE output. $close = strpos($sql, '*/', $i + 2); $end = ($close === false) ? $length - 1 : $close + 1; } else if ($char === '#' || ($char === '-' && $i + 1 < $length && $sql[$i + 1] === '-' && ($i + 2 >= $length || preg_match('/\s/', $sql[$i + 2]) === 1))) { // A line comment ends AT the newline; the break itself still // separates what follows. MySQL requires whitespace (or end of // input) after the double dash, which is what keeps it apart // from a subtraction. $end = max($i, $i + strcspn($sql, "\r\n", $i) - 1); } if ($end === null) { $out .= $char; continue; } $out .= str_repeat(' ', $end - $i + 1); $i = $end; } return $out; } /** * The offset of the closing quote of the quoted run that STARTS at $start. * * A run that is never closed (DDL truncated mid-string) ends at the last * character rather than being reported as an error: such a statement has no * balanced body either, so the caller's answer is already "unreadable" and * there is nothing after the run left to misread. * * @param string $sql * @param int $start Offset of the opening quote. * @return int */ private static function endOfQuotedRun($sql, $start) { $quote = $sql[$start]; $length = strlen($sql); for ($i = $start + 1; $i < $length; $i++) { $char = $sql[$i]; // Backticks take no backslash escapes; a backslash inside one is an // ordinary character. if ($char === '\\' && $quote !== '`' && $i + 1 < $length) { $i++; continue; } if ($char === $quote) { // A doubled quote is an escaped quote, not the end of the run. if ($i + 1 < $length && $sql[$i + 1] === $quote) { $i++; continue; } return $i; } } return $length - 1; } }