dbx/apps/desktop/src/lib/sql/sqlNavigation.ts

320 lines
8.2 KiB
TypeScript

/**
* Utilities for SQL identifier navigation (Ctrl/Cmd + click on table/column names).
*/
import type { ObjectSourceKind } from "@/types/database";
const SQL_KEYWORDS_SET = new Set([
"select",
"from",
"where",
"join",
"left",
"right",
"inner",
"outer",
"on",
"group",
"by",
"order",
"asc",
"desc",
"having",
"limit",
"offset",
"insert",
"into",
"values",
"update",
"set",
"delete",
"create",
"table",
"view",
"as",
"and",
"or",
"not",
"in",
"is",
"null",
"like",
"distinct",
"union",
"all",
"exists",
"between",
"case",
"when",
"then",
"else",
"end",
"count",
"sum",
"avg",
"min",
"max",
"coalesce",
"cast",
"alter",
"drop",
"add",
"column",
"index",
"primary",
"key",
"foreign",
"references",
"constraint",
"default",
"check",
"unique",
"begin",
"commit",
"rollback",
"truncate",
"explain",
"analyze",
"with",
"recursive",
"over",
"partition",
"row_number",
"rank",
"dense_rank",
"lag",
"lead",
"first_value",
"last_value",
"ntile",
"cross",
"full",
"natural",
"using",
"lateral",
"unnest",
"filter",
"exclude",
"replace",
"qualify",
"pivot",
"unpivot",
"asof",
"positional",
"anti",
"semi",
"sample",
"struct",
"map",
"list",
"array",
"lambda",
"copy",
"export",
"import",
"describe",
"show",
"summarize",
"pragma",
"tablesample",
"read_csv",
"read_parquet",
"read_json",
"list_transform",
]);
type IdentifierPart = { value: string; start: number; end: number; quoted: boolean };
export interface ExtractedSqlIdentifier {
identifier: string;
quoted: boolean;
}
export interface ExtractedSqlIdentifierPart {
value: string;
quoted: boolean;
}
export type SqlObjectNavigationType = "table" | "view" | "materialized_view";
export interface SqlObjectNavigationTarget {
name: string;
database?: string;
schema?: string;
type?: SqlObjectNavigationType;
}
export function sqlObjectNavigationTarget(table: SqlObjectNavigationTarget): SqlObjectNavigationTarget {
return {
name: table.name,
...(table.database ? { database: table.database } : {}),
...(table.schema ? { schema: table.schema } : {}),
...(table.type ? { type: table.type } : {}),
};
}
export function sqlObjectHoverDetail(table: SqlObjectNavigationTarget): string {
const objectType = table.type === "materialized_view" ? "materialized view" : table.type === "view" ? "view" : "table";
return table.schema ? `${objectType} in ${table.schema}` : objectType;
}
export function sqlObjectNavigationTableType(table: SqlObjectNavigationTarget): "TABLE" | "VIEW" | "MATERIALIZED_VIEW" {
if (table.type === "materialized_view") return "MATERIALIZED_VIEW";
return table.type === "view" ? "VIEW" : "TABLE";
}
export function sqlObjectNavigationSourceKind(table: SqlObjectNavigationTarget): ObjectSourceKind | undefined {
const type = sqlObjectNavigationTableType(table);
return type === "TABLE" ? undefined : type;
}
export function sqlObjectNavigationTypeFromTableType(tableType: string | null | undefined): SqlObjectNavigationType {
const normalized = tableType
?.trim()
.toUpperCase()
.replace(/[\s-]+/g, "_");
if (normalized === "MATERIALIZED_VIEW") return "materialized_view";
if (normalized === "VIEW") return "view";
return "table";
}
export function mergeSqlObjectNavigationType(left?: SqlObjectNavigationType, right?: SqlObjectNavigationType): SqlObjectNavigationType | undefined {
if (left === "materialized_view" || right === "materialized_view") return "materialized_view";
if (left === "view" || right === "view") return "view";
return left ?? right;
}
function isIdentifierChar(char: string | undefined): boolean {
return !!char && /^[A-Za-z0-9_$]$/.test(char);
}
function readQuotedPart(text: string, start: number): IdentifierPart | null {
const open = text[start];
const close = open === "[" ? "]" : open;
if (open !== "`" && open !== '"' && open !== "[") return null;
let value = "";
for (let i = start + 1; i < text.length; i += 1) {
const char = text[i];
if (char === close) {
if (text[i + 1] === close) {
value += close;
i += 1;
continue;
}
return { value, start, end: i + 1, quoted: true };
}
value += char;
}
return null;
}
function readUnquotedPart(text: string, start: number): IdentifierPart | null {
if (!isIdentifierChar(text[start])) return null;
let end = start + 1;
while (end < text.length && isIdentifierChar(text[end])) end += 1;
return { value: text.slice(start, end), start, end, quoted: false };
}
function readIdentifierPart(text: string, start: number): IdentifierPart | null {
return readQuotedPart(text, start) ?? readUnquotedPart(text, start);
}
function parseQualifiedIdentifier(text: string, start: number): { parts: IdentifierPart[]; start: number; end: number } | null {
const first = readIdentifierPart(text, start);
if (!first) return null;
const parts = [first];
let end = first.end;
while (text[end] === ".") {
const next = readIdentifierPart(text, end + 1);
if (!next) break;
parts.push(next);
end = next.end;
}
return { parts, start, end };
}
function identifierSearchBounds(doc: string, pos: number): { start: number; end: number } {
let start = pos;
while (start > 0 && doc[start - 1] !== "\n" && doc[start - 1] !== "\r") start -= 1;
let end = pos;
while (end < doc.length && doc[end] !== "\n" && doc[end] !== "\r") end += 1;
return { start, end };
}
/** Extract qualified identifier parts and per-part quote metadata at position `pos`. */
export function extractIdentifierPartsAt(doc: string, pos: number): ExtractedSqlIdentifierPart[] {
if (pos < 0 || pos > doc.length) return [];
const clickPos = pos === doc.length ? pos - 1 : pos;
if (clickPos < 0) return [];
const bounds = identifierSearchBounds(doc, clickPos);
let index = bounds.start;
while (index < bounds.end) {
const parsed = parseQualifiedIdentifier(doc, index);
if (parsed) {
if (clickPos >= parsed.start && clickPos < parsed.end) {
return parsed.parts.map((part) => ({ value: part.value, quoted: part.quoted }));
}
index = Math.max(parsed.end, index + 1);
continue;
}
index += 1;
}
return [];
}
/** Extract identifier and quote metadata at position `pos` in the document. */
export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedSqlIdentifier | null {
const parts = extractIdentifierPartsAt(doc, pos);
if (parts.length === 0) return null;
return {
identifier: parts.map((part) => part.value).join("."),
quoted: parts.some((part) => part.quoted),
};
}
/** Extract identifier at position `pos` in the document. */
export function extractIdentifierAt(doc: string, pos: number): string | null {
return extractIdentifierDetailsAt(doc, pos)?.identifier ?? null;
}
/** Check whether the identifier is a SQL keyword (not a table/column name). */
export function isSqlKeyword(identifier: string): boolean {
return SQL_KEYWORDS_SET.has(identifier.toLowerCase());
}
export function splitQualifiedIdentifier(identifier: string): string[] {
const trimmed = identifier.trim();
if (!trimmed) return [];
const parsed = parseQualifiedIdentifier(trimmed, 0);
if (!parsed || parsed.end !== trimmed.length) return [trimmed];
return parsed.parts.map((part) => part.value);
}
/** Match identifier against known table names (case-insensitive). Supports qualified identifiers like schema.table. */
export function matchTable<T extends { name: string; schema?: string }>(identifier: string, tables: T[]): T | null {
const parts = splitQualifiedIdentifier(identifier);
const normalizedIdentifier = parts.length > 0 ? parts.join(".").toLowerCase() : identifier.toLowerCase();
const direct = tables.find((t) => t.name.toLowerCase() === normalizedIdentifier);
if (direct) return direct;
if (parts.length >= 2) {
// Use the final two parts so catalog.schema.table still resolves against schema-scoped metadata.
const qualifier = parts[parts.length - 2].toLowerCase();
const name = parts[parts.length - 1].toLowerCase();
const qualified = tables.find((t) => t.name.toLowerCase() === name && t.schema?.toLowerCase() === qualifier);
if (qualified) return qualified;
}
return null;
}