['Animaux', 'animals', 'ready'], 'owner' => ['Contacts et structures', 'directory_contacts', 'planned'], 'adoption' => ['Mouvements, FA et adoptions', 'animal_placements / adoptions', 'planned'], 'animalvaccination' => ['Vaccinations', 'vaccinations', 'planned'], 'medical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'], 'animalmedical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'], 'media' => ['Médias', 'animal_photos / medical_photos', 'planned'], 'animallitter' => ['Portées', 'litters', 'planned'], 'animalcontrol' => ['Incidents et contrôles', 'non pris en charge', 'review'], 'animaltest' => ['Tests médicaux', 'medical_notes', 'planned'], ]; public static function analyze(string $path): array { if (!is_file($path) || !is_readable($path)) { throw new RuntimeException(t('asm3.unreadable')); } if (self::isCopyDump($path)) { return self::analyzeCopy($path); } $handle = fopen($path, 'rb'); if (!$handle) { throw new RuntimeException(t('asm3.open_failed')); } $counts = []; $columns = []; $animalStats = [ 'active' => 0, 'archived' => 0, 'adoptable' => 0, 'not_adoptable' => 0, 'deceased' => 0, 'microchip' => 0, 'tattoo' => 0, 'neutered' => 0, 'missing_name' => 0, 'missing_birth_date' => 0, 'species' => [], ]; $statement = ''; $quoted = false; $bytes = 0; try { while (($chunk = fgets($handle)) !== false) { $bytes += strlen($chunk); $statement .= $chunk; $length = strlen($statement); $quoted = false; for ($i = 0; $i < $length; $i++) { if ($statement[$i] !== "'") { continue; } if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") { $i++; continue; } $quoted = !$quoted; } if ($quoted || !preg_match('/;\s*$/s', $statement)) { continue; } self::consume($statement, $counts, $columns, $animalStats); $statement = ''; } if (trim($statement) !== '') { self::consume($statement, $counts, $columns, $animalStats); } } finally { fclose($handle); } arsort($counts); $domains = []; foreach (self::DOMAINS as $table => [$label, $target, $status]) { $domains[] = [ 'table' => $table, 'label' => $label, 'target' => $target, 'status' => $status, 'rows' => $counts[$table] ?? 0, 'present' => isset($counts[$table]), ]; } $warnings = []; if (!isset($counts['animal'])) { $warnings[] = t('asm3.animal_table_missing'); } if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) { $warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]); } if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) { $warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]); } $unsupported = []; foreach ($counts as $table => $count) { if (!isset(self::DOMAINS[$table]) && $count > 0) { $unsupported[$table] = $count; } } return [ 'format' => 'ASM3 SQL INSERT', 'source' => basename($path), 'size_bytes' => filesize($path), 'read_bytes' => $bytes, 'generated_at' => date(DATE_ATOM), 'tables' => $counts, 'columns' => $columns, 'domains' => $domains, 'animals' => $animalStats, 'warnings' => $warnings, 'unmapped_tables' => $unsupported, 'read_only' => true, ]; } public static function rows(string $path, array $wantedTables): array { if (self::isCopyDump($path)) { return self::copyRows($path, $wantedTables); } $wanted = array_fill_keys(array_map('strtolower', $wantedTables), true); $rows = []; $columns = []; $handle = fopen($path, 'rb'); if (!$handle) { throw new RuntimeException(t('asm3.open_failed')); } $statement = ''; try { while (($chunk = fgets($handle)) !== false) { $statement .= $chunk; $quoted = false; $length = strlen($statement); for ($i = 0; $i < $length; $i++) { if ($statement[$i] !== "'") { continue; } if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") { $i++; continue; } $quoted = !$quoted; } if ($quoted || !preg_match('/;\s*$/s', $statement)) { continue; } if ( preg_match( '/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is', $statement, $m, ) ) { $table = strtolower($m[1]); if (isset($wanted[$table])) { $cols = $columns[$table] ??= array_map( static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), explode(',', $m[2]), ); $values = self::values($m[3]); $row = []; foreach ($cols as $i => $column) { $row[$column] = $values[$i] ?? null; } $rows[$table][] = $row; } } $statement = ''; } } finally { fclose($handle); } return $rows; } private static function consume(string $sql, array &$counts, array &$columns, array &$animalStats): void { if ( !preg_match( '/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is', $sql, $m, ) ) { return; } $table = strtolower($m[1]); $counts[$table] = ($counts[$table] ?? 0) + 1; if (!isset($columns[$table])) { $columns[$table] = array_map(static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), explode(',', $m[2])); } if ($table !== 'animal') { return; } $values = self::values($m[3]); $row = []; foreach ($columns[$table] as $i => $column) { $row[$column] = $values[$i] ?? null; } $truthy = static fn($v): bool => (string) $v === '1'; $blank = static fn($v): bool => $v === null || trim((string) $v) === ''; $animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++; $animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++; if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) { $animalStats['deceased']++; } if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) { $animalStats['microchip']++; } if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) { $animalStats['tattoo']++; } if ($truthy($row['NEUTERED'] ?? 0)) { $animalStats['neutered']++; } if ($blank($row['ANIMALNAME'] ?? null)) { $animalStats['missing_name']++; } if ($blank($row['DATEOFBIRTH'] ?? null)) { $animalStats['missing_birth_date']++; } $species = (string) ($row['SPECIESID'] ?? 'unknown'); $animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1; } private static function isCopyDump(string $path): bool { $handle = @fopen($path, 'rb'); if (!$handle) { return false; } $sample = (string) fread($handle, 262144); fclose($handle); return preg_match('/^COPY\s+(?:public\.)?[a-zA-Z0-9_]+\s*\(/mi', $sample) === 1; } private static function analyzeCopy(string $path): array { $counts = []; $columns = []; $animalStats = [ 'active' => 0, 'archived' => 0, 'adoptable' => 0, 'not_adoptable' => 0, 'deceased' => 0, 'microchip' => 0, 'tattoo' => 0, 'neutered' => 0, 'missing_name' => 0, 'missing_birth_date' => 0, 'species' => [], ]; $handle = fopen($path, 'rb'); if (!$handle) { throw new RuntimeException(t('asm3.open_failed')); } $table = null; $tableColumns = []; $bytes = 0; try { while (($line = fgets($handle)) !== false) { $bytes += strlen($line); $line = rtrim($line, "\r\n"); if ($table === null) { if ( preg_match( '/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i', $line, $m, ) ) { $table = strtolower($m[1]); $tableColumns = array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2])); $columns[$table] = $tableColumns; $counts[$table] ??= 0; } continue; } if ($line === '\\.') { $table = null; $tableColumns = []; continue; } $counts[$table]++; if ($table === 'animal') { self::accumulateAnimalStats(self::copyRow($line, $tableColumns), $animalStats); } } } finally { fclose($handle); } arsort($counts); $domains = []; foreach (self::DOMAINS as $name => [$label, $target, $status]) { $domains[] = [ 'table' => $name, 'label' => $label, 'target' => $target, 'status' => $status, 'rows' => $counts[$name] ?? 0, 'present' => isset($counts[$name]), ]; } $warnings = []; if (!isset($counts['animal'])) { $warnings[] = t('asm3.animal_table_missing'); } if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) { $warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]); } if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) { $warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]); } $unsupported = []; foreach ($counts as $name => $count) { if (!isset(self::DOMAINS[$name]) && $count > 0) { $unsupported[$name] = $count; } } return [ 'format' => 'ASM3 PostgreSQL COPY', 'source' => basename($path), 'size_bytes' => filesize($path), 'read_bytes' => $bytes, 'generated_at' => date(DATE_ATOM), 'tables' => $counts, 'columns' => $columns, 'domains' => $domains, 'animals' => $animalStats, 'warnings' => $warnings, 'unmapped_tables' => $unsupported, 'read_only' => true, ]; } private static function copyRows(string $path, array $wantedTables): array { $wanted = array_fill_keys(array_map('strtolower', $wantedTables), true); $rows = []; $handle = fopen($path, 'rb'); if (!$handle) { throw new RuntimeException(t('asm3.open_failed')); } $table = null; $columns = []; $capture = false; try { while (($line = fgets($handle)) !== false) { $line = rtrim($line, "\r\n"); if ($table === null) { if ( preg_match( '/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i', $line, $m, ) ) { $table = strtolower($m[1]); $capture = isset($wanted[$table]); $columns = $capture ? array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2])) : []; } continue; } if ($line === '\\.') { $table = null; $columns = []; $capture = false; continue; } if ($capture) { $rows[$table][] = self::copyRow($line, $columns); } } } finally { fclose($handle); } return $rows; } private static function copyRow(string $line, array $columns): array { $values = explode("\t", $line); $row = []; foreach ($columns as $i => $column) { $row[$column] = self::copyScalar($values[$i] ?? '\\N'); } return $row; } private static function copyScalar(string $value): ?string { if ($value === '\\N') { return null; } return preg_replace_callback( '/\\\\([0-7]{1,3}|.)/s', static function (array $m): string { $escape = $m[1]; if (ctype_digit($escape)) { return chr(octdec($escape)); } return match ($escape) { 'b' => "\x08", 'f' => "\x0c", 'n' => "\n", 'r' => "\r", 't' => "\t", 'v' => "\x0b", '\\' => '\\', default => $escape, }; }, $value, ) ?? $value; } private static function accumulateAnimalStats(array $row, array &$animalStats): void { $truthy = static fn($v): bool => (string) $v === '1'; $blank = static fn($v): bool => $v === null || trim((string) $v) === ''; $animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++; $animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++; if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) { $animalStats['deceased']++; } if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) { $animalStats['microchip']++; } if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) { $animalStats['tattoo']++; } if ($truthy($row['NEUTERED'] ?? 0)) { $animalStats['neutered']++; } if ($blank($row['ANIMALNAME'] ?? null)) { $animalStats['missing_name']++; } if ($blank($row['DATEOFBIRTH'] ?? null)) { $animalStats['missing_birth_date']++; } $species = (string) ($row['SPECIESID'] ?? 'unknown'); $animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1; } private static function values(string $input): array { $values = []; $value = ''; $quoted = false; $length = strlen($input); for ($i = 0; $i < $length; $i++) { $char = $input[$i]; if ($char === "'") { if ($quoted && $i + 1 < $length && $input[$i + 1] === "'") { $value .= "'"; $i++; continue; } $quoted = !$quoted; continue; } if ($char === ',' && !$quoted) { $values[] = self::scalar($value); $value = ''; continue; } $value .= $char; } $values[] = self::scalar($value); return $values; } private static function scalar(string $value): mixed { $value = trim($value); if (strcasecmp($value, 'null') === 0) { return null; } return $value; } }