This commit is contained in:
seb
2026-10-13 17:49:36 +02:00
parent c3d4fa5cd9
commit b7b5d554d8
9 changed files with 39 additions and 34 deletions

View File

@@ -0,0 +1,357 @@
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 `<Buffer ${val.length} bytes>`;
}
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,
};
}
}