|null */ private ?array $lastSyncResult = null; private function lockFilePath(): string { return WRITEPATH . 'setup_db_complete.lock'; } private function masterSchemaFilePath(): string { $fromEnv = trim((string) env('setup.sync.master.schema_file', '')); if ($fromEnv !== '') { if (str_starts_with($fromEnv, '/') || preg_match('/^[A-Za-z]:[\\\/]/', $fromEnv) === 1) { return $fromEnv; } return ROOTPATH . ltrim(str_replace(['\\'], '/', $fromEnv), '/'); } return APPPATH . 'Database/master_schema.json'; } private function masterSeedFilePath(): string { $fromEnv = trim((string) env('setup.seed.file', '')); if ($fromEnv !== '') { if (str_starts_with($fromEnv, '/') || preg_match('/^[A-Za-z]:[\\\/]/', $fromEnv) === 1) { return $fromEnv; } return ROOTPATH . ltrim(str_replace(['\\'], '/', $fromEnv), '/'); } return APPPATH . 'Database/master_seed_data.json'; } private function isSetupLocked(): bool { return is_file($this->lockFilePath()); } private function ensureSetupAccess() { if ($this->isSetupLocked()) { return $this->response->setStatusCode(404)->setBody('Setup is already completed and locked.'); } $keyFromEnv = trim((string) env('setup.dbToolsKey', '')); if ($keyFromEnv !== '') { $keyFromRequest = trim((string) ($this->request->getGet('key') ?? $this->request->getPost('key') ?? '')); if ($keyFromRequest !== '' && hash_equals($keyFromEnv, $keyFromRequest)) { return null; } } if (!function_exists('auth')) { return null; } $user = auth()->user(); if (!$user) { return $this->response->setStatusCode(403)->setBody('Please login to access setup tools.'); } $allowed = false; if (method_exists($user, 'can') && $user->can('billing.opd.edit')) { $allowed = true; } if (!$allowed && method_exists($user, 'inGroup') && $user->inGroup('admin', 'superadmin', 'developer')) { $allowed = true; } if (!$allowed) { return $this->response->setStatusCode(403)->setBody('Access denied.'); } return null; } public function dbTools() { if ($deny = $this->ensureSetupAccess()) { return $deny; } try { return view('setup/db_setup', $this->buildDbToolsViewData()); } catch (\Throwable $e) { log_message('error', 'Setup dbTools load failed: {message}', ['message' => $e->getMessage()]); return view('setup/db_setup', [ 'tables' => [], 'msg' => 'Setup tools could not load completely: ' . $e->getMessage(), 'lock_file' => $this->lockFilePath(), 'setup_key' => trim((string) env('setup.dbToolsKey', '')), 'sync_result' => null, 'sync_master_database' => (string) env('setup.sync.master.database', ''), 'sync_client_database' => (string) env('setup.sync.client.database', ''), 'master_schema_file' => $this->masterSchemaFilePath(), 'master_schema_exists' => is_file($this->masterSchemaFilePath()), 'diagnostics' => $this->collectSetupDiagnostics($e->getMessage()), ]); } } public function schemaSync() { if ($deny = $this->ensureSetupAccess()) { return $deny; } @ini_set('memory_limit', (string) env('setup.sync.memory_limit', '1024M')); @ini_set('max_execution_time', '0'); @set_time_limit(0); @ignore_user_abort(true); $action = strtolower(trim((string) $this->request->getPost('sync_action'))); if (!in_array($action, ['analyze', 'apply'], true)) { $action = 'analyze'; } $this->lastSyncResult = $this->executeSchemaSyncAction($action); return view('setup/db_setup', $this->buildDbToolsViewData()); } public function schemaSyncStep() { if ($deny = $this->ensureSetupAccess()) { return $deny; } @ini_set('memory_limit', (string) env('setup.sync.memory_limit', '1024M')); @ini_set('max_execution_time', '0'); @set_time_limit(0); @ignore_user_abort(true); $result = $this->executeSchemaSyncAction('apply'); $this->lastSyncResult = $result; $sqlCount = is_array($result['sql'] ?? null) ? count($result['sql']) : 0; $applyErrors = is_array($result['apply_errors'] ?? null) ? count($result['apply_errors']) : 0; $applied = (int) ($result['applied'] ?? 0); $truncated = !empty($result['truncated']) || !empty($result['apply_truncated']); $shouldContinue = $applyErrors === 0 && ($truncated || ($sqlCount > 0 && $applied > 0)); return $this->response->setJSON([ 'ok' => empty($result['errors'] ?? []), 'result' => $result, 'sql_count' => $sqlCount, 'applied' => $applied, 'apply_error_count' => $applyErrors, 'should_continue' => $shouldContinue, 'message' => $shouldContinue ? 'Applied chunk, continuing...' : 'Schema sync auto-apply reached a stop condition.', ]); } /** * @return array */ private function executeSchemaSyncAction(string $action): array { try { $maxRuntimeSeconds = (int) env('setup.sync.max_runtime_seconds', 40); if ($maxRuntimeSeconds < 5) { $maxRuntimeSeconds = 5; } $startedAt = microtime(true); $syncResult = $this->buildSchemaSyncPlan($startedAt, $maxRuntimeSeconds); if ($action === 'apply' && empty($syncResult['errors']) && !empty($syncResult['sql'])) { $maxApplyStatements = (int) env('setup.sync.max_apply_statements_per_run', 75); if ($maxApplyStatements <= 0) { $maxApplyStatements = 75; } $applyResult = $this->applySchemaSyncSql($syncResult['sql'], $startedAt, $maxRuntimeSeconds, $maxApplyStatements); $syncResult['applied'] = $applyResult['applied']; $syncResult['apply_errors'] = $applyResult['errors']; $syncResult['apply_truncated'] = $applyResult['truncated']; } if ($action === 'apply') { $canRunSeed = empty($syncResult['errors']) && empty($syncResult['apply_errors'] ?? []) && empty($syncResult['apply_truncated'] ?? false); if ($canRunSeed) { $syncResult['seed'] = $this->applySeedDataToClientDb(); } } return $syncResult; } catch (\Throwable $e) { log_message('error', 'Setup schemaSync failed: {message}', ['message' => $e->getMessage()]); return [ 'errors' => ['Schema sync failed: ' . $e->getMessage()], 'sql' => [], 'summary' => [], 'source' => 'runtime-exception', ]; } } public function exportMasterSchema() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $schema = $this->readSchemaFromConnection($this->db); $file = $this->masterSchemaFilePath(); $dir = dirname($file); if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { session()->setFlashdata('setup_msg', 'Unable to create schema file directory.'); return redirect()->to($this->dbToolsUrlWithKey()); } $payload = [ 'generated_at' => date('Y-m-d H:i:s'), 'source' => 'local-current-db', 'tables' => $schema, ]; $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); if (!is_string($json) || @file_put_contents($file, $json) === false) { session()->setFlashdata('setup_msg', 'Unable to write master schema file.'); return redirect()->to($this->dbToolsUrlWithKey()); } session()->setFlashdata('setup_msg', 'Master schema exported: ' . $file); return redirect()->to($this->dbToolsUrlWithKey()); } public function exportMasterSeedData() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $tables = $this->parseSeedTableList(); $exportedAllTables = false; if (empty($tables)) { $tables = $this->db->listTables(); $exportedAllTables = true; } // Tables that must never be exported in all-tables mode: // audit trails, session logs, queue tables, and CI framework internals // can contain secret values (API keys set via admin UI) or sensitive data. $neverExport = [ 'clinical_audit_trail', 'lab_log', 'bridge_sync_queue', 'ci_sessions', 'sessions', 'auth_token_logins', 'auth_logins', 'auth_remember_tokens', 'patient_duplicate_log', ]; if ($exportedAllTables) { $tables = array_values(array_filter($tables, static function ($t) use ($neverExport): bool { return ! in_array(strtolower((string) $t), $neverExport, true); })); } $seedPayload = [ 'generated_at' => date('Y-m-d H:i:s'), 'source' => 'local-current-db', 'tables' => [], ]; foreach ($tables as $tableName) { $resolvedTable = $this->resolveTableNameInConnection($this->db, (string) $tableName); if ($resolvedTable === null) { $seedPayload['tables'][$tableName] = []; continue; } try { $rows = $this->db->table($resolvedTable)->get()->getResultArray(); $seedPayload['tables'][$resolvedTable] = is_array($rows) ? $rows : []; } catch (\Throwable $e) { log_message('error', 'Seed export table read failed for {table}: {message}', ['table' => $resolvedTable, 'message' => $e->getMessage()]); $seedPayload['tables'][$resolvedTable] = []; } } $file = $this->masterSeedFilePath(); $dir = dirname($file); if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { session()->setFlashdata('setup_msg', 'Unable to create seed file directory.'); return redirect()->to($this->dbToolsUrlWithKey()); } $json = json_encode($seedPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); if (!is_string($json) || @file_put_contents($file, $json) === false) { session()->setFlashdata('setup_msg', 'Unable to write seed data file.'); return redirect()->to($this->dbToolsUrlWithKey()); } $mode = $exportedAllTables ? 'all tables (setup.seed.export_tables not set)' : 'configured table list'; session()->setFlashdata('setup_msg', 'Master seed data exported: ' . $file . ' (' . count((array) $seedPayload['tables']) . ' table(s), mode: ' . $mode . ').'); return redirect()->to($this->dbToolsUrlWithKey()); } public function generateMigrations() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $selected = $this->request->getPost('tables'); if (!is_array($selected)) { $selected = []; } $selected = array_values(array_unique(array_map(static function ($value): string { $name = strtolower(trim((string) $value)); return preg_replace('/[^a-z0-9_]/', '', $name) ?? ''; }, $selected))); $selected = array_values(array_filter($selected)); if (empty($selected)) { session()->setFlashdata('setup_msg', 'Please select at least one table.'); return redirect()->to($this->dbToolsUrlWithKey()); } $migrationDir = APPPATH . 'Database/Migrations'; if (!is_dir($migrationDir) && !mkdir($migrationDir, 0777, true) && !is_dir($migrationDir)) { session()->setFlashdata('setup_msg', 'Unable to create migration directory.'); return redirect()->to($this->dbToolsUrlWithKey()); } $createdFiles = []; $ts = date('Y-m-d-His'); $counter = 0; foreach ($selected as $tableName) { $showQuery = $this->db->query('SHOW CREATE TABLE `' . $tableName . '`'); $showRow = $showQuery->getRowArray(); if (empty($showRow)) { continue; } $createSql = ''; foreach ($showRow as $key => $value) { if (is_string($key) && stripos($key, 'Create Table') !== false) { $createSql = (string) $value; break; } } if ($createSql === '') { $vals = array_values($showRow); $createSql = (string) ($vals[1] ?? ''); } if ($createSql === '') { continue; } $createSql = $this->ensureCreateTableIfNotExists((string) $createSql); if (!str_ends_with(trim($createSql), ';')) { $createSql .= ';'; } $className = 'CreateExistingTable' . $this->studly($tableName) . date('His') . str_pad((string) $counter, 2, '0', STR_PAD_LEFT); $fileStamp = $ts . str_pad((string) $counter, 2, '0', STR_PAD_LEFT); $fileName = $fileStamp . '_' . $className . '.php'; $counter++; $php = "db->query(<<<'SQL'\n{$createSql}\nSQL\n );\n }\n\n public function down()\n {\n \$this->forge->dropTable('{$tableName}', true);\n }\n}\n"; $path = $migrationDir . DIRECTORY_SEPARATOR . $fileName; if (@file_put_contents($path, $php) !== false) { $createdFiles[] = $fileName; } } if (empty($createdFiles)) { session()->setFlashdata('setup_msg', 'No migration files generated.'); } else { session()->setFlashdata('setup_msg', 'Generated ' . count($createdFiles) . ' migration file(s).'); } return redirect()->to($this->dbToolsUrlWithKey()); } public function complete() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $lockFile = $this->lockFilePath(); $userName = 'unknown'; if (function_exists('auth') && auth()->user()) { $user = auth()->user(); $userName = (string) ($user->username ?? $user->email ?? 'unknown'); } $content = "Setup completed at: " . date('Y-m-d H:i:s') . "\nBy: " . $userName . "\n"; @file_put_contents($lockFile, $content); return $this->response->setBody('Setup locked successfully. You can now remove setup routes/page if required.'); } public function ensureAdminLogin() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $repair = $this->repairAuthSchemaInternal(); if (!empty($repair['errors'])) { session()->setFlashdata('setup_msg', 'Auth schema repair failed before admin setup: ' . implode(' | ', (array) $repair['errors'])); return redirect()->to($this->dbToolsUrlWithKey()); } $username = trim((string) env('setup.admin.username', 'admin')); $email = trim((string) env('setup.admin.email', 'admin@example.com')); $password = (string) env('setup.admin.password', 'Admin@12345'); $group = strtolower(trim((string) env('setup.admin.group', 'admin'))); if ($username === '') { $username = 'admin'; } if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { $email = 'admin@example.com'; } if (mb_strlen($password) < 8) { $password = 'Admin@12345'; } try { $userModel = model(UserModel::class); $user = $userModel->where('username', $username)->first(); if ($user === null) { $tables = config('Auth')->tables; $identitiesTable = (string) ($tables['identities'] ?? 'auth_identities'); $idRow = $this->db->table($identitiesTable) ->select('user_id') ->where('type', 'email_password') ->where('secret', $email) ->get(1) ->getRowArray(); if (!empty($idRow['user_id'])) { $user = $userModel->find((int) $idRow['user_id']); } } $action = 'updated'; if ($user === null) { $newUser = new User(); $newUser->username = $username; $newUser->email = $email; $newUser->password = $password; $newUser->active = 1; $userModel->save($newUser); $insertId = (int) $userModel->getInsertID(); $user = $insertId > 0 ? $userModel->find($insertId) : null; $action = 'created'; } else { $user->username = $username; $user->email = $email; $user->password = $password; $user->active = 1; $userModel->save($user); $user = $userModel->find((int) ($user->id ?? 0)); } if ($user !== null && method_exists($user, 'addGroup')) { $groupConfig = setting('AuthGroups.groups'); if (!is_array($groupConfig) || !isset($groupConfig[$group])) { $group = 'admin'; } $user->addGroup($group); } if ($user !== null) { $identityResult = $this->upsertEmailPasswordIdentity((int) ($user->id ?? 0), $email, $password); if (!empty($identityResult['errors'])) { session()->setFlashdata('setup_msg', 'Admin login setup failed: ' . implode(' | ', (array) $identityResult['errors'])); return redirect()->to($this->dbToolsUrlWithKey()); } } $repairMsg = ''; if (!empty($repair['applied'])) { $repairMsg = ' Auth schema repaired: ' . implode(', ', (array) $repair['applied']) . '.'; } session()->setFlashdata('setup_msg', 'Admin login ' . $action . ': username=' . $username . ', email=' . $email . ', group=' . $group . '.' . $repairMsg . ' Password is set from .env key setup.admin.password.'); } catch (\Throwable $e) { session()->setFlashdata('setup_msg', 'Admin login setup failed: ' . $e->getMessage()); } return redirect()->to($this->dbToolsUrlWithKey()); } public function repairAuthSchema() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $result = $this->repairAuthSchemaInternal(); $parts = []; $parts[] = 'Auth schema check finished.'; $parts[] = 'Applied: ' . count((array) ($result['applied'] ?? [])) . '.'; if (!empty($result['applied'])) { $parts[] = 'Changes: ' . implode(', ', (array) $result['applied']) . '.'; } if (!empty($result['warnings'])) { $parts[] = 'Warnings: ' . implode(' | ', (array) $result['warnings']) . '.'; } if (!empty($result['errors'])) { $parts[] = 'Errors: ' . implode(' | ', (array) $result['errors']) . '.'; } session()->setFlashdata('setup_msg', implode(' ', $parts)); return redirect()->to($this->dbToolsUrlWithKey()); } public function prepareFilesystem() { if ($deny = $this->ensureSetupAccess()) { return $deny; } $result = $this->prepareWritablePaths(); $parts = []; $parts[] = 'Filesystem prep done.'; $parts[] = 'Created: ' . count($result['created']) . '.'; $parts[] = 'Permission-updated: ' . count($result['permission_updated']) . '.'; $parts[] = 'Writable: ' . count($result['writable']) . '/' . count($result['checked']) . '.'; if (!empty($result['failed'])) { $parts[] = 'Failed: ' . implode(', ', $result['failed']) . '.'; } if (!empty($result['commands'])) { $parts[] = 'Run on server shell if needed: ' . implode(' | ', $result['commands']); } session()->setFlashdata('setup_msg', implode(' ', $parts)); return redirect()->to($this->dbToolsUrlWithKey()); } /** * @return array */ private function buildDbToolsViewData(): array { $allTables = []; $scripted = []; $dbError = null; try { $allTables = $this->db->listTables(); $scripted = $this->detectScriptedTables(); } catch (\Throwable $e) { $dbError = $e->getMessage(); log_message('error', 'Setup buildDbToolsViewData DB failure: {message}', ['message' => $dbError]); session()->setFlashdata('setup_msg', 'Database connection/setup error: ' . $dbError); } $tableRows = []; foreach ($allTables as $tableName) { $isScripted = isset($scripted[strtolower((string) $tableName)]); $tableRows[] = [ 'name' => (string) $tableName, 'scripted' => $isScripted, ]; } usort($tableRows, static function (array $a, array $b): int { if ($a['scripted'] === $b['scripted']) { return strcmp($a['name'], $b['name']); } return $a['scripted'] ? 1 : -1; }); return [ 'tables' => $tableRows, 'msg' => session('setup_msg'), 'lock_file' => $this->lockFilePath(), 'setup_key' => trim((string) env('setup.dbToolsKey', '')), 'sync_result' => $this->lastSyncResult, 'sync_master_database' => (string) env('setup.sync.master.database', ''), 'sync_client_database' => (string) env('setup.sync.client.database', ''), 'master_schema_file' => $this->masterSchemaFilePath(), 'master_schema_exists' => is_file($this->masterSchemaFilePath()), 'master_seed_file' => $this->masterSeedFilePath(), 'master_seed_exists' => is_file($this->masterSeedFilePath()), 'diagnostics' => $this->collectSetupDiagnostics($dbError), ]; } /** * @return array> */ private function collectSetupDiagnostics(?string $dbError = null): array { $errors = []; $warnings = []; $info = []; $env = (string) env('CI_ENVIRONMENT', 'production'); $info[] = 'CI_ENVIRONMENT: ' . $env; $setupKey = trim((string) env('setup.dbToolsKey', '')); if ($setupKey === '') { $warnings[] = 'setup.dbToolsKey is empty. Setup endpoint can only be accessed by authenticated privileged users.'; } else { $info[] = 'setup.dbToolsKey is configured.'; } $dbHost = trim((string) env('database.default.hostname', '')); $dbName = trim((string) env('database.default.database', '')); $dbUser = trim((string) env('database.default.username', '')); if ($dbHost === '' || $dbName === '' || $dbUser === '') { $errors[] = 'Missing database.default.* values in .env (hostname/database/username).'; } else { $info[] = 'DB target: ' . $dbHost . ' / ' . $dbName . ' / user=' . $dbUser; } if ($dbError !== null && $dbError !== '') { $errors[] = 'Database error: ' . $dbError; } $authSchema = $this->checkAuthSchemaHealth(); if (!empty($authSchema['errors'])) { foreach ((array) $authSchema['errors'] as $authErr) { $errors[] = (string) $authErr; } } if (!empty($authSchema['warnings'])) { foreach ((array) $authSchema['warnings'] as $authWarn) { $warnings[] = (string) $authWarn; } } foreach ($this->requiredWritablePaths() as $path) { $relative = $this->relativeProjectPath($path); if (!is_dir($path)) { $warnings[] = $relative . ' does not exist.'; continue; } if (!is_writable($path)) { $warnings[] = $relative . ' is not writable.'; } else { $info[] = $relative . ' is writable.'; } } $info[] = 'Web SAPI/PHP: ' . PHP_SAPI . ' / ' . PHP_VERSION; $info[] = 'If spark fails but web works, your CLI PHP version likely differs from web PHP version.'; if ($this->isSetupLocked()) { $warnings[] = 'Setup lock file exists. Remove lock file if this is a fresh installation attempt.'; } return [ 'errors' => $errors, 'warnings' => $warnings, 'info' => $info, ]; } /** * @return array */ private function buildSchemaSyncPlan(float $startedAt, int $maxRuntimeSeconds): array { $errors = []; $sql = []; $masterSchema = $this->loadMasterSchemaDefinition(); if (!empty($masterSchema['errors'])) { return [ 'errors' => $masterSchema['errors'], 'sql' => [], 'summary' => [], 'source' => $masterSchema['source'] ?? 'unknown', ]; } try { $client = $this->resolveClientConnection(); } catch (\Throwable $e) { return [ 'errors' => [$e->getMessage()], 'sql' => [], 'summary' => [], 'source' => (string) ($masterSchema['source'] ?? 'unknown'), ]; } $masterTables = $masterSchema['tables'] ?? []; $clientTables = array_flip($client->listTables()); $maxTables = (int) env('setup.sync.max_tables_per_run', 0); if ($maxTables < 0) { $maxTables = 0; } $processedTables = 0; $truncated = false; $createCount = 0; $alterCount = 0; $missingCreateSqlByTable = []; $alterStatements = []; // Prioritize known parent tables that commonly unblock FK-dependent tables. $priorityParentTables = ['users', 'hc_insurancebak', 'opd_advice_type']; foreach ($priorityParentTables as $priorityTable) { if (isset($clientTables[$priorityTable])) { continue; } if (!isset($masterTables[$priorityTable])) { continue; } $priorityCreateSql = (string) (($masterTables[$priorityTable]['create_sql'] ?? '')); if ($priorityCreateSql === '') { continue; } $priorityCreateSql = $this->ensureCreateTableIfNotExists($priorityCreateSql); if ($priorityCreateSql !== '' && !str_ends_with($priorityCreateSql, ';')) { $priorityCreateSql .= ';'; } if ($priorityCreateSql !== '') { $missingCreateSqlByTable[$priorityTable] = $priorityCreateSql; $createCount++; } } foreach ($masterTables as $table => $masterTableDef) { if ($maxTables > 0 && $processedTables >= $maxTables) { $truncated = true; break; } if ((microtime(true) - $startedAt) >= $maxRuntimeSeconds) { $truncated = true; break; } $table = (string) $table; $processedTables++; if (!isset($clientTables[$table])) { $createSql = (string) ($masterTableDef['create_sql'] ?? ''); if ($createSql !== '') { $createSql = $this->ensureCreateTableIfNotExists((string) $createSql); if (!str_ends_with($createSql, ';')) { $createSql .= ';'; } } if ($createSql !== '' && !isset($missingCreateSqlByTable[$table])) { $missingCreateSqlByTable[$table] = $createSql; $createCount++; } continue; } $tableAlterSql = $this->buildAlterSqlForTableFromSchema((array) $masterTableDef, $client, $table); if (!empty($tableAlterSql)) { foreach ($tableAlterSql as $stmt) { $alterStatements[] = $stmt; } $alterCount += count($tableAlterSql); } } if (!empty($missingCreateSqlByTable)) { $orderedCreateStatements = $this->orderCreateTableStatementsByDependency($missingCreateSqlByTable, array_keys($clientTables)); foreach ($orderedCreateStatements as $stmt) { $sql[] = $stmt; } } foreach ($alterStatements as $stmt) { $sql[] = $stmt; } return [ 'errors' => $errors, 'sql' => $sql, 'summary' => [ 'master_tables' => count((array) $masterTables), 'processed_tables' => $processedTables, 'max_tables_per_run' => $maxTables, 'create_tables' => $createCount, 'alter_statements' => $alterCount, ], 'source' => (string) ($masterSchema['source'] ?? 'master-schema-file'), 'truncated' => $truncated, ]; } /** * @param array $createSqlByTable * @param array $alreadyExistingTables * @return array */ private function orderCreateTableStatementsByDependency(array $createSqlByTable, array $alreadyExistingTables): array { $existing = array_fill_keys(array_map(static fn ($t) => strtolower((string) $t), $alreadyExistingTables), true); $pending = $createSqlByTable; $ordered = []; while (!empty($pending)) { $progress = false; foreach ($pending as $table => $createSql) { $deps = $this->extractReferencedTablesFromCreateSql($createSql); $blocked = false; foreach ($deps as $depTable) { $depKey = strtolower($depTable); if (isset($pending[$depTable]) && !isset($existing[$depKey])) { $blocked = true; break; } } if ($blocked) { continue; } $ordered[] = $createSql; $existing[strtolower($table)] = true; unset($pending[$table]); $progress = true; } if ($progress) { continue; } // Cycles or unresolved references: keep deterministic fallback order. ksort($pending); foreach ($pending as $table => $createSql) { $ordered[] = $createSql; } break; } return $ordered; } /** * @return array */ private function extractReferencedTablesFromCreateSql(string $createSql): array { $refs = []; if (preg_match_all('/REFERENCES\s+`([^`]+)`/i', $createSql, $matches) === 1 || !empty($matches[1])) { foreach ((array) ($matches[1] ?? []) as $table) { $name = trim((string) $table); if ($name !== '') { $refs[] = $name; } } } return array_values(array_unique($refs)); } /** * @param array $sqlList * @return array{applied:int,errors:array,truncated:bool} */ private function applySchemaSyncSql(array $sqlList, float $startedAt, int $maxRuntimeSeconds, int $maxStatements): array { $client = $this->resolveClientConnection(); $applied = 0; $errors = []; $truncated = false; $attempted = 0; $pending = []; foreach ($sqlList as $stmt) { if ($attempted >= $maxStatements) { $truncated = true; break; } if ((microtime(true) - $startedAt) >= $maxRuntimeSeconds) { $truncated = true; break; } $stmt = $this->normalizeSyncSql((string) $stmt); if ($stmt === '') { continue; } $attempted++; $pending[] = $stmt; } $maxPasses = (int) env('setup.sync.max_apply_retry_passes', 6); if ($maxPasses < 1) { $maxPasses = 1; } $deferredLastErrors = []; for ($pass = 1; $pass <= $maxPasses && !empty($pending); $pass++) { if ((microtime(true) - $startedAt) >= $maxRuntimeSeconds) { $truncated = true; break; } $nextPending = []; $appliedThisPass = 0; foreach ($pending as $stmt) { if ((microtime(true) - $startedAt) >= $maxRuntimeSeconds) { $truncated = true; $nextPending[] = $stmt; continue; } try { $client->query($stmt); $applied++; $appliedThisPass++; } catch (\Throwable $e) { $message = (string) $e->getMessage(); if ($this->isSafeToSkipSyncStatement($message, $stmt)) { continue; } if ($this->shouldDeferSyncStatement($message, $stmt)) { $nextPending[] = $stmt; $deferredLastErrors[$stmt] = $message; continue; } $errors[] = $message . ' | SQL: ' . mb_substr($stmt, 0, 180); } } $pending = $nextPending; if ($appliedThisPass === 0) { break; } } if (!empty($pending)) { foreach ($pending as $stmt) { $message = (string) ($deferredLastErrors[$stmt] ?? 'Unresolved FK dependency after retries'); if ($this->canRetryCreateTableWithoutForeignKeys($stmt, $message)) { $retry = $this->stripForeignKeysFromCreateTable($stmt); if ($retry !== $stmt && $retry !== '') { try { $client->query($retry); $applied++; continue; } catch (\Throwable $retryError) { $message = (string) $retryError->getMessage(); } } } $errors[] = $message . ' | SQL: ' . mb_substr($stmt, 0, 180); } } return ['applied' => $applied, 'errors' => $errors, 'truncated' => $truncated]; } /** * @return array */ private function loadMasterSchemaDefinition(): array { if ($this->hasSyncRoleConfig('master')) { try { $masterDb = $this->connectSyncDb('master'); return [ 'errors' => [], 'tables' => $this->readSchemaFromConnection($masterDb), 'source' => 'master-db-connection', ]; } catch (\Throwable $e) { return [ 'errors' => ['Master DB connection failed: ' . $e->getMessage()], 'tables' => [], 'source' => 'master-db-connection', ]; } } $file = $this->masterSchemaFilePath(); if (!is_file($file)) { return [ 'errors' => ['Master schema file not found: ' . $file . '. Use "Export Master Schema File" first on master system.'], 'tables' => [], 'source' => 'master-schema-file', ]; } $json = @file_get_contents($file); $decoded = is_string($json) ? json_decode($json, true) : null; if (!is_array($decoded) || !is_array($decoded['tables'] ?? null)) { return [ 'errors' => ['Invalid master schema file format: ' . $file], 'tables' => [], 'source' => 'master-schema-file', ]; } return [ 'errors' => [], 'tables' => $decoded['tables'], 'source' => 'master-schema-file', ]; } /** * @return array */ private function loadMasterSeedDefinition(): array { $file = $this->masterSeedFilePath(); if (!is_file($file)) { return [ 'errors' => ['Master seed data file not found: ' . $file], 'tables' => [], 'source' => 'master-seed-file', ]; } $json = @file_get_contents($file); $decoded = is_string($json) ? json_decode($json, true) : null; if (!is_array($decoded) || !is_array($decoded['tables'] ?? null)) { return [ 'errors' => ['Invalid master seed data file format: ' . $file], 'tables' => [], 'source' => 'master-seed-file', ]; } return [ 'errors' => [], 'tables' => $decoded['tables'], 'source' => 'master-seed-file', ]; } /** * @return array */ private function parseSeedTableList(): array { $raw = trim((string) env('setup.seed.export_tables', '')); if ($raw === '') { return []; } $parts = preg_split('/[\s,]+/', $raw) ?: []; $out = []; foreach ($parts as $part) { $name = trim((string) $part); $name = preg_replace('/[^a-zA-Z0-9_]/', '', $name) ?? ''; if ($name !== '') { $out[strtolower($name)] = $name; } } return array_values($out); } /** * @return array */ private function applySeedDataToClientDb(): array { $definition = $this->loadMasterSeedDefinition(); if (!empty($definition['errors'])) { return [ 'applied' => 0, 'skipped' => 0, 'errors' => (array) $definition['errors'], 'tables' => [], ]; } $tables = is_array($definition['tables'] ?? null) ? $definition['tables'] : []; if (empty($tables)) { return [ 'applied' => 0, 'skipped' => 0, 'errors' => [], 'tables' => [], ]; } $client = $this->resolveClientConnection(); $applied = 0; $skipped = 0; $errors = []; $tableStats = []; foreach ($tables as $tableName => $rows) { $table = trim((string) $tableName); if ($table === '') { continue; } $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; if ($safeTable === '') { continue; } $resolvedTable = $this->resolveTableNameInConnection($client, $safeTable); if ($resolvedTable === null) { $skipped++; $tableStats[] = $safeTable . ' (skipped: table missing)'; continue; } if (!is_array($rows)) { $skipped++; $tableStats[] = $safeTable . ' (skipped: invalid row list)'; continue; } $tableApplied = 0; foreach ($rows as $row) { if (!is_array($row) || empty($row)) { continue; } try { // REPLACE provides idempotent seed behavior: on re-run, rows with existing // PRIMARY or UNIQUE keys are updated instead of duplicated. // This ensures seed data can be safely applied multiple times. $client->table($resolvedTable)->replace($row); $applied++; $tableApplied++; } catch (\Throwable $e) { $errors[] = $resolvedTable . ': ' . $e->getMessage(); } } $tableStats[] = $resolvedTable . ' (rows: ' . $tableApplied . ')'; } return [ 'applied' => $applied, 'skipped' => $skipped, 'errors' => $errors, 'tables' => $tableStats, ]; } private function hasSyncRoleConfig(string $role): bool { $role = strtolower(trim($role)); $host = trim((string) env('setup.sync.' . $role . '.hostname', '')); $user = trim((string) env('setup.sync.' . $role . '.username', '')); $db = trim((string) env('setup.sync.' . $role . '.database', '')); return $host !== '' && $user !== '' && $db !== ''; } private function resolveClientConnection() { if ($this->hasSyncRoleConfig('client')) { return $this->connectSyncDb('client'); } return $this->db; } /** * @return array> */ private function readSchemaFromConnection($db): array { $tables = $db->listTables(); $out = []; foreach ($tables as $tableName) { $table = (string) $tableName; $out[$table] = [ 'create_sql' => $this->getCreateTableSql($db, $table), 'columns' => $this->readColumns($db, $table), 'indexes' => $this->readIndexes($db, $table), ]; } return $out; } /** * @return array */ private function buildAlterSqlForTableFromSchema(array $masterTableDef, $client, string $table): array { $sql = []; $columnAlterClauses = []; $masterCols = is_array($masterTableDef['columns'] ?? null) ? $masterTableDef['columns'] : []; $clientCols = $this->readColumns($client, $table); $clientIdx = $this->readIndexes($client, $table); $tableCollation = $this->readTableCollation($client, $table); $fkProtectedCols = $this->readForeignKeyProtectedColumns($client, $table); $hasAutoIncrement = $this->tableHasAutoIncrementColumn($clientCols); $prevCol = ''; foreach ($masterCols as $colName => $masterCol) { if (!isset($clientCols[$colName])) { if ($this->isAutoIncrementColumn((array) $masterCol) && $hasAutoIncrement) { $prevCol = $colName; continue; } $position = $prevCol === '' ? ' FIRST' : (' AFTER `' . $prevCol . '`'); $columnAlterClauses[] = 'ADD COLUMN ' . $this->columnDefinition($masterCol) . $position; $clientCols[$colName] = (array) $masterCol; if ($this->isAutoIncrementColumn((array) $masterCol)) { $hasAutoIncrement = true; } $prevCol = $colName; continue; } if (isset($fkProtectedCols[strtolower((string) $colName)])) { $prevCol = $colName; continue; } if ($this->shouldModifyExistingColumn((string) $colName, (array) $masterCol, (array) $clientCols[$colName], $clientCols, $clientIdx, $tableCollation)) { $columnAlterClauses[] = 'MODIFY COLUMN ' . $this->columnDefinition($masterCol); } $prevCol = $colName; } if (!empty($columnAlterClauses)) { $sql[] = 'ALTER TABLE `' . $table . '` ' . implode(', ', $columnAlterClauses) . ';'; } $masterIdx = is_array($masterTableDef['indexes'] ?? null) ? $masterTableDef['indexes'] : []; foreach ($masterIdx as $keyName => $masterDef) { if (!$this->areIndexColumnsAvailable((array) $masterDef, $clientCols)) { continue; } if (!isset($clientIdx[$keyName])) { if ($this->hasEquivalentIndexDefinition($clientIdx, (array) $masterDef)) { continue; } if ($this->isIndexTooWideForInnoDb((array) $masterDef, $clientCols, $tableCollation)) { continue; } if ($this->shouldSkipUniqueIndexSync($client, $table, (array) $masterDef, $clientCols)) { continue; } $sql[] = 'ALTER TABLE `' . $table . '` ADD ' . $this->indexDefinitionSql($masterDef) . ';'; continue; } if ($this->indexSignature($masterDef) !== $this->indexSignature($clientIdx[$keyName])) { if ($this->hasEquivalentIndexDefinition($clientIdx, (array) $masterDef, $keyName)) { continue; } if ($this->isIndexTooWideForInnoDb((array) $masterDef, $clientCols, $tableCollation)) { continue; } if ($this->shouldSkipUniqueIndexSync($client, $table, (array) $masterDef, $clientCols)) { continue; } if ($keyName === 'PRIMARY') { $sql[] = 'ALTER TABLE `' . $table . '` DROP PRIMARY KEY;'; } else { $sql[] = 'ALTER TABLE `' . $table . '` DROP INDEX `' . $keyName . '`;'; } $sql[] = 'ALTER TABLE `' . $table . '` ADD ' . $this->indexDefinitionSql($masterDef) . ';'; } } return $sql; } /** * @param array $idxDef * @param array> $clientCols */ private function areIndexColumnsAvailable(array $idxDef, array $clientCols): bool { $columns = array_values(array_filter(array_map(static fn ($col) => (string) $col, (array) ($idxDef['columns'] ?? [])))); if (empty($columns)) { return false; } foreach ($columns as $column) { if (!isset($clientCols[$column])) { return false; } } return true; } /** * @param array> $clientCols */ private function tableHasAutoIncrementColumn(array $clientCols): bool { foreach ($clientCols as $colMeta) { if ($this->isAutoIncrementColumn((array) $colMeta)) { return true; } } return false; } /** * @param array $colMeta */ private function isAutoIncrementColumn(array $colMeta): bool { $extra = strtolower(trim((string) ($colMeta['Extra'] ?? ''))); return str_contains($extra, 'auto_increment'); } /** * @return array */ private function readForeignKeyProtectedColumns($db, string $table): array { $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; if ($safeTable === '') { return []; } try { $dbNameRow = $db->query('SELECT DATABASE() AS db_name')->getRowArray(); $dbName = (string) ($dbNameRow['db_name'] ?? ''); if ($dbName === '') { return []; } $safeDb = str_replace("'", "''", $dbName); $out = []; $outbound = $db->query( "SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='{$safeDb}' AND TABLE_NAME='{$safeTable}' AND REFERENCED_TABLE_NAME IS NOT NULL" )->getResultArray(); foreach ($outbound as $row) { $name = strtolower((string) ($row['COLUMN_NAME'] ?? '')); if ($name !== '') { $out[$name] = true; } } $inbound = $db->query( "SELECT REFERENCED_COLUMN_NAME AS COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='{$safeDb}' AND REFERENCED_TABLE_NAME='{$safeTable}' AND REFERENCED_COLUMN_NAME IS NOT NULL" )->getResultArray(); foreach ($inbound as $row) { $name = strtolower((string) ($row['COLUMN_NAME'] ?? '')); if ($name !== '') { $out[$name] = true; } } return $out; } catch (\Throwable $e) { return []; } } /** * @param array> $clientIdx * @param array $masterDef */ private function hasEquivalentIndexDefinition(array $clientIdx, array $masterDef, ?string $ignoreKeyName = null): bool { $masterCols = array_values((array) ($masterDef['columns'] ?? [])); $masterType = strtoupper((string) ($masterDef['type'] ?? 'BTREE')); $masterUnique = (bool) ($masterDef['unique'] ?? false); $masterName = (string) ($masterDef['name'] ?? ''); $masterIsPrimary = strtoupper($masterName) === 'PRIMARY'; if ($masterIsPrimary) { $masterUnique = true; } if (empty($masterCols)) { return false; } foreach ($clientIdx as $clientKeyName => $clientDef) { if ($ignoreKeyName !== null && strcasecmp((string) $clientKeyName, $ignoreKeyName) === 0) { continue; } $clientCols = array_values((array) ($clientDef['columns'] ?? [])); $clientType = strtoupper((string) ($clientDef['type'] ?? 'BTREE')); $clientUnique = (bool) ($clientDef['unique'] ?? false); $clientName = (string) ($clientDef['name'] ?? ''); $clientIsPrimary = strtoupper($clientName) === 'PRIMARY'; if ($clientIsPrimary) { $clientUnique = true; } if ($masterCols === $clientCols && $masterType === $clientType && $masterUnique === $clientUnique) { return true; } } return false; } private function connectSyncDb(string $role) { $role = strtolower(trim($role)); $cfg = [ 'DSN' => '', 'hostname' => (string) env('setup.sync.' . $role . '.hostname', ''), 'username' => (string) env('setup.sync.' . $role . '.username', ''), 'password' => (string) env('setup.sync.' . $role . '.password', ''), 'database' => (string) env('setup.sync.' . $role . '.database', ''), 'DBDriver' => (string) env('setup.sync.' . $role . '.dbdriver', 'MySQLi'), 'DBPrefix' => '', 'pConnect' => false, 'DBDebug' => true, 'charset' => (string) env('setup.sync.' . $role . '.charset', 'utf8mb4'), 'DBCollat' => (string) env('setup.sync.' . $role . '.dbcollat', 'utf8mb4_general_ci'), 'swapPre' => '', 'encrypt' => false, 'compress' => false, 'strictOn' => false, 'failover' => [], 'port' => (int) env('setup.sync.' . $role . '.port', 3306), ]; if ($cfg['hostname'] === '' || $cfg['username'] === '' || $cfg['database'] === '') { throw new \RuntimeException('Missing setup.sync.' . $role . ' database config in .env'); } return \Config\Database::connect($cfg, false); } private function dbToolsUrlWithKey(): string { $url = base_url('setup/db-tools'); $key = trim((string) env('setup.dbToolsKey', '')); if ($key !== '') { $url .= '?key=' . urlencode($key); } return $url; } private function getCreateTableSql($db, string $table): string { $row = $db->query('SHOW CREATE TABLE `' . $table . '`')->getRowArray(); if (empty($row)) { return ''; } $create = ''; foreach ($row as $key => $val) { if (is_string($key) && stripos($key, 'Create Table') !== false) { $create = (string) $val; break; } } if ($create === '') { $values = array_values($row); $create = (string) ($values[1] ?? ''); } if ($create === '') { return ''; } $create = $this->ensureCreateTableIfNotExists((string) $create); if (!str_ends_with($create, ';')) { $create .= ';'; } return $create; } /** * @return array> */ private function readColumns($db, string $table): array { $rows = $db->query('SHOW FULL COLUMNS FROM `' . $table . '`')->getResultArray(); $out = []; foreach ($rows as $row) { $name = (string) ($row['Field'] ?? ''); if ($name !== '') { $out[$name] = $row; } } return $out; } /** * @return array> */ private function readIndexes($db, string $table): array { $rows = $db->query('SHOW INDEX FROM `' . $table . '`')->getResultArray(); $out = []; foreach ($rows as $row) { $key = (string) ($row['Key_name'] ?? ''); if ($key === '') { continue; } if (!isset($out[$key])) { $out[$key] = [ 'name' => $key, 'unique' => ((int) ($row['Non_unique'] ?? 1)) === 0, 'type' => strtoupper((string) ($row['Index_type'] ?? 'BTREE')), 'columns' => [], ]; } $seq = (int) ($row['Seq_in_index'] ?? 0); $out[$key]['columns'][$seq] = (string) ($row['Column_name'] ?? ''); } foreach ($out as $key => $def) { ksort($def['columns']); $def['columns'] = array_values(array_filter($def['columns'])); $out[$key] = $def; } return $out; } private function columnDefinition(array $col): string { $name = (string) ($col['Field'] ?? ''); $type = (string) ($col['Type'] ?? 'text'); $null = strtoupper((string) ($col['Null'] ?? 'YES')) === 'NO' ? ' NOT NULL' : ' NULL'; $extra = trim((string) ($col['Extra'] ?? '')); $default = $this->columnDefaultSql($col); $collation = trim((string) ($col['Collation'] ?? '')); $comment = trim((string) ($col['Comment'] ?? '')); $applyCollation = strtolower(trim((string) env('setup.sync.apply_column_collation', 'false'))); $canApplyCollation = in_array($applyCollation, ['1', 'true', 'yes', 'on'], true); $sql = '`' . $name . '` ' . $type; if ($collation !== '' && $canApplyCollation) { $sql .= ' COLLATE ' . $this->normalizeCollationName($collation); } $sql .= $null . $default; if ($extra !== '') { $extra = preg_replace('/\bDEFAULT_GENERATED\b/i', '', $extra) ?? $extra; $extra = trim(preg_replace('/\s+/', ' ', $extra) ?? $extra); if ($extra !== '') { $sql .= ' ' . strtoupper($extra); } } if ($comment !== '') { $sql .= " COMMENT '" . str_replace("'", "''", $comment) . "'"; } return $sql; } private function columnDefaultSql(array $col): string { if (!array_key_exists('Default', $col)) { return ''; } $default = $col['Default']; if ($default === null) { $isNotNull = strtoupper((string) ($col['Null'] ?? 'YES')) === 'NO'; return $isNotNull ? '' : ' DEFAULT NULL'; } $defaultStr = (string) $default; $upper = strtoupper($defaultStr); if ($upper === 'CURRENT_TIMESTAMP' || str_starts_with($upper, 'CURRENT_TIMESTAMP(')) { return ' DEFAULT ' . $defaultStr; } return " DEFAULT '" . str_replace("'", "''", $defaultStr) . "'"; } private function columnSignature(array $col): string { $type = $this->normalizeColumnTypeForCompare((string) ($col['Type'] ?? '')); $nullability = $this->normalizeColumnNullabilityForCompare($col); $defaultNormalized = $this->normalizeColumnDefaultFromMetadata($col); return json_encode([ $type, $nullability, $defaultNormalized, ]) ?: ''; } private function normalizeColumnTypeForCompare(string $type): string { $type = strtolower(trim($type)); $type = preg_replace('/\s+/', ' ', $type) ?? $type; if ($type === '') { return ''; } // MySQL/MariaDB may report integer display width differently: int vs int(11), int unsigned vs int(10) unsigned. // Ignore only integer display width for comparison to avoid false-positive MODIFY statements. $type = preg_replace('/\b(tinyint|smallint|mediumint|int|integer|bigint)\s*\(\s*\d+\s*\)/i', '$1', $type) ?? $type; $type = preg_replace('/\s+/', ' ', $type) ?? $type; return trim($type); } /** * @param array $masterCol * @param array $clientCol * @param array> $clientCols * @param array> $clientIdx */ private function shouldModifyExistingColumn(string $columnName, array $masterCol, array $clientCol, array $clientCols, array $clientIdx, string $tableCollation): bool { $masterType = $this->normalizeColumnTypeForCompare((string) ($masterCol['Type'] ?? '')); $clientType = $this->normalizeColumnTypeForCompare((string) ($clientCol['Type'] ?? '')); $isCompatibleWider = $this->isCompatibleWiderType($clientType, $masterType); $isNarrowing = $this->isNarrowingTypeChange($clientType, $masterType); $typeMatches = $masterType === $clientType || $isCompatibleWider; if (!$typeMatches) { if ($isNarrowing) { return false; } if ($this->isUnsafeIndexedWidening($columnName, $masterType, $clientType, $masterCol, $clientCol, $clientCols, $clientIdx, $tableCollation)) { return false; } return true; } $masterNullability = $this->normalizeColumnNullabilityForCompare($masterCol); $clientNullability = $this->normalizeColumnNullabilityForCompare($clientCol); if ($masterNullability !== $clientNullability) { $tightenNotNull = $masterNullability === 'NO' && $clientNullability === 'YES'; $enforceNotNull = in_array(strtolower(trim((string) env('setup.sync.enforce_not_null', 'false'))), ['1', 'true', 'yes', 'on'], true); if ($tightenNotNull && !$enforceNotNull) { return false; } return true; } // For text/blob families, default metadata differs across MySQL/MariaDB and can cause noisy unsafe narrowing alters. // If client type is already equivalent/wider, skip default-based modifications. if ($isCompatibleWider && ($this->isTextFamilyType($masterType) || $this->isBlobFamilyType($masterType))) { return false; } if ($this->normalizeColumnDefaultFromMetadata($masterCol) !== $this->normalizeColumnDefaultFromMetadata($clientCol)) { return true; } return false; } private function isNarrowingTypeChange(string $clientType, string $masterType): bool { $client = $this->parseColumnTypeSpec($clientType); $master = $this->parseColumnTypeSpec($masterType); if ($client === null || $master === null) { return false; } if ($client['base'] === $master['base'] && $client['unsigned'] === $master['unsigned']) { if (in_array($client['base'], ['varchar', 'char', 'varbinary', 'binary'], true) && $client['length'] !== null && $master['length'] !== null ) { return $master['length'] < $client['length']; } return false; } $textOrder = [ 'tinytext' => 1, 'text' => 2, 'mediumtext' => 3, 'longtext' => 4, ]; if (isset($textOrder[$client['base']], $textOrder[$master['base']])) { return $textOrder[$master['base']] < $textOrder[$client['base']]; } $blobOrder = [ 'tinyblob' => 1, 'blob' => 2, 'mediumblob' => 3, 'longblob' => 4, ]; if (isset($blobOrder[$client['base']], $blobOrder[$master['base']])) { return $blobOrder[$master['base']] < $blobOrder[$client['base']]; } return false; } /** * @param array $masterCol * @param array $clientCol * @param array> $clientCols * @param array> $clientIdx */ private function isUnsafeIndexedWidening(string $columnName, string $masterType, string $clientType, array $masterCol, array $clientCol, array $clientCols, array $clientIdx, string $tableCollation): bool { $masterSpec = $this->parseColumnTypeSpec($masterType); $clientSpec = $this->parseColumnTypeSpec($clientType); if ($masterSpec === null || $clientSpec === null) { return false; } if ($masterSpec['base'] !== $clientSpec['base'] || $masterSpec['unsigned'] !== $clientSpec['unsigned']) { return false; } if (!in_array($masterSpec['base'], ['varchar', 'char', 'varbinary', 'binary'], true)) { return false; } if ($masterSpec['length'] === null || $clientSpec['length'] === null || $masterSpec['length'] <= $clientSpec['length']) { return false; } $effectiveCollation = trim((string) ($clientCol['Collation'] ?? '')); if ($effectiveCollation === '') { $effectiveCollation = trim($tableCollation); } $overrideMeta = $clientCol; $overrideMeta['Type'] = (string) ($masterCol['Type'] ?? $clientCol['Type'] ?? ''); if ($effectiveCollation !== '') { $overrideMeta['Collation'] = $effectiveCollation; } foreach ($clientIdx as $idxDef) { $columns = array_values(array_map(static fn ($c) => (string) $c, (array) ($idxDef['columns'] ?? []))); if (!in_array($columnName, $columns, true)) { continue; } $totalBytes = 0; foreach ($columns as $idxColumn) { if ($idxColumn === $columnName) { $totalBytes += $this->estimateIndexedColumnBytes($overrideMeta, $tableCollation); } else { $otherMeta = $clientCols[$idxColumn] ?? null; if (!is_array($otherMeta)) { continue 2; } $totalBytes += $this->estimateIndexedColumnBytes($otherMeta, $tableCollation); } } if ($totalBytes > 3072) { return true; } } return false; } /** * @param array $col */ private function normalizeColumnNullabilityForCompare(array $col): string { $nullability = strtoupper(trim((string) ($col['Null'] ?? 'YES'))); return $nullability === 'NO' ? 'NO' : 'YES'; } /** * @param array $col */ private function normalizeColumnDefaultFromMetadata(array $col): string { $default = $col['Default'] ?? null; if ($default === null) { return '__NULL__'; } return $this->normalizeColumnDefaultForCompare((string) $default); } private function isCompatibleWiderType(string $clientType, string $masterType): bool { $client = $this->parseColumnTypeSpec($clientType); $master = $this->parseColumnTypeSpec($masterType); if ($client === null || $master === null) { return false; } if ($client['base'] === $master['base']) { if ($client['unsigned'] !== $master['unsigned']) { return false; } if (in_array($client['base'], ['varchar', 'char', 'varbinary', 'binary'], true)) { if ($client['length'] === null || $master['length'] === null) { return false; } return $client['length'] >= $master['length']; } return false; } $textOrder = [ 'tinytext' => 1, 'text' => 2, 'mediumtext' => 3, 'longtext' => 4, ]; if (isset($textOrder[$client['base']], $textOrder[$master['base']])) { return $textOrder[$client['base']] >= $textOrder[$master['base']]; } $blobOrder = [ 'tinyblob' => 1, 'blob' => 2, 'mediumblob' => 3, 'longblob' => 4, ]; if (isset($blobOrder[$client['base']], $blobOrder[$master['base']])) { return $blobOrder[$client['base']] >= $blobOrder[$master['base']]; } return false; } /** * @return array{base:string,unsigned:bool,length:int|null}|null */ private function parseColumnTypeSpec(string $type): ?array { $type = trim(strtolower($type)); if ($type === '') { return null; } $type = preg_replace('/\s+/', ' ', $type) ?? $type; if (preg_match('/^([a-z]+)\s*\(\s*(\d+)\s*\)\s*(unsigned)?$/i', $type, $m) === 1) { return [ 'base' => strtolower((string) $m[1]), 'length' => (int) $m[2], 'unsigned' => !empty($m[3]), ]; } if (preg_match('/^([a-z]+)\s*(unsigned)?$/i', $type, $m) === 1) { return [ 'base' => strtolower((string) $m[1]), 'length' => null, 'unsigned' => !empty($m[2]), ]; } return null; } private function normalizeColumnDefaultForCompare(string $default): string { $defaultStr = trim($default); if ($defaultStr === '') { return ''; } if (strcasecmp($defaultStr, 'NULL') === 0) { return '__NULL__'; } $upper = strtoupper($defaultStr); // Treat CURRENT_TIMESTAMP, CURRENT_TIMESTAMP(), and CURRENT_TIMESTAMP(0) as equivalent. if (preg_match('/^CURRENT_TIMESTAMP\s*(?:\(\s*0?\s*\))?$/i', $upper) === 1) { return 'CURRENT_TIMESTAMP'; } return $defaultStr; } private function isTextFamilyType(string $type): bool { $base = strtolower(trim((string) preg_replace('/\s*\(.*/', '', $type))); return in_array($base, ['tinytext', 'text', 'mediumtext', 'longtext'], true); } private function isBlobFamilyType(string $type): bool { $base = strtolower(trim((string) preg_replace('/\s*\(.*/', '', $type))); return in_array($base, ['tinyblob', 'blob', 'mediumblob', 'longblob'], true); } private function ensureCreateTableIfNotExists(string $sql): string { $sql = trim($sql); if ($sql === '') { return ''; } $sql = preg_replace('/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i', 'CREATE TABLE IF NOT EXISTS ', $sql, 1) ?? $sql; return $this->normalizeSyncSql($sql); } private function normalizeCollationName(string $collation): string { $collation = trim($collation); if ($collation === '') { return ''; } if (preg_match('/^utf8mb4_0900_/i', $collation) === 1) { return (string) env('setup.sync.collation_fallback', 'utf8mb4_general_ci'); } return $collation; } private function normalizeSyncSql(string $sql): string { $sql = trim($sql); if ($sql === '') { return ''; } $fallbackCollation = $this->normalizeCollationName('utf8mb4_0900_ai_ci'); $sql = preg_replace('/\bDEFAULT_GENERATED\b/i', '', $sql) ?? $sql; $sql = preg_replace('/\butf8mb4_0900_[a-z0-9_]+\b/i', $fallbackCollation, $sql) ?? $sql; $sql = preg_replace('/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)+/i', 'CREATE TABLE IF NOT EXISTS ', $sql, 1) ?? $sql; $sql = preg_replace('/\s+/', ' ', $sql) ?? $sql; $sql = preg_replace('/\s+;$/', ';', $sql) ?? $sql; return trim($sql); } private function isCreateTableStatement(string $stmt): bool { return preg_match('/^CREATE\s+TABLE\s+/i', ltrim($stmt)) === 1; } private function isSafeToSkipSyncStatement(string $message, string $stmt): bool { $messageLower = strtolower($message); $stmtLower = strtolower($stmt); if (str_contains($messageLower, 'used in a foreign key constraint') && str_contains($stmtLower, 'alter table')) { return true; } if (str_contains($messageLower, 'blob/text column') && str_contains($messageLower, 'key specification without a key length')) { return true; } if (str_contains($messageLower, 'duplicate entry') && str_contains($stmtLower, 'alter table') && (str_contains($stmtLower, 'add unique key') || str_contains($stmtLower, 'add primary key'))) { return true; } if (str_contains($messageLower, 'specified key was too long') && str_contains($messageLower, 'max key length is') && str_contains($stmtLower, 'alter table')) { return true; } return false; } private function shouldDeferSyncStatement(string $message, string $stmt): bool { if (!$this->isCreateTableStatement($stmt) && stripos($stmt, 'ALTER TABLE') !== 0) { return false; } $messageLower = strtolower($message); if (str_contains($messageLower, 'failed to open the referenced table')) { return true; } if (str_contains($messageLower, 'cannot add foreign key constraint')) { return true; } if (str_contains($messageLower, 'foreign key constraint is incorrectly formed')) { return true; } if (str_contains($messageLower, 'errno: 150')) { return true; } return false; } private function canRetryCreateTableWithoutForeignKeys(string $stmt, string $message): bool { if (!$this->isCreateTableStatement($stmt)) { return false; } $messageLower = strtolower($message); if (str_contains($messageLower, 'cannot add foreign key constraint')) { return true; } if (str_contains($messageLower, 'failed to open the referenced table')) { return true; } if (str_contains($messageLower, 'foreign key constraint is incorrectly formed')) { return true; } if (str_contains($messageLower, 'errno: 150')) { return true; } return false; } /** * @param array $idxDef * @param array> $clientCols */ private function isIndexTooWideForInnoDb(array $idxDef, array $clientCols, string $tableCollation = ''): bool { $columns = array_values(array_filter(array_map(static fn ($col) => (string) $col, (array) ($idxDef['columns'] ?? [])))); if (empty($columns)) { return false; } $totalBytes = 0; foreach ($columns as $column) { $colMeta = $clientCols[$column] ?? null; if (!is_array($colMeta)) { return false; } $totalBytes += $this->estimateIndexedColumnBytes($colMeta, $tableCollation); if ($totalBytes > 3072) { return true; } } return $totalBytes > 3072; } /** * @param array $colMeta */ private function estimateIndexedColumnBytes(array $colMeta, string $tableCollation = ''): int { $type = strtolower(trim((string) ($colMeta['Type'] ?? ''))); $collation = strtolower(trim((string) ($colMeta['Collation'] ?? ''))); if ($collation === '') { $collation = strtolower(trim($tableCollation)); } $bytesPerChar = 1; if ($collation !== '') { if (str_starts_with($collation, 'utf32_')) { $bytesPerChar = 4; } elseif (str_starts_with($collation, 'utf8mb4_')) { $bytesPerChar = 4; } elseif (str_starts_with($collation, 'utf8mb3_') || str_starts_with($collation, 'utf8_')) { $bytesPerChar = 3; } elseif (str_starts_with($collation, 'ucs2_')) { $bytesPerChar = 2; } } if (preg_match('/^(char|varchar)\((\d+)\)/i', $type, $m) === 1) { return (int) $m[2] * $bytesPerChar; } if (preg_match('/^(binary|varbinary)\((\d+)\)/i', $type, $m) === 1) { return (int) $m[2]; } if (preg_match('/^tinytext|^text|^mediumtext|^longtext|^tinyblob|^blob|^mediumblob|^longblob/i', $type) === 1) { return 4096; } if (preg_match('/^tinyint/i', $type) === 1) { return 1; } if (preg_match('/^smallint/i', $type) === 1) { return 2; } if (preg_match('/^mediumint/i', $type) === 1) { return 3; } if (preg_match('/^(int|integer)/i', $type) === 1) { return 4; } if (preg_match('/^bigint/i', $type) === 1) { return 8; } return 64; } private function readTableCollation($db, string $table): string { $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; if ($safeTable === '') { return ''; } try { $row = $db->query("SHOW TABLE STATUS LIKE '{$safeTable}'")->getRowArray(); if (!is_array($row)) { return ''; } return (string) ($row['Collation'] ?? ''); } catch (\Throwable $e) { return ''; } } /** * @param array $idxDef * @param array> $clientCols */ private function shouldSkipUniqueIndexSync($db, string $table, array $idxDef, array $clientCols): bool { $isPrimary = strtoupper((string) ($idxDef['name'] ?? '')) === 'PRIMARY'; $isUnique = $isPrimary || (bool) ($idxDef['unique'] ?? false); if (!$isUnique) { return false; } $columns = array_values(array_filter(array_map(static fn ($col) => (string) $col, (array) ($idxDef['columns'] ?? [])))); if (empty($columns)) { return false; } $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; if ($safeTable === '') { return false; } $safeColumns = []; $notNullConditions = []; foreach ($columns as $column) { $safeColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $column) ?? ''; if ($safeColumn === '') { return false; } $safeColumns[] = '`' . $safeColumn . '`'; $colMeta = $clientCols[$safeColumn] ?? null; $isNullable = strtoupper((string) ($colMeta['Null'] ?? 'YES')) !== 'NO'; if ($isNullable) { $notNullConditions[] = '`' . $safeColumn . '` IS NOT NULL'; } } $groupBy = implode(', ', $safeColumns); $sql = 'SELECT 1 FROM `' . $safeTable . '`'; if (!empty($notNullConditions)) { $sql .= ' WHERE ' . implode(' AND ', $notNullConditions); } $sql .= ' GROUP BY ' . $groupBy . ' HAVING COUNT(*) > 1 LIMIT 1'; try { $row = $db->query($sql)->getRowArray(); return !empty($row); } catch (\Throwable $e) { return false; } } private function stripForeignKeysFromCreateTable(string $stmt): string { $out = $stmt; $patterns = [ '/\s*,\s*CONSTRAINT\s+`[^`]+`\s+FOREIGN\s+KEY\s*\([^\)]*\)\s*REFERENCES\s*`[^`]+`\s*\([^\)]*\)(?:\s+ON\s+DELETE\s+[A-Z_ ]+)?(?:\s+ON\s+UPDATE\s+[A-Z_ ]+)?/i', '/\s*,\s*FOREIGN\s+KEY\s*\([^\)]*\)\s*REFERENCES\s*`[^`]+`\s*\([^\)]*\)(?:\s+ON\s+DELETE\s+[A-Z_ ]+)?(?:\s+ON\s+UPDATE\s+[A-Z_ ]+)?/i', ]; foreach ($patterns as $pattern) { $out = preg_replace($pattern, '', $out) ?? $out; } $out = preg_replace('/,\s*\)/', ')', $out) ?? $out; $out = preg_replace('/\s+/', ' ', $out) ?? $out; $out = trim($out); if ($out !== '' && !str_ends_with($out, ';')) { $out .= ';'; } return $this->normalizeSyncSql($out); } private function indexDefinitionSql(array $idx): string { $name = (string) ($idx['name'] ?? ''); $columns = array_values((array) ($idx['columns'] ?? [])); $columnSql = implode(',', array_map(static fn ($c) => '`' . $c . '`', $columns)); if ($name === 'PRIMARY') { return 'PRIMARY KEY (' . $columnSql . ')'; } $type = strtoupper((string) ($idx['type'] ?? 'BTREE')); $unique = (bool) ($idx['unique'] ?? false); if ($type === 'FULLTEXT') { return 'FULLTEXT KEY `' . $name . '` (' . $columnSql . ')'; } if ($type === 'SPATIAL') { return 'SPATIAL KEY `' . $name . '` (' . $columnSql . ')'; } if ($unique) { return 'UNIQUE KEY `' . $name . '` (' . $columnSql . ')'; } return 'KEY `' . $name . '` (' . $columnSql . ')'; } private function indexSignature(array $idx): string { return json_encode([ (string) ($idx['name'] ?? ''), (bool) ($idx['unique'] ?? false), strtoupper((string) ($idx['type'] ?? 'BTREE')), array_values((array) ($idx['columns'] ?? [])), ]) ?: ''; } /** * @return array */ private function detectScriptedTables(): array { $result = []; $files = glob(APPPATH . 'Database/Migrations/*.php') ?: []; foreach ($files as $filePath) { $content = @file_get_contents($filePath); if (!is_string($content) || $content === '') { continue; } if (preg_match_all('/createTable\(\s*[\'\"]([a-zA-Z0-9_]+)[\'\"]/i', $content, $m1) === 1 || !empty($m1[1])) { foreach (($m1[1] ?? []) as $table) { $result[strtolower((string) $table)] = true; } } if (preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([a-zA-Z0-9_]+)`?/i', $content, $m2) === 1 || !empty($m2[1])) { foreach (($m2[1] ?? []) as $table) { $result[strtolower((string) $table)] = true; } } } return $result; } private function studly(string $value): string { $value = preg_replace('/[^a-zA-Z0-9]+/', ' ', $value) ?? $value; $value = ucwords(strtolower(trim($value))); return str_replace(' ', '', $value); } /** * @return array */ private function requiredWritablePaths(): array { return [ WRITEPATH, WRITEPATH . 'cache', WRITEPATH . 'logs', WRITEPATH . 'session', WRITEPATH . 'uploads', WRITEPATH . 'debugbar', WRITEPATH . 'tmp', FCPATH . 'uploads', ]; } /** * @return array> */ private function prepareWritablePaths(): array { $checked = []; $created = []; $permissionUpdated = []; $writable = []; $failed = []; $paths = $this->requiredWritablePaths(); foreach ($paths as $path) { $checked[] = $this->relativeProjectPath($path); if (!is_dir($path)) { if (@mkdir($path, 0775, true)) { $created[] = $this->relativeProjectPath($path); } } if (is_dir($path) && !is_writable($path)) { if (@chmod($path, 0775)) { $permissionUpdated[] = $this->relativeProjectPath($path); } } if (is_dir($path) && is_writable($path)) { $writable[] = $this->relativeProjectPath($path); } else { $failed[] = $this->relativeProjectPath($path); } } $commands = []; if (!empty($failed)) { $projectPath = rtrim(str_replace('\\', '/', ROOTPATH), '/'); $commands[] = 'sudo chown -R www-data:www-data "' . $projectPath . '/writable" "' . $projectPath . '/public/uploads"'; $commands[] = 'sudo find "' . $projectPath . '/writable" -type d -exec chmod 775 {} \;'; $commands[] = 'sudo find "' . $projectPath . '/writable" -type f -exec chmod 664 {} \;'; $commands[] = 'sudo chmod -R 775 "' . $projectPath . '/public/uploads"'; } return [ 'checked' => $checked, 'created' => $created, 'permission_updated' => $permissionUpdated, 'writable' => $writable, 'failed' => array_values(array_unique($failed)), 'commands' => $commands, ]; } private function relativeProjectPath(string $absolutePath): string { $normalizedPath = str_replace('\\', '/', $absolutePath); $normalizedRoot = str_replace('\\', '/', ROOTPATH); if (str_starts_with($normalizedPath, $normalizedRoot)) { return ltrim(substr($normalizedPath, strlen($normalizedRoot)), '/'); } return $absolutePath; } /** * @return array{applied:array,warnings:array,errors:array} */ private function repairAuthSchemaInternal(): array { $applied = []; $warnings = []; $errors = []; try { $tables = config('Auth')->tables; $usersTable = (string) ($tables['users'] ?? 'users'); if (!$this->tableExists($usersTable)) { $errors[] = 'Auth users table not found: ' . $usersTable . '. Run schema sync first.'; return ['applied' => $applied, 'warnings' => $warnings, 'errors' => $errors]; } $requiredColumns = [ 'deleted_at' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `deleted_at` DATETIME NULL DEFAULT NULL AFTER `updated_at`', 'updated_at' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `updated_at` DATETIME NULL DEFAULT NULL AFTER `created_at`', 'created_at' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `created_at` DATETIME NULL DEFAULT NULL AFTER `last_active`', 'last_active' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `last_active` DATETIME NULL DEFAULT NULL AFTER `active`', 'active' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `active` TINYINT(1) NOT NULL DEFAULT 0 AFTER `status_message`', 'status_message' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `status_message` VARCHAR(255) NULL DEFAULT NULL AFTER `status`', 'status' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `status` VARCHAR(255) NULL DEFAULT NULL AFTER `username`', 'username' => 'ALTER TABLE `' . $usersTable . '` ADD COLUMN `username` VARCHAR(30) NULL DEFAULT NULL AFTER `id`', ]; foreach ($requiredColumns as $column => $sql) { if ($this->columnExists($usersTable, $column)) { continue; } try { $this->db->query($sql); $applied[] = $usersTable . '.' . $column; } catch (\Throwable $e) { $errors[] = 'Failed to add ' . $usersTable . '.' . $column . ': ' . $e->getMessage(); } } if (!$this->columnExists($usersTable, 'deleted_at')) { $errors[] = 'Column still missing after repair: ' . $usersTable . '.deleted_at'; } } catch (\Throwable $e) { $errors[] = 'Auth schema repair exception: ' . $e->getMessage(); } return ['applied' => $applied, 'warnings' => $warnings, 'errors' => $errors]; } /** * @return array{warnings:array,errors:array} */ private function checkAuthSchemaHealth(): array { $warnings = []; $errors = []; try { $tables = config('Auth')->tables; $usersTable = (string) ($tables['users'] ?? 'users'); if (!$this->tableExists($usersTable)) { $errors[] = 'Auth users table missing: ' . $usersTable . '.'; return ['warnings' => $warnings, 'errors' => $errors]; } if (!$this->columnExists($usersTable, 'deleted_at')) { $errors[] = 'Missing required auth column: ' . $usersTable . '.deleted_at (causes login failure).'; } } catch (\Throwable $e) { $warnings[] = 'Auth schema diagnostics skipped: ' . $e->getMessage(); } return ['warnings' => $warnings, 'errors' => $errors]; } private function tableExists(string $tableName): bool { $tableName = trim($tableName); if ($tableName === '') { return false; } try { return $this->resolveTableNameInConnection($this->db, $tableName) !== null; } catch (\Throwable $e) { return false; } } private function tableExistsInConnection($db, string $tableName): bool { $tableName = trim($tableName); if ($tableName === '') { return false; } try { return $this->resolveTableNameInConnection($db, $tableName) !== null; } catch (\Throwable $e) { return false; } } private function resolveTableNameInConnection($db, string $tableName): ?string { $tableName = trim($tableName); if ($tableName === '') { return null; } try { $tables = $db->listTables(); foreach ($tables as $existingTable) { $existing = (string) $existingTable; if (strcasecmp($existing, $tableName) === 0) { return $existing; } } } catch (\Throwable $e) { return null; } return null; } private function columnExists(string $tableName, string $columnName): bool { $tableName = trim($tableName); $columnName = trim($columnName); if ($tableName === '' || $columnName === '') { return false; } try { $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $tableName) ?? ''; $safeColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $columnName) ?? ''; if ($safeTable === '' || $safeColumn === '') { return false; } $row = $this->db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'")->getRowArray(); return !empty($row); } catch (\Throwable $e) { return false; } } /** * @return array{errors:array} */ private function upsertEmailPasswordIdentity(int $userId, string $email, string $plainPassword): array { $errors = []; if ($userId <= 0) { return ['errors' => ['Invalid user id for auth identity update.']]; } try { $tables = config('Auth')->tables; $identitiesTable = (string) ($tables['identities'] ?? 'auth_identities'); $passwordHash = service('passwords')->hash($plainPassword); $rows = $this->db->table($identitiesTable) ->select('id,user_id,type,secret') ->where('user_id', $userId) ->where('type', 'email_password') ->orderBy('id', 'ASC') ->get() ->getResultArray(); if (empty($rows)) { $insert = [ 'user_id' => $userId, 'type' => 'email_password', 'secret' => $email, 'secret2' => $passwordHash, 'expires' => null, 'extra' => null, 'force_reset' => 0, 'last_used_at' => null, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; $this->db->table($identitiesTable)->insert($insert); return ['errors' => []]; } $keepId = (int) ($rows[0]['id'] ?? 0); if ($keepId <= 0) { return ['errors' => ['Unable to determine auth identity id for update.']]; } $this->db->table($identitiesTable) ->where('id', $keepId) ->update([ 'secret' => $email, 'secret2' => $passwordHash, 'force_reset' => 0, 'updated_at' => date('Y-m-d H:i:s'), ]); $duplicateIds = []; foreach ($rows as $row) { $id = (int) ($row['id'] ?? 0); if ($id > 0 && $id !== $keepId) { $duplicateIds[] = $id; } } if (!empty($duplicateIds)) { $this->db->table($identitiesTable) ->whereIn('id', $duplicateIds) ->delete(); } } catch (\Throwable $e) { $errors[] = 'Auth identity update failed: ' . $e->getMessage(); } return ['errors' => $errors]; } }