import env from '../config/env.js'; import { getProvider } from '../db/store.js'; /** * Returns dialect ('sqlite' or 'mssql') */ export function getDialect(provider) { if (provider?.dialect === 'sqlite3') return 'sqlite'; if (provider?.dialect === 'mssql') return 'mssql'; return env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3' ? 'sqlite' : 'mssql'; } function sanitizeValue(val) { if (val === null || val === undefined) return null; if (Buffer.isBuffer(val) || val instanceof Uint8Array) { return ``; } if (typeof val === 'bigint') { return val.toString(); } return val; } export function sanitizeRow(row) { if (!row || typeof row !== 'object') return row; const sanitized = {}; for (const [key, val] of Object.entries(row)) { sanitized[key] = sanitizeValue(val); } return sanitized; } export async function getDatabaseInfo() { const provider = await getProvider(); const dialect = getDialect(provider); return { dialect, database: dialect === 'sqlite' ? env.sqlite.filename : env.mssql.database, server: dialect === 'sqlite' ? 'local' : (env.mssql.server || 'localhost'), port: env.monitorPort, }; } /** * Returns list of tables in the database with their row counts. */ export async function getTables() { const provider = await getProvider(); const dialect = getDialect(provider); let tablesQuery = ''; if (dialect === 'sqlite') { tablesQuery = `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC;`; } else { tablesQuery = `SELECT TABLE_NAME as name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME NOT LIKE 'sys%' AND TABLE_NAME != 'sysdiagrams' ORDER BY TABLE_NAME ASC;`; } const result = await provider.query(tablesQuery); const tableList = (result.rows || []).map((r) => ({ name: r.name, rowCount: 0, })); // Fetch row counts concurrently await Promise.all( tableList.map(async (t) => { try { const countRes = await provider.query(`SELECT COUNT(*) as cnt FROM [${t.name}]`); t.rowCount = Number(countRes.rows[0]?.cnt ?? 0); } catch { t.rowCount = 0; } }) ); return tableList; } /** * Returns column schema for a given table. */ export async function getTableSchema(tableName) { const provider = await getProvider(); const dialect = getDialect(provider); if (dialect === 'sqlite') { const res = await provider.query(`PRAGMA table_info("${tableName}")`); return (res.rows || []).map((row) => ({ name: row.name, type: (row.type || 'TEXT').toUpperCase(), isNullable: row.notnull === 0, defaultValue: row.dflt_value, isPrimaryKey: Boolean(row.pk), pkOrder: row.pk || 0, })); } else { const sql = ` SELECT c.COLUMN_NAME as name, c.DATA_TYPE as type, CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END as isNullable, c.COLUMN_DEFAULT as defaultValue, CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as isPrimaryKey FROM INFORMATION_SCHEMA.COLUMNS c LEFT JOIN ( SELECT ku.TABLE_NAME, ku.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME ) pk ON c.TABLE_NAME = pk.TABLE_NAME AND c.COLUMN_NAME = pk.COLUMN_NAME WHERE c.TABLE_NAME = @tableName ORDER BY c.ORDINAL_POSITION; `; const res = await provider.query(sql, { tableName }); return (res.rows || []).map((row) => ({ name: row.name, type: (row.type || 'VARCHAR').toUpperCase(), isNullable: Boolean(row.isNullable), defaultValue: row.defaultValue, isPrimaryKey: Boolean(row.isPrimaryKey), pkOrder: row.isPrimaryKey ? 1 : 0, })); } } /** * Browse table data with pagination, sorting, and search. */ export async function getTableData(tableName, { page = 1, pageSize = 50, sortBy, sortDir = 'ASC', search = '' } = {}) { const provider = await getProvider(); const dialect = getDialect(provider); // Verify table exists const tables = await getTables(); const tableExists = tables.some((t) => t.name.toLowerCase() === tableName.toLowerCase()); if (!tableExists) { throw new Error(`Table "${tableName}" not found`); } const schema = await getTableSchema(tableName); if (!schema.length) { throw new Error(`No columns found for table "${tableName}"`); } const columnNames = schema.map((c) => c.name); const primaryKeys = schema.filter((c) => c.isPrimaryKey).map((c) => c.name); const safePage = Math.max(1, parseInt(page, 10) || 1); const safePageSize = Math.min(500, Math.max(1, parseInt(pageSize, 10) || 50)); const offset = (safePage - 1) * safePageSize; // Validate sorting column let safeSortCol = sortBy && columnNames.includes(sortBy) ? sortBy : null; if (!safeSortCol) { safeSortCol = primaryKeys.length > 0 ? primaryKeys[0] : columnNames[0]; } const safeSortDir = String(sortDir).toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; // Search filter const trimmedSearch = (search || '').trim(); let whereClause = ''; const queryParams = {}; if (trimmedSearch) { // Build search across text/varchar/char columns const searchableCols = schema.filter((c) => { const t = c.type.toLowerCase(); return t.includes('char') || t.includes('text') || t.includes('string'); }); const targetCols = searchableCols.length > 0 ? searchableCols : schema; const searchConditions = targetCols.map((c, i) => { const paramKey = `search${i}`; queryParams[paramKey] = `%${trimmedSearch}%`; return `CAST([${c.name}] AS NVARCHAR(MAX)) LIKE @${paramKey}`; }); whereClause = `WHERE (${searchConditions.join(' OR ')})`; } // Count query const countSql = `SELECT COUNT(*) as total FROM [${tableName}] ${whereClause}`; const countRes = await provider.query(countSql, queryParams); const total = Number(countRes.rows[0]?.total ?? 0); // Data query let dataSql = ''; if (dialect === 'sqlite') { dataSql = `SELECT * FROM [${tableName}] ${whereClause} ORDER BY [${safeSortCol}] ${safeSortDir} LIMIT ${safePageSize} OFFSET ${offset};`; } else { dataSql = `SELECT * FROM [${tableName}] ${whereClause} ORDER BY [${safeSortCol}] ${safeSortDir} OFFSET ${offset} ROWS FETCH NEXT ${safePageSize} ROWS ONLY;`; } const dataRes = await provider.query(dataSql, queryParams); const rows = (dataRes.rows || []).map((row) => sanitizeRow(row)); return { tableName, columns: schema, rows, total, page: safePage, pageSize: safePageSize, totalPages: Math.ceil(total / safePageSize) || 1, primaryKeys, sortBy: safeSortCol, sortDir: safeSortDir, }; } /** * Insert a new row into the table. */ export async function insertRow(tableName, rowData) { const provider = await getProvider(); const schema = await getTableSchema(tableName); const validColNames = new Set(schema.map((c) => c.name)); // Filter to valid columns only and strip undefined / empty auto-increment PKs const cleanData = {}; for (const [col, val] of Object.entries(rowData || {})) { if (!validColNames.has(col)) continue; const colDef = schema.find((c) => c.name === col); if (colDef?.isPrimaryKey && (val === '' || val === null || val === undefined)) { // omit auto-increment primary key so provider assigns it continue; } cleanData[col] = val === '' ? (colDef?.isNullable ? null : '') : val; } const cols = Object.keys(cleanData); if (!cols.length) { throw new Error('No valid column data provided for insert'); } const placeholders = cols.map((_, i) => `@p${i}`).join(', '); const params = {}; cols.forEach((col, i) => { params[`p${i}`] = cleanData[col]; }); const sql = `INSERT INTO [${tableName}] (${cols.map((c) => `[${c}]`).join(', ')}) VALUES (${placeholders});`; const result = await provider.query(sql, params); return { success: true, insertId: result.insertId, rowsAffected: result.rowsAffected, }; } /** * Update an existing row. */ export async function updateRow(tableName, { primaryKey, changes } = {}) { const provider = await getProvider(); const schema = await getTableSchema(tableName); const validColNames = new Set(schema.map((c) => c.name)); if (!primaryKey || typeof primaryKey !== 'object' || !Object.keys(primaryKey).length) { throw new Error('Primary key specification required for updating a row'); } if (!changes || typeof changes !== 'object' || !Object.keys(changes).length) { throw new Error('No changes specified'); } // Build SET clause const setCols = Object.keys(changes).filter((c) => validColNames.has(c)); if (!setCols.length) { throw new Error('No valid columns in changes'); } const params = {}; const setParts = setCols.map((c, i) => { params[`s${i}`] = changes[c]; return `[${c}] = @s${i}`; }); // Build WHERE clause const pkCols = Object.keys(primaryKey).filter((c) => validColNames.has(c)); const whereParts = pkCols.map((c, i) => { params[`pk${i}`] = primaryKey[c]; return `[${c}] = @pk${i}`; }); const sql = `UPDATE [${tableName}] SET ${setParts.join(', ')} WHERE ${whereParts.join(' AND ')};`; const result = await provider.query(sql, params); return { success: true, rowsAffected: result.rowsAffected, }; } /** * Delete a row by primary key. */ export async function deleteRow(tableName, { primaryKey } = {}) { const provider = await getProvider(); const schema = await getTableSchema(tableName); const validColNames = new Set(schema.map((c) => c.name)); if (!primaryKey || typeof primaryKey !== 'object' || !Object.keys(primaryKey).length) { throw new Error('Primary key specification required for deleting a row'); } const pkCols = Object.keys(primaryKey).filter((c) => validColNames.has(c)); if (!pkCols.length) { throw new Error('No valid primary key columns provided'); } const params = {}; const whereParts = pkCols.map((c, i) => { params[`pk${i}`] = primaryKey[c]; return `[${c}] = @pk${i}`; }); const sql = `DELETE FROM [${tableName}] WHERE ${whereParts.join(' AND ')};`; const result = await provider.query(sql, params); return { success: true, rowsAffected: result.rowsAffected, }; } /** * Execute raw arbitrary SQL query. */ export async function executeRawQuery(sql) { const provider = await getProvider(); const trimmed = (sql || '').trim(); if (!trimmed) { throw new Error('Query cannot be empty'); } const start = performance.now(); try { const result = await provider.query(trimmed, {}); const executionTimeMs = Math.round((performance.now() - start) * 100) / 100; const rows = (result.rows || []).map((row) => sanitizeRow(row)); const columns = rows.length > 0 ? Object.keys(rows[0]) : []; return { success: true, rows, columns, rowsAffected: result.rowsAffected ?? 0, insertId: result.insertId ?? null, executionTimeMs, }; } catch (err) { const executionTimeMs = Math.round((performance.now() - start) * 100) / 100; return { success: false, error: err.message, executionTimeMs, }; } }