Add exact application files

This commit is contained in:
2026-01-21 02:10:12 +03:00
parent d25a8abc80
commit df33488ad7
7 changed files with 1586 additions and 0 deletions

386
src/DataService.php Normal file
View File

@@ -0,0 +1,386 @@
<?php
namespace App;
use PDO;
class DataService
{
public function __construct(private PDO $pdo) {}
public function fetchData(
string $schema,
string $table,
array $columns,
int $page,
int $pageSize,
array $filters,
?array $sort
): array {
$offset = ($page - 1) * $pageSize;
$colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns);
$quotedColumns = array_map(fn($name) => "`$name`", $colNames);
$selectList = implode(', ', $quotedColumns);
$whereParts = [];
$params = [];
foreach ($filters as $i => $f) {
$field = $f['field'] ?? null;
$value = $f['value'] ?? null;
if (!$field || !in_array($field, $colNames, true) || $value === null || $value === '') {
continue;
}
$param = ":f{$i}";
$whereParts[] = "`$field` LIKE $param";
$params[$param] = '%' . $value . '%';
}
$whereSql = $whereParts ? 'WHERE ' . implode(' AND ', $whereParts) : '';
$orderSql = '';
if ($sort && !empty($sort['field']) && in_array($sort['field'], $colNames, true)) {
$dir = strtoupper($sort['dir'] ?? 'ASC');
if (!in_array($dir, ['ASC', 'DESC'], true)) {
$dir = 'ASC';
}
$orderSql = "ORDER BY `{$sort['field']}` $dir";
}
$sql = "SELECT $selectList
FROM `{$schema}`.`{$table}`
$whereSql
$orderSql
LIMIT :limit OFFSET :offset";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v);
}
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$countSql = "SELECT COUNT(*) AS cnt
FROM `{$schema}`.`{$table}` $whereSql";
$countStmt = $this->pdo->prepare($countSql);
foreach ($params as $k => $v) {
$countStmt->bindValue($k, $v);
}
$countStmt->execute();
$total = (int)$countStmt->fetchColumn();
$lastPage = max(1, (int)ceil($total / $pageSize));
return [
'data' => $rows,
'last_page' => $lastPage,
'total' => $total,
'current_page' => $page
];
}
public function insertRow(string $schema, string $table, array $row, array $columns): array
{
$insertCols = [];
$placeholders = [];
$params = [];
foreach ($columns as $c) {
$name = $c['COLUMN_NAME'];
$extra = $c['EXTRA'] ?? '';
if (str_contains($extra, 'auto_increment')) {
continue;
}
if (array_key_exists($name, $row)) {
$insertCols[] = "`$name`";
$placeholders[] = ":$name";
$params[":$name"] = $row[$name];
}
}
if (empty($insertCols)) {
$sql = "INSERT INTO `{$schema}`.`{$table}` () VALUES ()";
$stmt = $this->pdo->prepare($sql);
try {
$stmt->execute();
return ['inserted' => true, 'id' => $this->pdo->lastInsertId()];
} catch (\PDOException $e) {
throw new \RuntimeException($this->formatPDOError($e, $schema, $table));
}
}
$sql = sprintf(
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
$schema, $table,
implode(',', $insertCols),
implode(',', $placeholders)
);
$stmt = $this->pdo->prepare($sql);
try {
$stmt->execute($params);
return ['inserted' => true, 'id' => $this->pdo->lastInsertId()];
} catch (\PDOException $e) {
throw new \RuntimeException($this->formatPDOError($e, $schema, $table, $params));
}
}
// ✅ Метод для форматирования ошибок
private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
{
$code = $e->getCode();
$message = $e->getMessage();
// Foreign Key constraint
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
$field = $matches[1];
return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы.";
}
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
}
// Duplicate key
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
$value = $matches[1];
$key = $matches[2];
return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
}
return "Ошибка: запись с таким значением уже существует.";
}
// NOT NULL constraint
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
$field = $matches[1];
return "Ошибка: поле '{$field}' обязательно для заполнения.";
}
return "Ошибка: не заполнены обязательные поля.";
}
return "Ошибка БД: " . $message;
}
public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array
{
if (empty($pk)) {
throw new \RuntimeException('No primary key — update disabled');
}
$sets = [];
$params = [];
foreach ($columns as $c) {
$name = $c['COLUMN_NAME'];
if (in_array($name, $pk, true)) {
continue;
}
if (array_key_exists($name, $row)) {
$sets[] = "`$name` = :v_$name";
$params[":v_$name"] = $row[$name];
}
}
if (empty($sets)) {
return ['updated' => 0, 'message' => 'No changes'];
}
$whereParts = [];
foreach ($pk as $name) {
if (!array_key_exists($name, $row)) {
throw new \RuntimeException("Missing PK value: $name");
}
$whereParts[] = "`$name` = :pk_$name";
$params[":pk_$name"] = $row[$name];
}
$sql = sprintf(
"UPDATE `%s`.`%s` SET %s WHERE %s",
$schema, $table,
implode(', ', $sets),
implode(' AND ', $whereParts)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return ['updated' => $stmt->rowCount()];
}
public function deleteRow(string $schema, string $table, array $row, array $pk): array
{
if (empty($pk)) {
throw new \RuntimeException('No primary key — delete disabled');
}
$whereParts = [];
$params = [];
foreach ($pk as $name) {
if (!array_key_exists($name, $row)) {
throw new \RuntimeException("Missing PK value: $name");
}
$whereParts[] = "`$name` = :pk_$name";
$params[":pk_$name"] = $row[$name];
}
$sql = sprintf(
"DELETE FROM `%s`.`%s` WHERE %s",
$schema, $table,
implode(' AND ', $whereParts)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return ['deleted' => $stmt->rowCount()];
}
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
{
if (empty($rows)) {
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
}
$inserted = 0;
$errors = 0;
$errorMessages = [];
$validColumns = [];
foreach ($columns as $c) {
$name = $c['COLUMN_NAME'];
$extra = $c['EXTRA'] ?? '';
if (!str_contains($extra, 'auto_increment')) {
$validColumns[] = $name;
}
}
$this->pdo->beginTransaction();
try {
foreach ($rows as $index => $row) {
try {
$insertCols = [];
$placeholders = [];
$params = [];
foreach ($validColumns as $name) {
if (array_key_exists($name, $row)) {
$insertCols[] = "`$name`";
$placeholders[] = ":$name";
$params[":$name"] = $row[$name];
}
}
if (empty($insertCols)) {
continue;
}
$sql = sprintf(
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
$schema, $table,
implode(',', $insertCols),
implode(',', $placeholders)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$inserted++;
} catch (\PDOException $e) {
$errors++;
$errorMessages[] = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
}
}
$this->pdo->commit();
} catch (\Exception $e) {
$this->pdo->rollBack();
throw new \RuntimeException('Import failed: ' . $e->getMessage());
}
return [
'inserted' => $inserted,
'errors' => $errors,
'errorMessages' => $errorMessages
];
}
public function exportCSV(
string $schema,
string $table,
array $columns,
array $filters,
?array $sort
): array {
$colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns);
$quotedColumns = array_map(fn($name) => "`$name`", $colNames);
$selectList = implode(', ', $quotedColumns);
$whereParts = [];
$params = [];
foreach ($filters as $i => $f) {
$field = $f['field'] ?? null;
$value = $f['value'] ?? null;
if (!$field || !in_array($field, $colNames, true) || $value === null) {
continue;
}
$param = ":f{$i}";
$whereParts[] = "`$field` LIKE $param";
$params[$param] = '%' . $value . '%';
}
$whereSql = $whereParts ? 'WHERE ' . implode(' AND ', $whereParts) : '';
$orderSql = '';
if ($sort && !empty($sort['field']) && in_array($sort['field'], $colNames, true)) {
$dir = strtoupper($sort['dir'] ?? 'ASC');
if (!in_array($dir, ['ASC', 'DESC'], true)) {
$dir = 'ASC';
}
$orderSql = "ORDER BY `{$sort['field']}` $dir";
}
$sql = "SELECT $selectList
FROM `{$schema}`.`{$table}`
$whereSql
$orderSql";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v);
}
$stmt->execute();
$rows = $stmt->fetchAll();
$csv = $this->arrayToCSV($colNames, $rows);
return [
'csv' => $csv,
'rowCount' => count($rows)
];
}
private function arrayToCSV(array $headers, array $rows): string
{
$output = fopen('php://temp', 'r+');
fputcsv($output, $headers, ';');
foreach ($rows as $row) {
$rowData = [];
foreach ($headers as $header) {
$rowData[] = $row[$header] ?? '';
}
fputcsv($output, $rowData, ';');
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
return $csv;
}
}

43
src/Db.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace App;
use PDO;
use PDOException;
class Db
{
// Простая фабрика PDO по логину/паролю MariaDB из сессии
public static function connectFromSession(): PDO
{
if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) {
throw new \RuntimeException('Not authenticated');
}
$user = $_SESSION['db_user'];
$pass = $_SESSION['db_pass'];
// TODO: вынести host/port/charset в конфиг .env
$dsn = 'mysql:host=localhost;port=3306;charset=utf8mb4';
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // важная настройка [web:28][web:25]
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return $pdo;
}
public static function testConnection(string $user, string $pass): void
{
$dsn = 'mysql:host=localhost;port=3306;charset=utf8mb4'; // MariaDB совместим [web:22][web:28]
try {
new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
throw new \RuntimeException('Connection failed: ' . $e->getMessage());
}
}
}

