mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-01 00:25:38 +00:00
add: 增加对于查询得到的结果集直接进行数据编辑的功能。当查询是单表且包含主键(有联合主键时包含全部主键)时,可以在结果集内双击单元格修改数据,修改结果以事务形式提交或回滚。
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// Binary column types that should not be edited inline
|
||||
export const BINARY_TYPES = new Set([
|
||||
"blob",
|
||||
"clob",
|
||||
"bytea",
|
||||
"varbinary",
|
||||
"binary",
|
||||
"image",
|
||||
"longblob",
|
||||
"mediumblob",
|
||||
"tinyblob",
|
||||
"blob sub_type 2004",
|
||||
"blob sub_type 2005",
|
||||
]);
|
||||
|
||||
export function isBinaryType(dataType: string): boolean {
|
||||
const lower = dataType.toLowerCase();
|
||||
return BINARY_TYPES.has(lower);
|
||||
}
|
||||
|
||||
export interface EditableQueryInfo {
|
||||
schema: string | undefined;
|
||||
tableName: string;
|
||||
selectStar: boolean;
|
||||
columns: string[]; // empty array if SELECT *
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SELECT statement to determine if it's editable.
|
||||
* Only simple single-table SELECT queries are considered editable:
|
||||
* - No JOIN, GROUP BY, HAVING, UNION, subqueries, CTEs, DISTINCT, aggregations
|
||||
* - Must have a single FROM clause with one table
|
||||
* - WHERE, ORDER BY, LIMIT are allowed
|
||||
*/
|
||||
export function analyzeEditableQuery(sql: string): EditableQueryInfo | null {
|
||||
const trimmed = sql.trim();
|
||||
|
||||
// Strip trailing semicolons and whitespace
|
||||
const cleaned = trimmed.replace(/;+\s*$/, "").trim();
|
||||
|
||||
// Must start with SELECT (case-insensitive)
|
||||
if (!/^SELECT\b/i.test(cleaned)) return null;
|
||||
|
||||
// Reject CTEs (WITH clause before SELECT)
|
||||
if (/^\s*WITH\b/i.test(trimmed)) return null;
|
||||
|
||||
// Remove inline comments and block comments
|
||||
const normalized = cleaned.replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Reject UNION, INTERSECT, EXCEPT
|
||||
if (/\b(UNION|INTERSECT|EXCEPT)\b/i.test(normalized)) return null;
|
||||
|
||||
// --- Extract SELECT body (columns) ---
|
||||
// Match everything between SELECT and the first SQL clause keyword (FROM, WHERE, ORDER, etc.)
|
||||
const selectMatch = normalized.match(
|
||||
/^SELECT\s+(.+?)(?:\bFROM\b|\bWHERE\b|\bORDER\b|\bLIMIT\b|\bGROUP\b|\bHAVING\b|$)/is,
|
||||
);
|
||||
if (!selectMatch) return null;
|
||||
const selectBody = selectMatch[1].trim();
|
||||
|
||||
// Reject DISTINCT
|
||||
if (/^\s*DISTINCT\b/i.test(selectBody)) return null;
|
||||
|
||||
// Check if SELECT *
|
||||
const selectStar = /^\s*\*\s*$/.test(selectBody);
|
||||
const columns: string[] = [];
|
||||
if (!selectStar) {
|
||||
columns.push(...parseSelectColumns(selectBody));
|
||||
}
|
||||
|
||||
// --- Extract FROM body ---
|
||||
const fromMatch = normalized.match(/\bFROM\s+(.+?)(?:\bWHERE\b|\bORDER\b|\bLIMIT\b|\bGROUP\b|\bHAVING\b|$)/is);
|
||||
if (!fromMatch) return null;
|
||||
const fromBody = fromMatch[1].trim();
|
||||
|
||||
// Reject JOIN
|
||||
if (/\bJOIN\b/i.test(fromBody)) return null;
|
||||
|
||||
// Reject GROUP BY, HAVING (already partially covered but double-check)
|
||||
if (/\b(GROUP\s+BY|HAVING)\b/i.test(fromBody)) return null;
|
||||
|
||||
// Reject subqueries: if FROM body contains SELECT keyword
|
||||
if (/\bSELECT\b/i.test(fromBody)) return null;
|
||||
|
||||
// Also reject if there's a SELECT anywhere after FROM (subquery in WHERE etc.)
|
||||
const fromIdx = normalized.toUpperCase().indexOf("FROM");
|
||||
if (fromIdx >= 0) {
|
||||
const afterFirstFrom = normalized.slice(fromIdx);
|
||||
// Count SELECT occurrences after FROM — should be 0 (the original SELECT is before FROM)
|
||||
const selectCount = (afterFirstFrom.match(/\bSELECT\b/g) || []).length;
|
||||
if (selectCount > 0) return null;
|
||||
}
|
||||
|
||||
// --- Extract table name from FROM clause ---
|
||||
// Strip all quoting characters (backticks, double quotes, square brackets)
|
||||
const stripped = fromBody.replace(/[`"[\]]/g, "").trim();
|
||||
|
||||
// Match: table OR schema.table
|
||||
const tableMatch = stripped.match(/^(\w+)(?:\.(\w+))?/);
|
||||
if (!tableMatch) return null;
|
||||
|
||||
const schema = tableMatch[2] ? tableMatch[1] : undefined;
|
||||
const tableName = tableMatch[2] || tableMatch[1];
|
||||
|
||||
return {
|
||||
schema,
|
||||
tableName,
|
||||
selectStar,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelectColumns(body: string): string[] {
|
||||
const cols: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
cols.push(extractColumnName(current.trim()));
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) {
|
||||
cols.push(extractColumnName(current.trim()));
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function extractColumnName(col: string): string {
|
||||
// Handle AS alias
|
||||
const asMatch = col.match(/\bAS\s+(\w+)/i);
|
||||
if (asMatch) return asMatch[1];
|
||||
|
||||
// Strip quoting characters
|
||||
const stripped = col.replace(/[`"'[\]]/g, "");
|
||||
|
||||
// Take last identifier (handles table.column -> column)
|
||||
const parts = stripped.split(".");
|
||||
return parts[parts.length - 1] || stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all primary key columns are present in the result set columns.
|
||||
* Comparison is case-insensitive.
|
||||
*/
|
||||
export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[]): boolean {
|
||||
const colSet = new Set(resultColumns.map((c) => c.toLowerCase()));
|
||||
return primaryKeys.every((pk) => colSet.has(pk.toLowerCase()));
|
||||
}
|
||||
Reference in New Issue
Block a user