PHP Classes

File: scripts/audit_data_model_doc.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/audit_data_model_doc.php   Download  
File: scripts/audit_data_model_doc.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 11,718 bytes
 

Contents

Class file image Download
#!/usr/bin/env php <?php /** * Audits docs/DATA_MODEL.md against the live PostgreSQL schema. * * Reports: * - Tables present in the DB but absent from the doc * - Tables named in the doc but absent from the DB * - For tables whose columns are enumerated in the doc: * - columns present in the DB but missing from the doc * - columns named in the doc but absent from the DB * * Output is plain-text structured for AI ingestion. * * Usage: * php scripts/audit_data_model_doc.php [--json] * * --json Emit JSON instead of human-readable text (useful for piping) */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Database; $opts = getopt('', ['json', 'help']); if (isset($opts['help'])) { echo "Usage: php scripts/audit_data_model_doc.php [--json]\n"; exit(0); } $jsonMode = isset($opts['json']); // --------------------------------------------------------------------------- // 1. Read the live schema from PostgreSQL // --------------------------------------------------------------------------- $db = Database::getInstance()->getPdo(); $dbTables = $db->query( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name" )->fetchAll(\PDO::FETCH_COLUMN); $dbColumns = []; $rows = $db->query( "SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position" )->fetchAll(\PDO::FETCH_ASSOC); foreach ($rows as $row) { $dbColumns[$row['table_name']][] = [ 'name' => $row['column_name'], 'type' => $row['data_type'], 'nullable' => $row['is_nullable'], 'default' => $row['column_default'], ]; } // --------------------------------------------------------------------------- // 2. Parse docs/DATA_MODEL.md // --------------------------------------------------------------------------- $docPath = __DIR__ . '/../docs/DATA_MODEL.md'; if (!is_readable($docPath)) { fwrite(STDERR, "ERROR: Cannot read $docPath\n"); exit(1); } $docLines = file($docPath, FILE_IGNORE_NEW_LINES); /** * Tables with full column listings in the doc. * Key = table name, value = array of column names. * @var array<string, string[]> */ $docDetailedColumns = []; /** * All table names mentioned anywhere in the doc. * @var string[] */ $docMentionedTables = []; // Parse: look for backtick-quoted identifiers that look like table names, // and parse markdown column tables under ### `tablename` headings. $currentTable = null; $inColumnTable = false; foreach ($docLines as $line) { // Detect ### `tablename` headings if (preg_match('/^###\s+`([a-z_][a-z0-9_]*)`/', $line, $m)) { $currentTable = $m[1]; $inColumnTable = false; $docMentionedTables[] = $currentTable; $docDetailedColumns[$currentTable] = []; continue; } // Detect the start of a markdown table with a | `column` | pattern if ($currentTable !== null && preg_match('/^\|\s*`[a-z_]/', $line)) { $inColumnTable = true; } // Parse column rows: | `column_name` | ... | if ($inColumnTable && $currentTable !== null && preg_match('/^\|\s*`([a-z_][a-z0-9_]*)`\s*\|/', $line, $m)) { $docDetailedColumns[$currentTable][] = $m[1]; continue; } // A blank line or a new heading ends the column table if ($inColumnTable && (trim($line) === '' || str_starts_with($line, '#'))) { $inColumnTable = false; } // Collect all backtick-quoted snake_case identifiers that plausibly name tables // (two or more words joined by underscores, or single common table keywords). // We also capture names from the Supporting Tables pipe-table rows. if (preg_match_all('/`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)`/', $line, $matches)) { foreach ($matches[1] as $name) { $docMentionedTables[] = $name; } } } // Remove duplicates; remove obvious non-table names (short utility strings). $docMentionedTables = array_unique($docMentionedTables); // Filter: only keep identifiers that exist in the DB *or* look like real table // names (at least one underscore or an exact DB table match). This removes // things like `tag`, `domain`, `user_id`, `created_at` etc. that are column // names masquerading as table names in narrative text. $knownDbSet = array_flip($dbTables); $docMentionedTables = array_filter($docMentionedTables, function (string $name) use ($knownDbSet): bool { // Always keep if it IS a known DB table. if (isset($knownDbSet[$name])) { return true; } // Keep if it looks like a table name: contains at least one underscore // and is not a typical column name fragment. $columnFragments = [ 'user_id', 'echoarea_id', 'message_id', 'reply_to_id', 'admin_only', 'is_active', 'is_local', 'is_read', 'is_deleted', 'is_admin', 'is_approved', 'is_sysop_only', 'is_nullable', 'column_name', 'table_name', 'data_type', 'created_at', 'updated_at', 'date_written', 'date_received', 'password_hash', 'credit_balance', 'last_login', 'referral_code', 'from_name', 'from_address', 'to_name', 'to_address', 'message_charset', 'kludge_lines', 'art_format', 'attachment_filename', 'column_default', ]; if (in_array($name, $columnFragments, true)) { return false; } // Keep multi-word identifiers (>1 underscore-separated segment) as likely tables return substr_count($name, '_') >= 1; }); $docMentionedTables = array_values($docMentionedTables); // --------------------------------------------------------------------------- // 3. Compute gaps // --------------------------------------------------------------------------- $dbTableSet = array_flip($dbTables); $docTableSet = array_flip($docMentionedTables); /** Tables in DB, not mentioned anywhere in the doc */ $tablesOnlyInDb = array_values(array_filter($dbTables, fn($t) => !isset($docTableSet[$t]))); sort($tablesOnlyInDb); /** Tables mentioned in doc but not in DB */ $tablesOnlyInDoc = array_values(array_filter($docMentionedTables, fn($t) => !isset($dbTableSet[$t]))); sort($tablesOnlyInDoc); /** Column-level gaps for tables that have detailed columns in the doc */ $columnGaps = []; foreach ($docDetailedColumns as $table => $docCols) { if (empty($docCols)) { continue; // doc heading with no column table } $dbColNames = array_column($dbColumns[$table] ?? [], 'name'); $docColSet = array_flip($docCols); $dbColSet = array_flip($dbColNames); $onlyInDb = array_values(array_filter($dbColNames, fn($c) => !isset($docColSet[$c]))); $onlyInDoc = array_values(array_filter($docCols, fn($c) => !isset($dbColSet[$c]))); if ($onlyInDb || $onlyInDoc) { $columnGaps[$table] = [ 'in_db_not_doc' => $onlyInDb, 'in_doc_not_db' => $onlyInDoc, ]; } } // --------------------------------------------------------------------------- // 4. Emit report // --------------------------------------------------------------------------- if ($jsonMode) { echo json_encode([ 'generated_at' => gmdate('c'), 'doc_path' => 'docs/DATA_MODEL.md', 'summary' => [ 'db_table_count' => count($dbTables), 'doc_mentioned_table_count' => count($docMentionedTables), 'tables_undocumented' => count($tablesOnlyInDb), 'tables_missing_from_db' => count($tablesOnlyInDoc), 'tables_with_column_gaps' => count($columnGaps), ], 'tables_in_db_not_in_doc' => $tablesOnlyInDb, 'tables_in_doc_not_in_db' => $tablesOnlyInDoc, 'column_gaps' => $columnGaps, 'all_db_tables_with_columns' => $dbColumns, ], JSON_PRETTY_PRINT) . "\n"; exit(0); } // Human-readable / AI-ingestible text format $sep = str_repeat('=', 72); $sub = str_repeat('-', 72); echo "$sep\n"; echo "DATA_MODEL.md AUDIT REPORT\n"; echo "Generated: " . gmdate('Y-m-d H:i:s') . " UTC\n"; echo "Source doc: docs/DATA_MODEL.md\n"; echo "$sep\n\n"; echo "SUMMARY\n"; echo "$sub\n"; echo " Tables in live database: " . count($dbTables) . "\n"; echo " Tables mentioned in doc: " . count($docMentionedTables) . "\n"; echo " Undocumented DB tables: " . count($tablesOnlyInDb) . "\n"; echo " Doc tables absent from DB: " . count($tablesOnlyInDoc) . "\n"; echo " Tables with column-level gaps: " . count($columnGaps) . "\n"; echo "\n"; // --- Section A: undocumented DB tables --- echo "SECTION A ? TABLES IN DATABASE BUT NOT IN docs/DATA_MODEL.md\n"; echo "$sub\n"; echo "These tables exist in PostgreSQL but are not mentioned anywhere in the doc.\n"; echo "They need either a description entry or at minimum a row in the Supporting\n"; echo "Tables table.\n\n"; if (empty($tablesOnlyInDb)) { echo " (none ? all DB tables are documented)\n\n"; } else { foreach ($tablesOnlyInDb as $table) { $colNames = array_column($dbColumns[$table] ?? [], 'name'); $colList = implode(', ', $colNames); echo " TABLE: $table\n"; echo " COLUMNS: $colList\n"; echo "\n"; } } // --- Section B: phantom doc tables --- echo "SECTION B ? TABLES MENTIONED IN DOC BUT ABSENT FROM DATABASE\n"; echo "$sub\n"; echo "These names appear in docs/DATA_MODEL.md but have no corresponding table\n"; echo "in PostgreSQL. They may be planned, renamed, or typos.\n\n"; if (empty($tablesOnlyInDoc)) { echo " (none ? all doc table names exist in the DB)\n\n"; } else { foreach ($tablesOnlyInDoc as $table) { echo " TABLE: $table\n"; } echo "\n"; } // --- Section C: column-level gaps --- echo "SECTION C ? COLUMN GAPS IN TABLES WITH DOCUMENTED COLUMN LISTS\n"; echo "$sub\n"; echo "Only tables that have explicit column tables in DATA_MODEL.md are checked here.\n\n"; if (empty($columnGaps)) { echo " (none ? all documented columns match the live schema)\n\n"; } else { foreach ($columnGaps as $table => $gap) { echo " TABLE: $table\n"; if ($gap['in_db_not_doc']) { echo " IN DB, MISSING FROM DOC:\n"; foreach ($gap['in_db_not_doc'] as $col) { $meta = null; foreach (($dbColumns[$table] ?? []) as $c) { if ($c['name'] === $col) { $meta = $c; break; } } $typeStr = $meta ? " ({$meta['type']}" . ($meta['nullable'] === 'NO' ? ', NOT NULL' : '') . ")" : ''; echo " - $col$typeStr\n"; } } if ($gap['in_doc_not_db']) { echo " IN DOC, MISSING FROM DB (may be renamed or removed):\n"; foreach ($gap['in_doc_not_db'] as $col) { echo " - $col\n"; } } echo "\n"; } } // --- Section D: full schema dump for AI reference --- echo "SECTION D ? FULL LIVE SCHEMA (for AI reference)\n"; echo "$sub\n"; echo "Complete column listing for every table in the database.\n\n"; foreach ($dbTables as $table) { echo " TABLE: $table\n"; foreach (($dbColumns[$table] ?? []) as $col) { $nullStr = $col['nullable'] === 'NO' ? ' NOT NULL' : ''; $defaultStr = $col['default'] !== null ? " DEFAULT {$col['default']}" : ''; echo " - {$col['name']} {$col['type']}{$nullStr}{$defaultStr}\n"; } echo "\n"; } echo "$sep\n"; echo "END OF REPORT\n"; echo "$sep\n";