Files
pest/src/Plugins/Tia/TableExtractor.php
T
Danny de Wit ad1850b110 fix: keep TableExtractor table names as strings (#1794)
PHP coerces numeric-string array keys to ints, so collecting table names in
$tables[$name] and returning array_keys() can yield ints. That breaks the
declared list<string> and throws a TypeError in Recorder::linkTable(string).

Standard SQL like substring(x FROM 1 FOR 10) is enough to trigger it, since
the numeric operand matches the FROM pattern.

Closes #1793

Co-authored-by: Danny de Wit <d.dewit@fenceweb.com>
2026-08-02 22:50:47 -04:00

163 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableExtractor
{
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
/**
* A single (optionally quoted) identifier segment.
*/
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
/**
* @return list<string> Sorted, deduped table names referenced by the
*/
public static function fromSql(string $sql): array
{
$trimmed = ltrim($sql);
if ($trimmed === '') {
return [];
}
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
return [];
}
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
return [];
}
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
if (preg_match_all($pattern, $sql, $matches) === false) {
return [];
}
$tables = [];
foreach ($matches[1] as $qualified) {
$name = self::unqualified($qualified);
if ($name === '') {
continue;
}
if (self::isSchemaMeta($name)) {
continue;
}
$tables[strtolower($name)] = true;
}
$out = array_map(strval(...), array_keys($tables));
sort($out);
return $out;
}
/**
* @return list<string> Table names referenced by `Schema::` calls,
*/
public static function fromMigrationSource(string $php): array
{
$tables = [];
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
foreach ($matches[1] as $i => $primary) {
$tables[strtolower(self::lastDottedSegment($primary))] = true;
$secondary = $matches[2][$i] ?? '';
if ($secondary !== '') {
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
}
}
}
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
$sqlPatterns = [
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
'/DELETE\s+FROM\s+'.$qualified.'/i',
];
foreach ($sqlPatterns as $pattern) {
if (preg_match_all($pattern, $php, $matches) === false) {
continue;
}
foreach ($matches[1] as $name) {
$lower = strtolower(self::unqualified($name));
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
foreach ($matches[1] as $name) {
$lower = strtolower(self::lastDottedSegment($name));
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$out = array_map(strval(...), array_keys($tables));
sort($out);
return $out;
}
/**
* The table segment of a possibly schema-qualified identifier chain,
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
*/
private static function unqualified(string $qualified): string
{
$name = '';
foreach (explode('.', $qualified) as $segment) {
$segment = trim($segment, " \t\n\r\"`[]");
if ($segment === '') {
continue;
}
if (self::isSchemaMeta($segment)) {
return '';
}
$name = $segment;
}
return $name;
}
private static function lastDottedSegment(string $name): string
{
$position = strrpos($name, '.');
return $position === false ? $name : substr($name, $position + 1);
}
private static function isSchemaMeta(string $name): bool
{
$lower = strtolower($name);
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|| str_starts_with($lower, 'pg_')
|| str_starts_with($lower, 'information_schema');
}
}