121
src/MetaService.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
namespace App;
use PDO;
class MetaService
{
public function __construct(private PDO $pdo) {}
public function getSchemaTree(): array
{
$sql = "
SELECT TABLE_SCHEMA, TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_TYPE = 'BASE TABLE'
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')
ORDER BY TABLE_SCHEMA, TABLE_NAME
";
$rows = $this->pdo->query($sql)->fetchAll();
$tree = [];
foreach ($rows as $row) {
$schema = $row['TABLE_SCHEMA'];
$table = $row['TABLE_NAME'];
$tree[$schema]['name'] = $schema;
$tree[$schema]['tables'][] = $table;
}
return array_values($tree);
}
public function getTableMeta(string $schema, string $table): array
{
$sql = "
SELECT
COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY,
IS_NULLABLE, COLUMN_DEFAULT, EXTRA, ORDINAL_POSITION
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
ORDER BY ORDINAL_POSITION
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':schema' => $schema, ':table' => $table]);
$cols = $stmt->fetchAll();
// ✅ Получаем информацию о Foreign Keys
$fkSql = "
SELECT
COLUMN_NAME,
REFERENCED_TABLE_SCHEMA,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = :schema
AND TABLE_NAME = :table
AND REFERENCED_TABLE_NAME IS NOT NULL
";
$fkStmt = $this->pdo->prepare($fkSql);
$fkStmt->execute([':schema' => $schema, ':table' => $table]);
$foreignKeys = $fkStmt->fetchAll();
// Создаём map FK для быстрого доступа
$fkMap = [];
foreach ($foreignKeys as $fk) {
$fkMap[$fk['COLUMN_NAME']] = [
'ref_schema' => $fk['REFERENCED_TABLE_SCHEMA'],
'ref_table' => $fk['REFERENCED_TABLE_NAME'],
'ref_column' => $fk['REFERENCED_COLUMN_NAME']
];
}
// Primary keys
$pk = [];
foreach ($cols as $c) {
if ($c['COLUMN_KEY'] === 'PRI') {
$pk[] = $c['COLUMN_NAME'];
}
}
$enrichedCols = array_map(function($c) use ($fkMap) {
$name = $c['COLUMN_NAME'];
return [
'COLUMN_NAME' => $name,
'DATA_TYPE' => $c['DATA_TYPE'],
'COLUMN_TYPE' => $c['COLUMN_TYPE'],
'COLUMN_KEY' => $c['COLUMN_KEY'],
'IS_NULLABLE' => $c['IS_NULLABLE'] === 'YES',
'COLUMN_DEFAULT' => $c['COLUMN_DEFAULT'],
'HAS_DEFAULT' => !empty($c['COLUMN_DEFAULT']),
'EXTRA' => $c['EXTRA'],
'IS_AUTO_INCREMENT' => str_contains($c['EXTRA'] ?? '', 'auto_increment'),
'ORDINAL_POSITION' => (int)$c['ORDINAL_POSITION'],
'IS_REQUIRED' => $c['IS_NULLABLE'] === 'NO' && empty($c['COLUMN_DEFAULT']),
'EDITOR_TYPE' => $this->getEditorType($c),
// ✅ Добавляем информацию о FK
'IS_FOREIGN_KEY' => isset($fkMap[$name]),
'FOREIGN_KEY' => $fkMap[$name] ?? null
];
}, $cols);
return [
'columns' => $enrichedCols,
'primaryKey' => $pk,
'totalColumns' => count($enrichedCols),
'foreignKeys' => $foreignKeys
];
}
private function getEditorType(array $col): string
{
$type = strtolower($col['DATA_TYPE']);
$fullType = strtolower($col['COLUMN_TYPE']);
if (str_contains($fullType, 'int') || str_contains($type, 'int')) return 'number';
if (str_contains($fullType, 'float') || str_contains($fullType, 'decimal') || str_contains($fullType, 'double')) return 'number';
if (str_contains($type, 'date')) return 'datetime';
if (str_contains($type, 'time')) return 'time';
if (str_contains($type, 'bool') || str_contains($type, 'boolean')) return 'tickCross';
return 'input';
}
}