diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..84b5c4b --- /dev/null +++ b/composer.json @@ -0,0 +1,13 @@ +{ + "require": { + "php": "^8.1", + "slim/slim": "^4.0", + "slim/psr7": "^1.6", + "php-di/php-di": "^7.0" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..04cc20c --- /dev/null +++ b/public/app.js @@ -0,0 +1,675 @@ +let currentSchema = null; +let currentTable = null; +let currentMeta = null; +let table = null; +let enterHandler = null; + +// Простая обёртка для fetch JSON +async function api(url, method = 'GET', body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`HTTP ${res.status}: ${txt}`); + } + return res.json(); +} + +// Логин +document.getElementById('loginBtn').addEventListener('click', async () => { + console.log('=== LOGIN BUTTON CLICKED ==='); + const user = document.getElementById('loginUser').value.trim(); + const pass = document.getElementById('loginPass').value; + const statusEl = document.getElementById('loginStatus'); + + if (!user || !pass) { + statusEl.textContent = 'Введите логин и пароль'; + statusEl.style.color = 'red'; + return; + } + + statusEl.textContent = 'Проверяем подключение...'; + statusEl.style.color = ''; + + try { + console.log('Отправляем /api/login:', { user, pass: '***' }); + const res = await api('/api/login', 'POST', { user, pass }); + console.log('Login ответ:', res); + + if (res.ok) { + statusEl.textContent = '✓ Авторизация успешна'; + statusEl.style.color = 'green'; + await loadTree(); + } else { + statusEl.textContent = 'Ошибка: ' + (res.error || 'Неизвестная ошибка'); + statusEl.style.color = 'red'; + } + } catch (e) { + console.error('Login ошибка:', e); + statusEl.textContent = 'Ошибка подключения: ' + e.message; + statusEl.style.color = 'red'; + } +}); + +// loadTree +async function loadTree() { + console.log('=== LOAD TREE ВЫЗВАН ==='); + const treeEl = document.getElementById('tree'); + treeEl.style.color = ''; + treeEl.innerHTML = 'Загрузка...'; + + try { + const tree = await api('/api/tree'); + console.log('Дерево получено:', tree); + treeEl.innerHTML = ''; + + tree.forEach(schema => { + const schemaEl = document.createElement('div'); + schemaEl.className = 'schema'; + schemaEl.textContent = schema.name; + schemaEl.style.cursor = 'pointer'; + schemaEl.style.padding = '4px'; + schemaEl.style.fontWeight = 'bold'; + treeEl.appendChild(schemaEl); + + (schema.tables || []).forEach(tbl => { + const tableEl = document.createElement('div'); + tableEl.className = 'table'; + tableEl.textContent = ` ${tbl}`; + tableEl.style.cursor = 'pointer'; + tableEl.style.paddingLeft = '12px'; + tableEl.style.padding = '2px'; + tableEl.addEventListener('click', () => selectTable(schema.name, tbl)); + treeEl.appendChild(tableEl); + }); + }); + } catch (e) { + console.error('loadTree ошибка:', e); + treeEl.innerHTML = 'Ошибка загрузки: ' + e.message; + treeEl.style.color = 'red'; + } +} + +let lastEditedRow = null; + +async function selectTable(schema, tableName) { + currentSchema = schema; + currentTable = tableName; + lastEditedRow = null; + + console.log('=== SELECT TABLE ===', { schema, tableName }); + + if (enterHandler) { + document.removeEventListener('keydown', enterHandler); + enterHandler = null; + } + + currentMeta = await api( + `/api/table/meta?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(tableName)}` + ); + + const columns = currentMeta.columns.map(col => ({ + title: col.COLUMN_NAME, + field: col.COLUMN_NAME, + editor: "input", + headerFilter: "input" + })); + + if (table) { + table.destroy(); + table = null; + } + + table = new Tabulator("#table", { + selectableRows: 1, + columns: columns, + layout: "fitColumns", + resizableColumnFit: true, + + pagination: true, + paginationMode: "remote", + paginationSize: 50, + paginationSizeSelector: [25, 50, 100, 200], + + filterMode: "remote", + sortMode: "remote", + + ajaxURL: "/api/table/data", + ajaxConfig: "POST", + ajaxContentType: "json", + + ajaxParams: function () { + const headerFilters = this.getHeaderFilters ? this.getHeaderFilters() : []; + const filters = (headerFilters || []).map(f => ({ + field: f.field, + value: f.value + })).filter(f => f.value !== null && f.value !== ''); + + const sorters = this.getSorters ? this.getSorters() : []; + const sort = (sorters && sorters.length > 0) ? { + field: sorters[0].field, + dir: sorters[0].dir + } : null; + + const params = { + schema: currentSchema, + table: currentTable, + filters: filters, + sort: sort, + columns: currentMeta.columns, + page: this.getPage ? this.getPage() : 1, + pageSize: this.getPageSize ? this.getPageSize() : 50 + }; + + console.log('📤 Запрос к серверу:', { + page: params.page, + pageSize: params.pageSize, + filters: params.filters, + sort: params.sort + }); + + return params; + }, + + ajaxResponse: function (url, params, response) { + console.log('📥 Ответ от сервера:', { + page: response.current_page, + last_page: response.last_page, + total: response.total, + rows: response.data ? response.data.length : 0 + }); + + return { + last_page: response.last_page || 1, + data: response.data || [] + }; + }, + + rowClick: function(e, row) { + row.toggleSelect(); + }, + + cellEdited: function(cell) { + const row = cell.getRow(); + lastEditedRow = row.getData(); + row.getElement().style.backgroundColor = '#fffae6'; + + console.log('Ячейка отредактирована:', cell.getField(), '→', cell.getValue()); + }, + + headerFilterLiveFilterDelay: 800, + + dataFiltering: function(filters) { + const activeFilters = filters.filter(f => f.value); + console.log('🔍 Активные фильтры:', activeFilters.map(f => `${f.field}=${f.value}`)); + }, + + dataFiltered: function(filters, rows) { + console.log('✅ Фильтрация применена, записей на странице:', rows.length); + }, + + pageLoaded: function(pageno) { + console.log('📄 Загружена страница:', pageno); + } + }); + + enterHandler = async function(e) { + if (e.key === 'Enter' && lastEditedRow && currentSchema && currentTable) { + e.preventDefault(); + console.log('Enter нажат, сохраняем строку:', lastEditedRow); + + try { + const res = await api('/api/table/update', 'POST', { + schema: currentSchema, + table: currentTable, + row: lastEditedRow + }); + + console.log('Сохранено:', res); + lastEditedRow = null; + + table.getRows().forEach(r => { + r.getElement().style.backgroundColor = ''; + }); + + await table.replaceData(); + } catch (err) { + console.error('Ошибка сохранения:', err); + alert('Ошибка: ' + err.message); + } + } + }; + + document.addEventListener('keydown', enterHandler); +} + + + +// CRUD кнопки +document.getElementById('btnInsert').addEventListener('click', async () => { + if (!currentSchema || !currentTable || !currentMeta) { + alert('Сначала выберите таблицу'); + return; + } + + const rowData = {}; + const fkFields = []; + + currentMeta.columns.forEach(col => { + const name = col.COLUMN_NAME; + + if (col.IS_AUTO_INCREMENT) return; + + if (col.HAS_DEFAULT && col.COLUMN_DEFAULT !== null) { + if (String(col.COLUMN_DEFAULT).toUpperCase().includes('CURRENT_TIMESTAMP')) return; + rowData[name] = col.COLUMN_DEFAULT; + return; + } + + if (col.IS_FOREIGN_KEY) { + fkFields.push({ + name: name, + ref_schema: col.FOREIGN_KEY.ref_schema, + ref_table: col.FOREIGN_KEY.ref_table, + ref_column: col.FOREIGN_KEY.ref_column, + required: col.IS_REQUIRED + }); + + if (!col.IS_REQUIRED) { + rowData[name] = null; + } + return; + } + + if (col.IS_REQUIRED) { + if (col.EDITOR_TYPE === 'number') rowData[name] = 0; + else if (col.EDITOR_TYPE === 'datetime' || col.EDITOR_TYPE === 'date') + rowData[name] = new Date().toISOString().slice(0, 10); + else if (col.EDITOR_TYPE === 'time') rowData[name] = '00:00:00'; + else rowData[name] = ''; + } + }); + + // ✅ Если есть обязательные FK - показываем модальное окно для выбора + const requiredFKs = fkFields.filter(f => f.required); + if (requiredFKs.length > 0) { + try { + const fkValues = await promptForForeignKeys(requiredFKs); + if (!fkValues) { + // Пользователь отменил + return; + } + + // Добавляем выбранные FK значения + Object.assign(rowData, fkValues); + } catch (err) { + console.error('Ошибка получения FK значений:', err); + alert('Ошибка: ' + err.message); + return; + } + } + + try { + await api('/api/table/insert', 'POST', { + schema: currentSchema, + table: currentTable, + row: rowData + }); + await table.replaceData(); + alert('✓ Строка успешно создана'); + } catch (e) { + console.error('Ошибка вставки:', e); + alert('Ошибка вставки: ' + e.message); + } +}); + +// ✅ Функция для выбора FK значений +async function promptForForeignKeys(fkFields) { + // Создаём модальное окно + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + `; + + const dialog = document.createElement('div'); + dialog.style.cssText = ` + background: white; + padding: 20px; + border-radius: 8px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + `; + + let html = '

Заполните обязательные поля

'; + + // Загружаем доступные значения для каждого FK + const fkOptions = {}; + for (const fk of fkFields) { + try { + const result = await api( + `/api/fk-values?schema=${encodeURIComponent(fk.ref_schema)}&` + + `table=${encodeURIComponent(fk.ref_table)}&` + + `column=${encodeURIComponent(fk.ref_column)}` + ); + fkOptions[fk.name] = result.values || []; + } catch (err) { + console.error(`Ошибка загрузки FK для ${fk.name}:`, err); + fkOptions[fk.name] = []; + } + } + + // Создаём поля для каждого FK + fkFields.forEach(fk => { + const options = fkOptions[fk.name]; + html += ` +
+ + `; + + if (options.length > 0) { + // Если есть значения - показываем select + html += `'; + } else { + // Если нет значений - показываем input + html += ` + + ⚠️ Нет доступных значений в ${fk.ref_table} + `; + } + + html += '
'; + }); + + html += ` +
+ + +
+ `; + + dialog.innerHTML = html; + modal.appendChild(dialog); + document.body.appendChild(modal); + + // Обработка кнопок + return new Promise((resolve) => { + document.getElementById('fkCancel').addEventListener('click', () => { + document.body.removeChild(modal); + resolve(null); + }); + + document.getElementById('fkSubmit').addEventListener('click', () => { + const values = {}; + let allFilled = true; + + for (const fk of fkFields) { + const input = document.getElementById(`fk_${fk.name}`); + const value = input.value.trim(); + + if (!value) { + alert(`Поле "${fk.name}" обязательно для заполнения!`); + allFilled = false; + break; + } + + values[fk.name] = value; + } + + if (allFilled) { + document.body.removeChild(modal); + resolve(values); + } + }); + }); +} + +// Вспомогательная функция для экранирования HTML +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + + +document.getElementById('btnUpdate').addEventListener('click', async () => { + if (!table) return; + let selected = table.getSelectedData(); + let rowToSave = selected.length === 1 ? selected[0] : lastEditedRow; + if (!rowToSave) { + alert('Кликните по строке или отредактируйте ячейку'); + return; + } + + try { + const res = await api('/api/table/update', 'POST', { + schema: currentSchema, table: currentTable, row: rowToSave + }); + lastEditedRow = null; + await table.replaceData(); + alert(`✓ Обновлено строк: ${res.updated}`); + } catch (e) { + console.error(e); + alert('Ошибка обновления: ' + e.message); + } +}); + +document.getElementById('btnDelete').addEventListener('click', async () => { + if (!table || !currentSchema || !currentTable) return; + const selected = table.getSelectedData(); + if (selected.length === 0) { + alert('Ничего не выбрано'); + return; + } + if (!confirm(`Удалить ${selected.length} строк(и)?`)) return; + + try { + for (const row of selected) { + await api('/api/table/delete', 'POST', { schema: currentSchema, table: currentTable, row }); + } + await table.replaceData(); + } catch (e) { + console.error(e); + alert('Ошибка удаления: ' + e.message); + } +}); + +// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ CSV ========== + +// Функция автоопределения разделителя +function detectDelimiter(text) { + const firstLine = text.split('\n')[0]; + const semicolonCount = (firstLine.match(/;/g) || []).length; + const commaCount = (firstLine.match(/,/g) || []).length; + + // Если есть точки с запятой и их больше, чем запятых, используем ; + if (semicolonCount > 0 && semicolonCount >= commaCount) { + return ';'; + } + return ','; +} + +// Улучшенный парсер CSV с поддержкой ; и , разделителей +function parseCSV(text) { + // Автоопределение разделителя + const delimiter = detectDelimiter(text); + console.log('Обнаружен разделитель CSV:', delimiter); + + const lines = []; + let currentLine = []; + let currentField = ''; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const nextChar = text[i + 1]; + + if (char === '"') { + if (inQuotes && nextChar === '"') { + currentField += '"'; + i++; // пропускаем следующую кавычку + } else { + inQuotes = !inQuotes; + } + } else if (char === delimiter && !inQuotes) { + currentLine.push(currentField.trim()); + currentField = ''; + } else if ((char === '\n' || char === '\r') && !inQuotes) { + if (char === '\r' && nextChar === '\n') { + i++; // пропускаем \n после \r + } + if (currentField || currentLine.length > 0) { + currentLine.push(currentField.trim()); + lines.push(currentLine); + currentLine = []; + currentField = ''; + } + } else { + currentField += char; + } + } + + // Добавляем последнюю строку + if (currentField || currentLine.length > 0) { + currentLine.push(currentField.trim()); + lines.push(currentLine); + } + + return lines; +} + +// ========== ИМПОРТ CSV ========== +document.getElementById('btnImportCSV').addEventListener('click', () => { + if (!currentSchema || !currentTable) { + alert('Сначала выберите таблицу'); + return; + } + document.getElementById('csvFileInput').click(); +}); + +document.getElementById('csvFileInput').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + console.log('Содержимое файла:', text.substring(0, 200)); // первые 200 символов для отладки + + const rows = parseCSV(text); + console.log('Распарсенные строки:', rows); + + if (rows.length === 0) { + alert('CSV файл пуст'); + return; + } + + const headers = rows[0]; + console.log('Заголовки:', headers); + + const dataRows = rows.slice(1); + + if (dataRows.length === 0) { + alert('Нет данных для импорта (только заголовки)'); + return; + } + + // Преобразуем в массив объектов + const records = dataRows.map(row => { + const obj = {}; + headers.forEach((header, i) => { + obj[header] = row[i] || null; + }); + return obj; + }); + + console.log('Импортируем записи:', records); + + const result = await api('/api/table/import-csv', 'POST', { + schema: currentSchema, + table: currentTable, + rows: records + }); + + alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`); + await table.replaceData(); + + // Очищаем input + e.target.value = ''; + } catch (err) { + console.error('Ошибка импорта:', err); + alert('Ошибка импорта: ' + err.message); + } +}); + +// ========== ЭКСПОРТ CSV ========== +document.getElementById('btnExportCSV').addEventListener('click', async () => { + if (!currentSchema || !currentTable || !table) { + alert('Сначала выберите таблицу'); + return; + } + + try { + // Получаем текущие фильтры и сортировку + const headerFilters = table.getHeaderFilters ? table.getHeaderFilters() : []; + const filters = (headerFilters || []).map(f => ({ field: f.field, value: f.value })); + const sorters = table.getSorters ? table.getSorters() : []; + const sort = (sorters && sorters.length > 0) ? { field: sorters[0].field, dir: sorters[0].dir } : null; + + const result = await api('/api/table/export-csv', 'POST', { + schema: currentSchema, + table: currentTable, + filters: filters, + sort: sort, + columns: currentMeta.columns + }); + + // Создаем CSV текст + const csv = result.csv; + + // Скачиваем файл + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); // BOM для Excel + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `${currentTable}_${new Date().toISOString().slice(0,10)}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + console.log(`Экспортировано ${result.rowCount} строк`); + } catch (err) { + console.error('Ошибка экспорта:', err); + alert('Ошибка экспорта: ' + err.message); + } +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3682609 --- /dev/null +++ b/public/index.html @@ -0,0 +1,118 @@ + + + + + Turbo RFQ + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+
+
+ + + + \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..3f76b51 --- /dev/null +++ b/public/index.php @@ -0,0 +1,230 @@ +addErrorMiddleware(true, true, true); + +$container->set('db', function () { + return \App\Db::connectFromSession(); +}); + +// --- ROUTES --- + +// Статическая страница с фронтендом +$app->get('/', function (Request $request, Response $response) { + // Очень простой view встроен прямо здесь + $html = file_get_contents(__DIR__ . '/index.html'); // вынесем в отдельный файл ниже + $response->getBody()->write($html); + return $response; +}); + +// Логин: сохраняет логин/пароль MariaDB в сессии после проверки +$app->post('/api/login', function (Request $request, Response $response) { + $data = json_decode((string)$request->getBody(), true); + $user = $data['user'] ?? ''; + $pass = $data['pass'] ?? ''; + + try { + \App\Db::testConnection($user, $pass); + $_SESSION['db_user'] = $user; + $_SESSION['db_pass'] = $pass; + + $payload = ['ok' => true]; + $status = 200; + } catch (\RuntimeException $e) { + $payload = ['ok' => false, 'error' => $e->getMessage()]; + $status = 401; + } + + $response->getBody()->write(json_encode($payload)); + return $response->withHeader('Content-Type', 'application/json') + ->withStatus($status); +}); + +// Middleware на /api/*: проверяем, что аутентифицированы +$app->add(function (Request $request, $handler) { + $path = $request->getUri()->getPath(); + if (str_starts_with($path, '/api/') && $path !== '/api/login') { + if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) { + $res = new \Slim\Psr7\Response(); + $res->getBody()->write(json_encode(['error' => 'Not authenticated'])); + return $res->withStatus(401)->withHeader('Content-Type', 'application/json'); + } + } + return $handler->handle($request); +}); + +// API: дерево схем/таблиц +$app->get('/api/tree', function (Request $request, Response $response) use ($container) { + $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); + $tree = $meta->getSchemaTree(); + + $response->getBody()->write(json_encode($tree)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// API: метаданные таблицы +$app->get('/api/table/meta', function (Request $request, Response $response) use ($container) { + $params = $request->getQueryParams(); + $schema = $params['schema'] ?? ''; + $table = $params['table'] ?? ''; + + $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); + $data = $meta->getTableMeta($schema, $table); + + $response->getBody()->write(json_encode($data)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// API: данные таблицы +$app->post('/api/table/data', function (Request $request, Response $response) use ($container) { + $payload = json_decode((string)$request->getBody(), true); + + $schema = $payload['schema'] ?? ''; + $table = $payload['table'] ?? ''; + $page = (int)($payload['page'] ?? 1); + $pageSize = (int)($payload['pageSize'] ?? 50); + $filters = $payload['filters'] ?? []; + $sort = $payload['sort'] ?? null; + $columns = $payload['columns'] ?? []; + + // ✅ Логирование для отладки + error_log("Data request: page=$page, pageSize=$pageSize, filters=" . json_encode($filters)); + + $pdo = $container->get('db'); + $ds = new \App\DataService($pdo); + $resData = $ds->fetchData($schema, $table, $columns, $page, $pageSize, $filters, $sort); + + // ✅ Логирование ответа + error_log("Data response: " . json_encode([ + 'data_count' => count($resData['data']), + 'last_page' => $resData['last_page'], + 'current_page' => $resData['current_page'], + 'total' => $resData['total'] + ])); + + $response->getBody()->write(json_encode($resData)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// API: insert / update / delete +$app->post('/api/table/insert', function (Request $request, Response $response) use ($container) { + $payload = json_decode((string)$request->getBody(), true); + $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); + $ds = new \App\DataService($pdo); + + $schema = $payload['schema']; + $table = $payload['table']; + $row = $payload['row']; + $metaArr = $meta->getTableMeta($schema, $table); + + $result = $ds->insertRow($schema, $table, $row, $metaArr['columns']); + $response->getBody()->write(json_encode($result)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->post('/api/table/update', function (Request $request, Response $response) use ($container) { + $payload = json_decode((string)$request->getBody(), true); + $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); + $ds = new \App\DataService($pdo); + + $schema = $payload['schema']; + $table = $payload['table']; + $row = $payload['row']; + $metaArr = $meta->getTableMeta($schema, $table); + + $result = $ds->updateRow($schema, $table, $row, $metaArr['columns'], $metaArr['primaryKey']); + $response->getBody()->write(json_encode($result)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->post('/api/table/delete', function (Request $request, Response $response) use ($container) { + $payload = json_decode((string)$request->getBody(), true); + $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); + $ds = new \App\DataService($pdo); + + $schema = $payload['schema']; + $table = $payload['table']; + $row = $payload['row']; + $metaArr = $meta->getTableMeta($schema, $table); + + $result = $ds->deleteRow($schema, $table, $row, $metaArr['primaryKey']); + $response->getBody()->write(json_encode($result)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// API: импорт CSV (массовая вставка) +$app->post('/api/table/import-csv', function (Request $request, Response $response) use ($container) { + $payload = json_decode((string)$request->getBody(), true); + $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); + $ds = new \App\DataService($pdo); + + $schema = $payload['schema']; + $table = $payload['table']; + $rows = $payload['rows'] ?? []; + $metaArr = $meta->getTableMeta($schema, $table); + + $result = $ds->insertMultipleRows($schema, $table, $rows, $metaArr['columns']); + + $response->getBody()->write(json_encode($result)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// API: экспорт CSV +$app->post('/api/table/export-csv', function (Request $request, Response $response) use ($container) { + $payload = json_decode((string)$request->getBody(), true); + $pdo = $container->get('db'); + $ds = new \App\DataService($pdo); + + $schema = $payload['schema'] ?? ''; + $table = $payload['table'] ?? ''; + $filters = $payload['filters'] ?? []; + $sort = $payload['sort'] ?? null; + $columns = $payload['columns'] ?? []; + + $result = $ds->exportCSV($schema, $table, $columns, $filters, $sort); + + $response->getBody()->write(json_encode($result)); + return $response->withHeader('Content-Type', 'application/json'); +}); +// API: получить доступные значения для Foreign Key +$app->get('/api/fk-values', function (Request $request, Response $response) use ($container) { + $params = $request->getQueryParams(); + $schema = $params['schema'] ?? ''; + $table = $params['table'] ?? ''; + $column = $params['column'] ?? ''; + + $pdo = $container->get('db'); + + // Получаем до 100 первых значений + $sql = "SELECT DISTINCT `{$column}` FROM `{$schema}`.`{$table}` + WHERE `{$column}` IS NOT NULL + ORDER BY `{$column}` + LIMIT 100"; + + $stmt = $pdo->prepare($sql); + $stmt->execute(); + $values = $stmt->fetchAll(PDO::FETCH_COLUMN); + + $response->getBody()->write(json_encode(['values' => $values])); + return $response->withHeader('Content-Type', 'application/json'); +}); +$app->run(); diff --git a/src/DataService.php b/src/DataService.php new file mode 100644 index 0000000..2561ea0 --- /dev/null +++ b/src/DataService.php @@ -0,0 +1,386 @@ + $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; + } +} diff --git a/src/Db.php b/src/Db.php new file mode 100644 index 0000000..6037a34 --- /dev/null +++ b/src/Db.php @@ -0,0 +1,43 @@ + 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()); + } + } +} diff --git a/src/MetaService.php b/src/MetaService.php new file mode 100644 index 0000000..c312eac --- /dev/null +++ b/src/MetaService.php @@ -0,0 +1,121 @@ +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'; + } +}