358 lines
12 KiB
JavaScript
358 lines
12 KiB
JavaScript
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,
|
|
};
|
|
}
|
|
}
|