396 lines
14 KiB
PHP
396 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Database;
|
|
|
|
use App\Contracts\DatabaseDriverInterface;
|
|
use App\Contracts\SchemaDiscoveryInterface;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Config;
|
|
|
|
class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
|
|
{
|
|
protected string $connectionName = 'mariavel_dynamic';
|
|
|
|
public function connect(array $config): bool
|
|
{
|
|
Config::set("database.connections.{$this->connectionName}", [
|
|
'driver' => 'mysql',
|
|
'host' => $config['host'] ?? '127.0.0.1',
|
|
'port' => $config['port'] ?? '3306',
|
|
'database' => $config['database'] ?? null,
|
|
'username' => $config['username'] ?? 'root',
|
|
'password' => $config['password'] ?? '',
|
|
'charset' => 'utf8mb4',
|
|
'collation' => 'utf8mb4_unicode_ci',
|
|
'prefix' => '',
|
|
'strict' => false,
|
|
'engine' => null,
|
|
]);
|
|
|
|
try {
|
|
DB::purge($this->connectionName);
|
|
DB::connection($this->connectionName)->getPdo();
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function query(string $sql, array $bindings = []): array
|
|
{
|
|
return DB::connection($this->connectionName)->select($sql, $bindings);
|
|
}
|
|
|
|
public function getConnection()
|
|
{
|
|
return DB::connection($this->connectionName);
|
|
}
|
|
|
|
public function getDatabases(): array
|
|
{
|
|
$results = $this->query('SHOW DATABASES');
|
|
return array_map(fn($db) => $db->Database, $results);
|
|
}
|
|
|
|
public function getTables(): array
|
|
{
|
|
$results = $this->query('SHOW TABLES');
|
|
$key = "Tables_in_" . DB::connection($this->connectionName)->getDatabaseName();
|
|
return array_map(fn($table) => $table->$key, $results);
|
|
}
|
|
|
|
public function getTableSchema(string $table): array
|
|
{
|
|
return $this->query("DESCRIBE `{$table}`");
|
|
}
|
|
|
|
public function getTableData(string $table, int $limit = 100, int $offset = 0, array $filters = []): array
|
|
{
|
|
$query = DB::connection($this->connectionName)->table($table);
|
|
$this->applyFilters($query, $filters);
|
|
return $query->limit($limit)->offset($offset)->get()->all();
|
|
}
|
|
|
|
public function getTableCount(string $table, array $filters = []): int
|
|
{
|
|
$query = DB::connection($this->connectionName)->table($table);
|
|
$this->applyFilters($query, $filters);
|
|
return $query->count();
|
|
}
|
|
|
|
protected function applyFilters($query, array $filters)
|
|
{
|
|
if (empty($filters)) return $query;
|
|
|
|
foreach ($filters as $filter) {
|
|
$field = $filter['field'] ?? ($filter['columnField'] ?? null);
|
|
$operator = $filter['operator'] ?? ($filter['operatorValue'] ?? null);
|
|
$value = $filter['value'] ?? null;
|
|
|
|
if (!$field) continue;
|
|
|
|
switch ($operator) {
|
|
case 'contains':
|
|
$query->where($field, 'LIKE', "%{$value}%");
|
|
break;
|
|
case 'equals':
|
|
case '=':
|
|
$query->where($field, '=', $value);
|
|
break;
|
|
case 'startsWith':
|
|
$query->where($field, 'LIKE', "{$value}%");
|
|
break;
|
|
case 'endsWith':
|
|
$query->where($field, 'LIKE', "%{$value}");
|
|
break;
|
|
case 'isEmpty':
|
|
$query->where(function($q) use ($field) {
|
|
$q->whereNull($field)->orWhere($field, '');
|
|
});
|
|
break;
|
|
case 'isNotEmpty':
|
|
$query->whereNotNull($field)->where($field, '!=', '');
|
|
break;
|
|
case 'isAnyOf':
|
|
if (is_array($value)) {
|
|
$query->whereIn($field, $value);
|
|
}
|
|
break;
|
|
case '>':
|
|
case '<':
|
|
case '>=':
|
|
case '<=':
|
|
case '!=':
|
|
$query->where($field, $operator, $value);
|
|
break;
|
|
}
|
|
}
|
|
return $query;
|
|
}
|
|
|
|
public function getForeignKeys(string $table): array
|
|
{
|
|
$sql = "
|
|
SELECT
|
|
COLUMN_NAME,
|
|
REFERENCED_TABLE_NAME,
|
|
REFERENCED_COLUMN_NAME
|
|
FROM
|
|
INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
|
WHERE
|
|
TABLE_NAME = ? AND
|
|
REFERENCED_TABLE_NAME IS NOT NULL AND
|
|
TABLE_SCHEMA = ?
|
|
";
|
|
|
|
$dbName = DB::connection($this->connectionName)->getDatabaseName();
|
|
return $this->query($sql, [$table, $dbName]);
|
|
}
|
|
|
|
public function export(array $config): string
|
|
{
|
|
$database = $config['database'] ?? '';
|
|
$table = $config['table'] ?? '';
|
|
|
|
$filename = !empty($table)
|
|
? "{$table}-" . date('Y-m-d') . ".sql"
|
|
: "backup-" . ($database ?: 'all') . "-" . date('Y-m-d') . ".sql";
|
|
|
|
$directory = storage_path('app/backups');
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0755, true);
|
|
}
|
|
|
|
$path = $directory . DIRECTORY_SEPARATOR . $filename;
|
|
$errorPath = $directory . DIRECTORY_SEPARATOR . $filename . '.err';
|
|
|
|
$mysqldumpPath = env('MYSQLDUMP_PATH', 'mysqldump');
|
|
|
|
$username = $config['username'] ?? 'root';
|
|
$password = $config['password'] ?? '';
|
|
$host = $config['host'] ?? '127.0.0.1';
|
|
$port = $config['port'] ?? '3306';
|
|
|
|
// Build command with flags for a complete and resilient export
|
|
$passwordPart = !empty($password) ? "-p" . escapeshellarg($password) : "";
|
|
|
|
if (!empty($table) && !empty($database)) {
|
|
$dbPart = escapeshellarg($database) . " " . escapeshellarg($table);
|
|
} else {
|
|
$dbPart = !empty($database) ? escapeshellarg($database) : "--all-databases";
|
|
}
|
|
|
|
// --single-transaction: for InnoDB tables, ensures consistency without locking
|
|
// --skip-lock-tables: avoids issues with views/definers that might prevent locking
|
|
// --routines, --triggers, --events: ensures all database objects are included
|
|
// --quick: useful for large tables
|
|
$flags = "--single-transaction --skip-lock-tables --routines --triggers --events --quick";
|
|
|
|
$command = sprintf(
|
|
'%s -u %s %s -h %s -P %s %s %s > %s 2> %s',
|
|
$mysqldumpPath === 'mysqldump' ? 'mysqldump' : escapeshellarg($mysqldumpPath),
|
|
escapeshellarg($username),
|
|
$passwordPart,
|
|
escapeshellarg($host),
|
|
escapeshellarg($port),
|
|
$flags,
|
|
$dbPart,
|
|
escapeshellarg($path),
|
|
escapeshellarg($errorPath)
|
|
);
|
|
|
|
shell_exec($command);
|
|
|
|
if (file_exists($errorPath)) {
|
|
$errors = file_get_contents($errorPath);
|
|
unlink($errorPath);
|
|
|
|
// Some errors are fatal even if some output was produced (like the definer issue)
|
|
if (!empty(trim($errors)) && (str_contains(strtolower($errors), 'error') || !file_exists($path) || filesize($path) < 1000)) {
|
|
// If it's just a warning (like SSL), we might want to continue,
|
|
// but if it's a "Got error", we should probably fail.
|
|
if (str_contains(strtolower($errors), 'error')) {
|
|
throw new \Exception("Export failed: " . $errors);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!file_exists($path) || filesize($path) === 0) {
|
|
$instruction = "The 'mysqldump' binary was not found. Please set MYSQLDUMP_PATH in your .env file to the absolute path of the mysqldump executable.";
|
|
throw new \Exception("Export failed: Binary not found.\n\nInstructions: " . $instruction);
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
public function import(array $config, string $filePath): bool
|
|
{
|
|
$directory = storage_path('app/backups');
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0755, true);
|
|
}
|
|
$errorPath = $directory . DIRECTORY_SEPARATOR . 'import_error.err';
|
|
|
|
$mysqlPath = env('MYSQL_BINARY_PATH', 'mysql');
|
|
|
|
$username = $config['username'] ?? 'root';
|
|
$password = $config['password'] ?? '';
|
|
$host = $config['host'] ?? '127.0.0.1';
|
|
$port = $config['port'] ?? '3306';
|
|
$database = $config['database'] ?? '';
|
|
|
|
$passwordPart = !empty($password) ? "-p" . escapeshellarg($password) : "";
|
|
$dbPart = !empty($database) ? escapeshellarg($database) : "";
|
|
|
|
$command = sprintf(
|
|
'%s -u %s %s -h %s -P %s %s < %s 2> %s',
|
|
$mysqlPath === 'mysql' ? 'mysql' : escapeshellarg($mysqlPath),
|
|
escapeshellarg($username),
|
|
$passwordPart,
|
|
escapeshellarg($host),
|
|
escapeshellarg($port),
|
|
$dbPart,
|
|
escapeshellarg($filePath),
|
|
escapeshellarg($errorPath)
|
|
);
|
|
|
|
shell_exec($command);
|
|
|
|
if (file_exists($errorPath)) {
|
|
$errors = file_get_contents($errorPath);
|
|
unlink($errorPath);
|
|
if (!empty(trim($errors))) {
|
|
$instruction = "Please check your .env file and ensure MYSQL_BINARY_PATH is correct. Example: MYSQL_BINARY_PATH=C:\\xampp\\mysql\\bin\\mysql.exe";
|
|
throw new \Exception("Import failed: " . $errors . "\n\nInstructions: " . $instruction);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function getDatabaseMetadata(string $database): array
|
|
{
|
|
$sql = "
|
|
SELECT
|
|
s.DEFAULT_CHARACTER_SET_NAME as charset,
|
|
s.DEFAULT_COLLATION_NAME as collation,
|
|
(SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = s.SCHEMA_NAME) as size_bytes,
|
|
(SELECT COUNT(*) FROM information_schema.TABLES WHERE table_schema = s.SCHEMA_NAME) as table_count,
|
|
(SELECT COUNT(*) FROM information_schema.VIEWS WHERE table_schema = s.SCHEMA_NAME) as view_count
|
|
FROM
|
|
information_schema.SCHEMATA s
|
|
WHERE
|
|
s.SCHEMA_NAME = ?
|
|
";
|
|
|
|
$results = $this->query($sql, [$database]);
|
|
return (array) ($results[0] ?? []);
|
|
}
|
|
|
|
public function getTableMetadata(string $database, string $table): array
|
|
{
|
|
$sql = "
|
|
SELECT
|
|
ENGINE as `engine`,
|
|
TABLE_ROWS as `rows`,
|
|
DATA_LENGTH as `data_length`,
|
|
INDEX_LENGTH as `index_length`,
|
|
DATA_FREE as `data_free`,
|
|
AUTO_INCREMENT as `auto_increment`,
|
|
CREATE_TIME as `create_time`,
|
|
UPDATE_TIME as `update_time`,
|
|
TABLE_COLLATION as `collation`,
|
|
TABLE_COMMENT as `comment`
|
|
FROM
|
|
information_schema.TABLES
|
|
WHERE
|
|
TABLE_SCHEMA = ? AND
|
|
TABLE_NAME = ?
|
|
";
|
|
|
|
$results = $this->query($sql, [$database, $table]);
|
|
return (array) ($results[0] ?? []);
|
|
}
|
|
|
|
public function truncateTable(string $table): bool
|
|
{
|
|
DB::connection($this->connectionName)->statement("TRUNCATE TABLE `{$table}`");
|
|
return true;
|
|
}
|
|
|
|
public function dropTable(string $table): bool
|
|
{
|
|
DB::connection($this->connectionName)->statement("DROP TABLE `{$table}`");
|
|
return true;
|
|
}
|
|
|
|
public function optimizeTable(string $table): bool
|
|
{
|
|
DB::connection($this->connectionName)->statement("OPTIMIZE TABLE `{$table}`");
|
|
return true;
|
|
}
|
|
|
|
public function getTablesMetadata(string $database): array
|
|
{
|
|
$sql = "
|
|
SELECT
|
|
TABLE_NAME as `name`,
|
|
ENGINE as `engine`,
|
|
TABLE_ROWS as `rows`,
|
|
DATA_LENGTH as `data_length`,
|
|
INDEX_LENGTH as `index_length`,
|
|
DATA_FREE as `data_free`,
|
|
AUTO_INCREMENT as `auto_increment`,
|
|
CREATE_TIME as `create_time`,
|
|
UPDATE_TIME as `update_time`,
|
|
TABLE_COLLATION as `collation`,
|
|
TABLE_COMMENT as `comment`
|
|
FROM
|
|
information_schema.TABLES
|
|
WHERE
|
|
TABLE_SCHEMA = ?
|
|
ORDER BY
|
|
TABLE_NAME ASC
|
|
";
|
|
|
|
return $this->query($sql, [$database]);
|
|
}
|
|
|
|
public function batchUpdate(string $table, array $changes): bool
|
|
{
|
|
$connection = $this->getConnection();
|
|
|
|
// Find primary key
|
|
$schema = $this->getTableSchema($table);
|
|
$primaryKey = 'id'; // default
|
|
foreach ($schema as $col) {
|
|
if (($col->Key ?? '') === 'PRI') {
|
|
$primaryKey = $col->Field;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$connection->beginTransaction();
|
|
try {
|
|
foreach ($changes as $change) {
|
|
if (!isset($change[$primaryKey])) {
|
|
continue;
|
|
}
|
|
|
|
$id = $change[$primaryKey];
|
|
$updateData = $change;
|
|
unset($updateData[$primaryKey]);
|
|
|
|
if (empty($updateData)) continue;
|
|
|
|
$connection->table($table)->where($primaryKey, $id)->update($updateData);
|
|
}
|
|
$connection->commit();
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
$connection->rollBack();
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|