mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 00:22:30 +00:00
feat(grid): edit simple query results
This commit is contained in:
+54
-23
@@ -21,6 +21,7 @@ export interface DataGridSaveStatementOptions {
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta: DataGridTableMeta;
|
||||
columns: string[];
|
||||
sourceColumns?: Array<string | undefined>;
|
||||
rows: GridCellValue[][];
|
||||
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
|
||||
deletedRows: number[];
|
||||
@@ -31,6 +32,7 @@ export interface DataGridSaveValidationOptions {
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta?: DataGridTableMeta;
|
||||
columns: string[];
|
||||
sourceColumns?: Array<string | undefined>;
|
||||
rows?: GridCellValue[][];
|
||||
columnInfo?: DataGridColumnInfo[];
|
||||
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
|
||||
@@ -56,17 +58,20 @@ export function validateDataGridSave(options: DataGridSaveValidationOptions): st
|
||||
|
||||
for (const [, changes] of options.dirtyRows) {
|
||||
for (const [columnIndex, value] of changes) {
|
||||
const column = options.columns[columnIndex];
|
||||
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, value)) {
|
||||
return nullWriteError(column);
|
||||
const sourceColumn = effectiveColumn(options.sourceColumns, options.columns, columnIndex);
|
||||
if (!sourceColumn) continue;
|
||||
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, sourceColumn, value)) {
|
||||
return nullWriteError(sourceColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of options.newRows) {
|
||||
for (const [columnIndex, column] of options.columns.entries()) {
|
||||
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, row[columnIndex])) {
|
||||
return nullWriteError(column);
|
||||
for (const columnIndex of options.columns.keys()) {
|
||||
const sourceColumn = effectiveColumn(options.sourceColumns, options.columns, columnIndex);
|
||||
if (!sourceColumn) continue;
|
||||
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, sourceColumn, row[columnIndex])) {
|
||||
return nullWriteError(sourceColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +83,8 @@ function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): st
|
||||
const primaryKeys = options.tableMeta?.primaryKeys ?? [];
|
||||
if (primaryKeys.length === 0 || options.newRows.length === 0) return undefined;
|
||||
|
||||
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(options.columns, primaryKey));
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(saveColumns, primaryKey));
|
||||
if (primaryKeyIndexes.some((index) => index === -1)) return undefined;
|
||||
|
||||
const existingKeys = new Set<string>();
|
||||
@@ -102,6 +108,7 @@ function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): st
|
||||
|
||||
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options);
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
@@ -114,27 +121,29 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const sets = changes
|
||||
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]))
|
||||
.filter(([columnIndex]) => !!saveColumns[columnIndex])
|
||||
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, saveColumns[columnIndex]))
|
||||
.map(
|
||||
([columnIndex, value]) =>
|
||||
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(value, options.databaseType)}`,
|
||||
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(value, options.databaseType)}`,
|
||||
)
|
||||
.join(", ");
|
||||
if (!sets) continue;
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, row);
|
||||
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIndex of options.deletedRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, row);
|
||||
statements.push(`DELETE FROM ${table} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const row of options.newRows) {
|
||||
const insertPairs = options.columns
|
||||
const insertPairs = saveColumns
|
||||
.map((column, index) => ({ column, value: row[index] }))
|
||||
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
|
||||
.filter((pair) => !isOracleRowId(options.databaseType, pair.column))
|
||||
.filter((pair) => pair.value !== null && pair.value !== undefined);
|
||||
if (!insertPairs.length) continue;
|
||||
@@ -148,6 +157,7 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
||||
|
||||
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options);
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
@@ -157,15 +167,16 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const row of options.newRows) {
|
||||
const where = buildRowWhere(options.databaseType, options.columns, row);
|
||||
const where = buildRowWhere(options.databaseType, saveColumns, row);
|
||||
if (where) statements.push(`DELETE FROM ${table} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIndex of options.deletedRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const insertPairs = options.columns
|
||||
const insertPairs = saveColumns
|
||||
.map((column, index) => ({ column, value: row[index] }))
|
||||
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
|
||||
.filter((pair) => !isOracleRowId(options.databaseType, pair.column));
|
||||
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
|
||||
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, options.databaseType)).join(", ");
|
||||
@@ -180,19 +191,19 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
|
||||
afterRow[columnIndex] = value;
|
||||
}
|
||||
const writableChanges = changes.filter(
|
||||
([columnIndex]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]),
|
||||
([columnIndex]) => !!saveColumns[columnIndex] && !isOracleRowId(options.databaseType, saveColumns[columnIndex]),
|
||||
);
|
||||
const sets = writableChanges
|
||||
.map(
|
||||
([columnIndex]) =>
|
||||
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
|
||||
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
|
||||
)
|
||||
.join(", ");
|
||||
if (!sets) continue;
|
||||
const where = [
|
||||
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, afterRow),
|
||||
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, afterRow),
|
||||
...writableChanges.map(([columnIndex, value]) =>
|
||||
buildColumnPredicate(options.databaseType, options.columns[columnIndex], value),
|
||||
buildColumnPredicate(options.databaseType, saveColumns[columnIndex]!, value),
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -203,6 +214,22 @@ export function buildDataGridRollbackStatements(options: DataGridSaveStatementOp
|
||||
return statements;
|
||||
}
|
||||
|
||||
function effectiveColumns(
|
||||
sourceColumns: Array<string | undefined> | undefined,
|
||||
columns: string[],
|
||||
): Array<string | undefined> {
|
||||
if (!sourceColumns || sourceColumns.length !== columns.length) return columns;
|
||||
return sourceColumns;
|
||||
}
|
||||
|
||||
function effectiveColumn(
|
||||
sourceColumns: Array<string | undefined> | undefined,
|
||||
columns: string[],
|
||||
index: number,
|
||||
): string | undefined {
|
||||
return effectiveColumns(sourceColumns, columns)[index];
|
||||
}
|
||||
|
||||
export function dataGridSaveExecutionSchema(
|
||||
databaseType: DatabaseType | undefined,
|
||||
tableMeta: DataGridTableMeta | undefined,
|
||||
@@ -233,7 +260,7 @@ export function formatGridSqlLiteral(value: GridCellValue, databaseType?: Databa
|
||||
function buildPrimaryKeyWhere(
|
||||
databaseType: DatabaseType | undefined,
|
||||
primaryKeys: string[],
|
||||
columns: string[],
|
||||
columns: Array<string | undefined>,
|
||||
row: GridCellValue[],
|
||||
): string {
|
||||
if (databaseType === "hive" && primaryKeys.length === 0) return buildRowWhere(databaseType, columns, row);
|
||||
@@ -245,10 +272,14 @@ function buildPrimaryKeyWhere(
|
||||
.join(" AND ");
|
||||
}
|
||||
|
||||
function buildRowWhere(databaseType: DatabaseType | undefined, columns: string[], row: GridCellValue[]): string {
|
||||
function buildRowWhere(
|
||||
databaseType: DatabaseType | undefined,
|
||||
columns: Array<string | undefined>,
|
||||
row: GridCellValue[],
|
||||
): string {
|
||||
return columns
|
||||
.map((column, index) =>
|
||||
isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]),
|
||||
!column || isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" AND ");
|
||||
@@ -283,9 +314,9 @@ function isNullWriteToNotNullColumn(
|
||||
return value === null && notNullColumns.has(normalizeColumnName(column));
|
||||
}
|
||||
|
||||
function findColumnIndex(columns: string[], target: string): number {
|
||||
function findColumnIndex(columns: Array<string | undefined>, target: string): number {
|
||||
const normalizedTarget = normalizeColumnName(target);
|
||||
return columns.findIndex((column) => normalizeColumnName(column) === normalizedTarget);
|
||||
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);
|
||||
}
|
||||
|
||||
function primaryKeyValueKey(primaryKeyIndexes: number[], row: GridCellValue[]): string | undefined {
|
||||
|
||||
+279
-81
@@ -21,10 +21,34 @@ export function isBinaryType(dataType: string): boolean {
|
||||
export interface EditableQueryInfo {
|
||||
schema: string | undefined;
|
||||
tableName: string;
|
||||
tableAlias?: string;
|
||||
selectStar: boolean;
|
||||
columns: string[]; // empty array if SELECT *
|
||||
columns: EditableQueryColumn[]; // empty array if SELECT *
|
||||
}
|
||||
|
||||
export interface EditableQueryColumn {
|
||||
sourceName?: string;
|
||||
resultName: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export type QueryEditabilityReason =
|
||||
| "not-select"
|
||||
| "cte"
|
||||
| "set-operation"
|
||||
| "aggregation"
|
||||
| "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:
|
||||
@@ -33,122 +57,296 @@ export interface EditableQueryInfo {
|
||||
* - WHERE, ORDER BY, LIMIT are allowed
|
||||
*/
|
||||
export function analyzeEditableQuery(sql: string): EditableQueryInfo | null {
|
||||
const trimmed = sql.trim();
|
||||
const result = analyzeEditableQueryEditability(sql);
|
||||
return result.editable ? result.analysis : null;
|
||||
}
|
||||
|
||||
// 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));
|
||||
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" };
|
||||
|
||||
// --- 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();
|
||||
const fromIndex = findTopLevelKeyword(normalized, "FROM", 0);
|
||||
if (fromIndex < 0) return { editable: false, reason: "no-table" };
|
||||
|
||||
// Reject JOIN
|
||||
if (/\bJOIN\b/i.test(fromBody)) return null;
|
||||
const selectBody = normalized.slice("SELECT".length, fromIndex).trim();
|
||||
if (/^DISTINCT\b/i.test(selectBody)) return { editable: false, reason: "aggregation" };
|
||||
|
||||
// Reject GROUP BY, HAVING (already partially covered but double-check)
|
||||
if (/\b(GROUP\s+BY|HAVING)\b/i.test(fromBody)) return null;
|
||||
const groupIndex = findTopLevelKeyword(normalized, "GROUP", fromIndex + 4);
|
||||
const havingIndex = findTopLevelKeyword(normalized, "HAVING", fromIndex + 4);
|
||||
if (groupIndex >= 0 || havingIndex >= 0) return { editable: false, reason: "aggregation" };
|
||||
|
||||
// Reject subqueries: if FROM body contains SELECT keyword
|
||||
if (/\bSELECT\b/i.test(fromBody)) return null;
|
||||
const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"], fromIndex + 4);
|
||||
const fromBody = normalized.slice(fromIndex + 4, fromEnd < 0 ? normalized.length : fromEnd).trim();
|
||||
const source = parseFromSource(fromBody);
|
||||
if (!source) return { editable: false, reason: "complex-source" };
|
||||
|
||||
// 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];
|
||||
const selectStar = isSelectStar(selectBody, source.alias);
|
||||
const columns = selectStar ? [] : parseSelectColumns(selectBody);
|
||||
if (!selectStar && columns.length === 0) return { editable: false, reason: "computed-columns" };
|
||||
|
||||
return {
|
||||
schema,
|
||||
tableName,
|
||||
selectStar,
|
||||
columns,
|
||||
editable: true,
|
||||
analysis: {
|
||||
schema: source.schema,
|
||||
tableName: source.tableName,
|
||||
tableAlias: source.alias,
|
||||
selectStar,
|
||||
columns,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelectColumns(body: string): string[] {
|
||||
const cols: string[] = [];
|
||||
export function queryEditabilityMessageKey(reason: QueryEditabilityReason): string {
|
||||
return {
|
||||
"not-select": "grid.queryEditUnsupportedNotSelect",
|
||||
cte: "grid.queryEditUnsupportedCte",
|
||||
"set-operation": "grid.queryEditUnsupportedSetOperation",
|
||||
aggregation: "grid.queryEditUnsupportedAggregation",
|
||||
"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 (ch === "(") depth++;
|
||||
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) {
|
||||
cols.push(extractColumnName(current.trim()));
|
||||
const col = parseSelectColumn(current.trim());
|
||||
if (!col) return [];
|
||||
cols.push(col);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) {
|
||||
cols.push(extractColumnName(current.trim()));
|
||||
const col = parseSelectColumn(current.trim());
|
||||
if (!col) return [];
|
||||
cols.push(col);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function extractColumnName(col: string): string {
|
||||
// Handle AS alias
|
||||
const asMatch = col.match(/\bAS\s+(\w+)/i);
|
||||
if (asMatch) return asMatch[1];
|
||||
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],
|
||||
resultName: alias ?? source.parts[source.parts.length - 1],
|
||||
expression: col.slice(0, source.end).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Strip quoting characters
|
||||
const stripped = col.replace(/[`"'[\]]/g, "");
|
||||
function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
|
||||
const alias = parseExpressionAlias(col);
|
||||
if (!alias) return null;
|
||||
return {
|
||||
sourceName: undefined,
|
||||
resultName: alias.resultName,
|
||||
expression: alias.expression,
|
||||
};
|
||||
}
|
||||
|
||||
// Take last identifier (handles table.column -> column)
|
||||
const parts = stripped.split(".");
|
||||
return parts[parts.length - 1] || stripped;
|
||||
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; tableName: string; 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 schema = ident.parts.length === 2 ? ident.parts[0] : undefined;
|
||||
return { schema, tableName, alias };
|
||||
}
|
||||
|
||||
function parseQualifiedIdentifier(text: string): { parts: string[]; end: number; rest: string; done: boolean } | null {
|
||||
const parts: string[] = [];
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
pos = skipWhitespace(text, pos);
|
||||
const ident = readIdentifier(text, pos);
|
||||
if (!ident) break;
|
||||
parts.push(ident.value);
|
||||
pos = skipWhitespace(text, ident.end);
|
||||
if (text[pos] !== ".") break;
|
||||
pos++;
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return { parts, end: pos, rest: text.slice(pos), done: text.slice(pos).trim() === "" };
|
||||
}
|
||||
|
||||
function readIdentifier(text: string, start: number): { value: string; 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, end: i + 1 };
|
||||
value += text[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const match = text.slice(pos).match(/^[A-Za-z_][\w$]*/);
|
||||
return match ? { value: match[0], 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[]): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user