aiwend_"). A test keeps this equal to the migrated schema. */ public const TABLES = [ 'audit', 'bot_sources', 'bots', 'budgets', 'chat_sessions', 'connections', 'jobs', 'knowledge_blobs', 'knowledge_chunks', 'knowledge_claims', 'knowledge_generations', 'knowledge_site_index', 'knowledge_sources', 'knowledge_versions', 'form_submissions', 'message_citations', 'message_feedback', 'messages', 'reservations', 'schema', ]; public function __construct(private Connection $db, private string $prefix, private string $optionsTable) { if (!preg_match('/^[A-Za-z0-9_]+$/', $prefix) || !preg_match('/^[A-Za-z0-9_]+$/', $optionsTable)) { throw new \InvalidArgumentException('Invalid table prefix.'); } } /** @return list the tables dropped */ public function dropTables(): array { $dropped = []; $failure = null; foreach (self::TABLES as $suffix) { $name = $this->prefix . 'aiwend_' . $suffix; if ($this->tableExists($name)) { try { $this->db->execute('DROP TABLE `' . $name . '`'); $dropped[] = $name; } catch (\Throwable $e) { $failure ??= $e; // e.g. a foreign key from another plugin's table: keep dropping the rest, then report } } } if ($failure !== null) { throw $failure; } return $dropped; } /** * Options named aiwend_* and their transients; returns the number removed. The administrator's own "delete on uninstall" flag is * kept when $keepFlag is true, so a run that fails half way can be repeated (deleteFlag() removes it last). * * The prefix test uses substr(), not a pattern match with an escape clause: that clause is spelled differently on SQLite and on * MySQL/MariaDB (a lone backslash literal is a syntax error there), and a plain prefix comparison needs no escaping at all. */ public function deleteOptions(bool $keepFlag = false): int { if (!$this->tableExists($this->optionsTable)) { return 0; } $n = 0; foreach (['aiwend_', '_transient_aiwend_', '_transient_timeout_aiwend_'] as $prefix) { $sql = 'DELETE FROM `' . $this->optionsTable . '` WHERE substr(option_name, 1, ' . strlen($prefix) . ') = ?'; $params = [$prefix]; if ($keepFlag) { $sql .= ' AND option_name <> ?'; $params[] = self::OPTION; } $n += $this->db->execute($sql, $params); } return $n; } /** Removes the administrator's flag; the last step of a successful uninstall. */ public function deleteFlag(): int { return $this->tableExists($this->optionsTable) ? $this->db->execute('DELETE FROM `' . $this->optionsTable . '` WHERE option_name = ?', [self::OPTION]) : 0; } /** True when any of this site's Aiwend tables exists (used to decide whether a key file is still needed elsewhere). */ public function hasData(): bool { foreach (self::TABLES as $suffix) { if ($this->tableExists($this->prefix . 'aiwend_' . $suffix)) { return true; } } return false; } private function tableExists(string $name): bool { if ($this->db->driver() === 'sqlite') { return $this->db->selectOne("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", [$name]) !== null; } return $this->db->selectOne('SHOW TABLES LIKE ?', [str_replace(['\\', '_', '%'], ['\\\\', '\\_', '\\%'], $name)]) !== null; } }