mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-26 00:00:51 +00:00
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
// 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()
|
|
.replace(/\s*\([^)]*\)\s*$/, "")
|
|
.trim();
|
|
return BINARY_TYPES.has(lower);
|
|
}
|
|
|
|
export interface EditableQueryInfo {
|
|
schema: string | undefined;
|
|
schemaQuoted?: boolean;
|
|
tableName: string;
|
|
tableNameQuoted?: boolean;
|
|
tableAlias?: string;
|
|
selectStar: boolean;
|
|
columns: EditableQueryColumn[]; // empty array if SELECT *
|
|
}
|
|
|
|
export interface EditableQueryColumn {
|
|
sourceName?: string;
|
|
sourceNameQuoted?: boolean;
|
|
resultName: string;
|
|
expression: string;
|
|
}
|
|
|
|
export type QueryEditabilityReason = "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable";
|
|
|
|
export type QueryEditability = { editable: true; analysis: EditableQueryInfo } | { editable: false; reason: QueryEditabilityReason };
|
|
|
|
/**
|
|
* 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 result = analyzeEditableQueryEditability(sql);
|
|
return result.editable ? result.analysis : null;
|
|
}
|
|
|
|
export function analyzeEditableQueryEditability(sql: string): QueryEditability {
|
|
const normalized = stripSqlComments(sql)
|
|
.replace(/;+\s*$/, "")
|
|
.trim();
|
|
if (!normalized) return { editable: false, reason: "not-select" };
|
|
if (/^\s*WITH\b/i.test(normalized)) return { editable: false, reason: "cte" };
|
|
if (!/^SELECT\b/i.test(normalized)) return { editable: false, reason: "not-select" };
|
|
if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT"])) {
|
|
return { editable: false, reason: "set-operation" };
|
|
}
|
|
if (normalized.includes(";")) return { editable: false, reason: "complex-source" };
|
|
|
|
const fromIndex = findTopLevelKeyword(normalized, "FROM", 0);
|
|
if (fromIndex < 0) return { editable: false, reason: "no-table" };
|
|
|
|
const selectBody = normalized.slice("SELECT".length, fromIndex).trim();
|
|
if (/^DISTINCT\b/i.test(selectBody)) return { editable: false, reason: "aggregation" };
|
|
|
|
const groupIndex = findTopLevelKeyword(normalized, "GROUP", fromIndex + 4);
|
|
const havingIndex = findTopLevelKeyword(normalized, "HAVING", fromIndex + 4);
|
|
if (groupIndex >= 0 || havingIndex >= 0) return { editable: false, reason: "aggregation" };
|
|
|
|
const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"], fromIndex + 4);
|
|
const fromBody = normalized.slice(fromIndex + 4, fromEnd < 0 ? normalized.length : fromEnd).trim();
|
|
if (isExternalFromSource(fromBody)) return { editable: false, reason: "external-source" };
|
|
const source = parseFromSource(fromBody);
|
|
if (!source) return { editable: false, reason: "complex-source" };
|
|
|
|
const selectStar = isSelectStar(selectBody, source.alias);
|
|
const columns = selectStar ? [] : parseSelectColumns(selectBody);
|
|
if (!selectStar && columns.length === 0) return { editable: false, reason: "computed-columns" };
|
|
|
|
return {
|
|
editable: true,
|
|
analysis: {
|
|
schema: source.schema,
|
|
schemaQuoted: source.schemaQuoted,
|
|
tableName: source.tableName,
|
|
tableNameQuoted: source.tableNameQuoted,
|
|
tableAlias: source.alias,
|
|
selectStar,
|
|
columns,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function queryEditabilityMessageKey(reason: QueryEditabilityReason): string {
|
|
return {
|
|
"not-select": "grid.queryEditUnsupportedNotSelect",
|
|
cte: "grid.queryEditUnsupportedCte",
|
|
"set-operation": "grid.queryEditUnsupportedSetOperation",
|
|
aggregation: "grid.queryEditUnsupportedAggregation",
|
|
"external-source": "grid.queryEditUnsupportedExternalSource",
|
|
"complex-source": "grid.queryEditUnsupportedComplexSource",
|
|
"computed-columns": "grid.queryEditUnsupportedComputedColumns",
|
|
"no-table": "grid.queryEditUnsupportedNoTable",
|
|
"no-primary-key": "grid.queryEditUnsupportedNoPrimaryKey",
|
|
"primary-key-not-returned": "grid.queryEditUnsupportedPrimaryKeyNotReturned",
|
|
"aliased-columns": "grid.queryEditUnsupportedAliasedColumns",
|
|
"metadata-unavailable": "grid.queryEditUnsupportedMetadataUnavailable",
|
|
}[reason];
|
|
}
|
|
|
|
function parseSelectColumns(body: string): EditableQueryColumn[] {
|
|
const cols: EditableQueryColumn[] = [];
|
|
let depth = 0;
|
|
let current = "";
|
|
let quote: string | null = null;
|
|
for (let i = 0; i < body.length; i++) {
|
|
const ch = body[i];
|
|
if (quote) {
|
|
current += ch;
|
|
if (ch === quote || (quote === "]" && ch === "]")) quote = null;
|
|
continue;
|
|
}
|
|
if (ch === "'" || ch === '"' || ch === "`") quote = ch;
|
|
else if (ch === "[") quote = "]";
|
|
else if (ch === "(") depth++;
|
|
else if (ch === ")") depth--;
|
|
else if (ch === "," && depth === 0) {
|
|
const col = parseSelectColumn(current.trim());
|
|
if (!col) return [];
|
|
cols.push(col);
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += ch;
|
|
}
|
|
if (current.trim()) {
|
|
const col = parseSelectColumn(current.trim());
|
|
if (!col) return [];
|
|
cols.push(col);
|
|
}
|
|
return cols;
|
|
}
|
|
|
|
function parseSelectColumn(col: string): EditableQueryColumn | null {
|
|
const source = parseQualifiedIdentifier(col);
|
|
if (!source) return parseComputedSelectColumn(col);
|
|
const alias = parseColumnAlias(source.rest);
|
|
if (alias === null) return parseComputedSelectColumn(col);
|
|
return {
|
|
sourceName: source.parts[source.parts.length - 1],
|
|
sourceNameQuoted: source.quoted[source.quoted.length - 1],
|
|
resultName: alias ?? source.parts[source.parts.length - 1],
|
|
expression: col.slice(0, source.end).trim(),
|
|
};
|
|
}
|
|
|
|
function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
|
|
const alias = parseExpressionAlias(col);
|
|
if (!alias) return null;
|
|
return {
|
|
sourceName: undefined,
|
|
sourceNameQuoted: false,
|
|
resultName: alias.resultName,
|
|
expression: alias.expression,
|
|
};
|
|
}
|
|
|
|
function parseExpressionAlias(col: string): { expression: string; resultName: string } | null {
|
|
const asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:[A-Za-z_][\w$]*))\s*$/i);
|
|
if (asMatch?.index === undefined) return null;
|
|
const alias = readIdentifier(asMatch[1], 0);
|
|
if (!alias || alias.end !== asMatch[1].length) return null;
|
|
const expression = col.slice(0, asMatch.index).trim();
|
|
return expression ? { expression, resultName: alias.value } : null;
|
|
}
|
|
|
|
function parseColumnAlias(rest: string): string | undefined | null {
|
|
const trimmed = rest.trim();
|
|
if (!trimmed) return undefined;
|
|
const asMatch = trimmed.match(/^AS\s+/i);
|
|
const aliasText = asMatch ? trimmed.slice(asMatch[0].length).trim() : trimmed;
|
|
const alias = readIdentifier(aliasText, 0);
|
|
if (!alias || alias.end !== aliasText.length) return null;
|
|
return alias.value;
|
|
}
|
|
|
|
function isSelectStar(body: string, alias: string | undefined): boolean {
|
|
const trimmed = body.trim();
|
|
if (trimmed === "*") return true;
|
|
if (!alias) return false;
|
|
return new RegExp(`^${escapeRegExp(alias)}\\s*\\.\\s*\\*$`, "i").test(trimmed);
|
|
}
|
|
|
|
function parseFromSource(body: string): { schema?: string; schemaQuoted?: boolean; tableName: string; tableNameQuoted?: boolean; alias?: string } | null {
|
|
if (!body || /[,()]/.test(body) || /\bJOIN\b/i.test(body)) return null;
|
|
const ident = parseQualifiedIdentifier(body);
|
|
if (!ident || ident.parts.length < 1 || ident.parts.length > 2) return null;
|
|
const tail = ident.rest.trim();
|
|
let alias: string | undefined;
|
|
if (tail) {
|
|
const aliasText = tail.replace(/^AS\s+/i, "").trim();
|
|
const aliasIdent = readIdentifier(aliasText, 0);
|
|
if (!aliasIdent || aliasIdent.end !== aliasText.length) return null;
|
|
alias = aliasIdent.value;
|
|
}
|
|
const tableName = ident.parts[ident.parts.length - 1];
|
|
const tableNameQuoted = ident.quoted[ident.quoted.length - 1];
|
|
const schema = ident.parts.length === 2 ? ident.parts[0] : undefined;
|
|
const schemaQuoted = ident.parts.length === 2 ? ident.quoted[0] : false;
|
|
return { schema, schemaQuoted, tableName, tableNameQuoted, alias };
|
|
}
|
|
|
|
function isExternalFromSource(body: string): boolean {
|
|
const trimmed = body.trim();
|
|
return /^'(?:''|[^'])*'(?:\s+(?:AS\s+)?[A-Za-z_][\w$]*)?$/i.test(trimmed) || /^[A-Za-z_][\w$]*\s*\(/.test(trimmed);
|
|
}
|
|
|
|
function parseQualifiedIdentifier(text: string): { parts: string[]; quoted: boolean[]; end: number; rest: string; done: boolean } | null {
|
|
const parts: string[] = [];
|
|
const quoted: boolean[] = [];
|
|
let pos = 0;
|
|
while (pos < text.length) {
|
|
pos = skipWhitespace(text, pos);
|
|
const ident = readIdentifier(text, pos);
|
|
if (!ident) break;
|
|
parts.push(ident.value);
|
|
quoted.push(ident.quoted);
|
|
pos = skipWhitespace(text, ident.end);
|
|
if (text[pos] !== ".") break;
|
|
pos++;
|
|
}
|
|
if (parts.length === 0) return null;
|
|
return { parts, quoted, end: pos, rest: text.slice(pos), done: text.slice(pos).trim() === "" };
|
|
}
|
|
|
|
function readIdentifier(text: string, start: number): { value: string; quoted: boolean; end: number } | null {
|
|
const pos = skipWhitespace(text, start);
|
|
const quote = text[pos];
|
|
if (quote === '"' || quote === "`" || quote === "[") {
|
|
const close = quote === "[" ? "]" : quote;
|
|
let value = "";
|
|
for (let i = pos + 1; i < text.length; i++) {
|
|
if (text[i] === close) return { value, quoted: true, end: i + 1 };
|
|
value += text[i];
|
|
}
|
|
return null;
|
|
}
|
|
const match = text.slice(pos).match(/^[A-Za-z_][\w$]*/);
|
|
return match ? { value: match[0], quoted: false, end: pos + match[0].length } : null;
|
|
}
|
|
|
|
function skipWhitespace(text: string, pos: number): number {
|
|
while (pos < text.length && /\s/.test(text[pos])) pos++;
|
|
return pos;
|
|
}
|
|
|
|
function stripSqlComments(sql: string): string {
|
|
return sql.replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
}
|
|
|
|
function hasTopLevelKeyword(sql: string, keywords: string[]): boolean {
|
|
return keywords.some((keyword) => findTopLevelKeyword(sql, keyword, 0) >= 0);
|
|
}
|
|
|
|
function firstTopLevelKeywordIndex(sql: string, keywords: string[], start: number): number {
|
|
const indexes = keywords.map((keyword) => findTopLevelKeyword(sql, keyword, start)).filter((index) => index >= 0);
|
|
return indexes.length ? Math.min(...indexes) : -1;
|
|
}
|
|
|
|
function findTopLevelKeyword(sql: string, keyword: string, start: number): number {
|
|
let depth = 0;
|
|
let quote: string | null = null;
|
|
const upperKeyword = keyword.toUpperCase();
|
|
for (let i = start; i < sql.length; i++) {
|
|
const ch = sql[i];
|
|
if (quote) {
|
|
if (ch === quote || (quote === "]" && ch === "]")) quote = null;
|
|
continue;
|
|
}
|
|
if (ch === "'" || ch === '"' || ch === "`") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === "[") {
|
|
quote = "]";
|
|
continue;
|
|
}
|
|
if (ch === "(") {
|
|
depth++;
|
|
continue;
|
|
}
|
|
if (ch === ")") {
|
|
depth = Math.max(0, depth - 1);
|
|
continue;
|
|
}
|
|
if (depth !== 0) continue;
|
|
if (sql.slice(i, i + keyword.length).toUpperCase() !== upperKeyword) continue;
|
|
const before = i === 0 ? "" : sql[i - 1];
|
|
const after = sql[i + keyword.length] ?? "";
|
|
if (!isIdentifierChar(before) && !isIdentifierChar(after)) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function isIdentifierChar(ch: string): boolean {
|
|
return /[\w$]/.test(ch);
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/**
|
|
* Check if all primary key columns are present in the result set columns.
|
|
* Comparison is case-insensitive.
|
|
*/
|
|
export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[], analysis?: EditableQueryInfo): boolean {
|
|
if (analysis && !analysis.selectStar) {
|
|
const sourceColumns = new Set(analysis.columns.flatMap((column) => (column.sourceName ? [column.sourceName.toLowerCase()] : [])));
|
|
return primaryKeys.every((pk) => sourceColumns.has(pk.toLowerCase()));
|
|
}
|
|
const colSet = new Set(resultColumns.map((c) => c.toLowerCase()));
|
|
return primaryKeys.every((pk) => colSet.has(pk.toLowerCase()));
|
|
}
|
|
|
|
export function allEditableColumnsWriteable(analysis: EditableQueryInfo, resultColumns: string[]): boolean {
|
|
if (analysis.selectStar) return true;
|
|
const sourceByResult = new Map(analysis.columns.map((column) => [column.resultName.toLowerCase(), column]));
|
|
return resultColumns.every((resultColumn) => {
|
|
const source = sourceByResult.get(resultColumn.toLowerCase());
|
|
return !!source;
|
|
});
|
|
}
|
|
|
|
export function sourceColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[]): Array<string | undefined> | undefined {
|
|
if (analysis.selectStar) return undefined;
|
|
const sourceByResult = new Map(analysis.columns.map((column) => [column.resultName.toLowerCase(), column]));
|
|
const sourceColumns = resultColumns.map((resultColumn) => sourceByResult.get(resultColumn.toLowerCase())?.sourceName);
|
|
return resultColumns.every((resultColumn) => sourceByResult.has(resultColumn.toLowerCase())) ? sourceColumns : undefined;
|
|
}
|