490 lines
17 KiB
PHP
490 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class Asm3Analyzer
|
|
{
|
|
private const DOMAINS = [
|
|
'animal' => ['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;
|
|
}
|
|
}
|