- Remove Gemini runtime fallback from OPD AI text generation and complaint drafting - Remove legacy Gemini scan extraction path - Update UI/provider labels to Azure/AI generic naming - Make AI usage guardrail metrics provider-neutral (ai_today) - Add seed export sanitization to exclude sensitive hospital_setting and audit entries - Remove Gemini API audit entry from master seed JSON
2696 lines
92 KiB
PHP
2696 lines
92 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Controllers\BaseController;
|
|
use CodeIgniter\Shield\Entities\User;
|
|
use CodeIgniter\Shield\Models\UserModel;
|
|
|
|
class Setup extends BaseController
|
|
{
|
|
/** @var array<string, mixed>|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<string, mixed>
|
|
*/
|
|
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();
|
|
$rows = is_array($rows) ? $rows : [];
|
|
$seedPayload['tables'][$resolvedTable] = $this->sanitizeSeedRows($resolvedTable, $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 = "<?php\n\nnamespace App\\Database\\Migrations;\n\nuse CodeIgniter\\Database\\Migration;\n\nclass {$className} extends Migration\n{\n public function up()\n {\n \$this->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<string, mixed>
|
|
*/
|
|
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<string, array<int, string>>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, string> $createSqlByTable
|
|
* @param array<int, string> $alreadyExistingTables
|
|
* @return array<int, string>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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<int, string> $sqlList
|
|
* @return array{applied:int,errors:array<int,string>,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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $rows
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function sanitizeSeedRows(string $tableName, array $rows): array
|
|
{
|
|
$table = strtolower(trim($tableName));
|
|
if ($rows === []) {
|
|
return $rows;
|
|
}
|
|
|
|
if ($table === 'hospital_setting') {
|
|
return array_values(array_filter($rows, function (array $row): bool {
|
|
$name = strtoupper(trim((string) ($row['s_name'] ?? '')));
|
|
return ! $this->isSensitiveSettingName($name);
|
|
}));
|
|
}
|
|
|
|
if ($table === 'clinical_audit_trail') {
|
|
return array_values(array_filter($rows, function (array $row): bool {
|
|
if (strtolower(trim((string) ($row['module'] ?? ''))) !== 'hospital_setting') {
|
|
return true;
|
|
}
|
|
|
|
$recordId = strtoupper(trim((string) ($row['record_id'] ?? '')));
|
|
$fieldName = strtoupper(trim((string) ($row['field_name'] ?? '')));
|
|
return ! ($this->isSensitiveSettingName($recordId) || $this->isSensitiveSettingName($fieldName));
|
|
}));
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function isSensitiveSettingName(string $name): bool
|
|
{
|
|
if ($name === '') {
|
|
return false;
|
|
}
|
|
|
|
if (str_contains($name, 'API_KEY') || str_contains($name, 'TOKEN') || str_contains($name, 'SECRET')) {
|
|
return true;
|
|
}
|
|
|
|
foreach (['GEMINI_', 'AZURE_', 'OPENAI_'] as $prefix) {
|
|
if (str_starts_with($name, $prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, array<string, mixed>>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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<string, mixed> $idxDef
|
|
* @param array<string, array<string, mixed>> $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<string, array<string, mixed>> $clientCols
|
|
*/
|
|
private function tableHasAutoIncrementColumn(array $clientCols): bool
|
|
{
|
|
foreach ($clientCols as $colMeta) {
|
|
if ($this->isAutoIncrementColumn((array) $colMeta)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $colMeta
|
|
*/
|
|
private function isAutoIncrementColumn(array $colMeta): bool
|
|
{
|
|
$extra = strtolower(trim((string) ($colMeta['Extra'] ?? '')));
|
|
return str_contains($extra, 'auto_increment');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, bool>
|
|
*/
|
|
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<string, array<string, mixed>> $clientIdx
|
|
* @param array<string, mixed> $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<string, array<string, mixed>>
|
|
*/
|
|
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<string, array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $masterCol
|
|
* @param array<string, mixed> $clientCol
|
|
* @param array<string, array<string, mixed>> $clientCols
|
|
* @param array<string, array<string, mixed>> $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<string, mixed> $masterCol
|
|
* @param array<string, mixed> $clientCol
|
|
* @param array<string, array<string, mixed>> $clientCols
|
|
* @param array<string, array<string, mixed>> $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<string, mixed> $col
|
|
*/
|
|
private function normalizeColumnNullabilityForCompare(array $col): string
|
|
{
|
|
$nullability = strtoupper(trim((string) ($col['Null'] ?? 'YES')));
|
|
return $nullability === 'NO' ? 'NO' : 'YES';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $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<string, mixed> $idxDef
|
|
* @param array<string, array<string, mixed>> $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<string, mixed> $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<string, mixed> $idxDef
|
|
* @param array<string, array<string, mixed>> $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<string, bool>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
private function requiredWritablePaths(): array
|
|
{
|
|
return [
|
|
WRITEPATH,
|
|
WRITEPATH . 'cache',
|
|
WRITEPATH . 'logs',
|
|
WRITEPATH . 'session',
|
|
WRITEPATH . 'uploads',
|
|
WRITEPATH . 'debugbar',
|
|
WRITEPATH . 'tmp',
|
|
FCPATH . 'uploads',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<int, string>>
|
|
*/
|
|
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<int,string>,warnings:array<int,string>,errors:array<int,string>}
|
|
*/
|
|
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<int,string>,errors:array<int,string>}
|
|
*/
|
|
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<int,string>}
|
|
*/
|
|
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];
|
|
}
|
|
}
|