Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,490 @@
<?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;
}
}