test(editor): cover context menu routing payloads
This commit is contained in:
1 parent
d3118256a4
commit
711d0ec57b
13 files changed
+484
-197
No files matched your search
@@ -42,6 +42,22 @@ describe("completionTreeIndex", () => {
|
||||
database: "hive",
|
||||
schema: "sales_analytics",
|
||||
},
|
||||
{
|
||||
id: "conn:hive:sales_analytics:__tables:daily_revenue_view",
|
||||
label: "daily_revenue_view",
|
||||
type: "view",
|
||||
connectionId: "conn",
|
||||
database: "hive",
|
||||
schema: "sales_analytics",
|
||||
},
|
||||
{
|
||||
id: "conn:hive:sales_analytics:__tables:daily_revenue_mv",
|
||||
label: "daily_revenue_mv",
|
||||
type: "materialized_view",
|
||||
connectionId: "conn",
|
||||
database: "hive",
|
||||
schema: "sales_analytics",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -53,7 +69,12 @@ describe("completionTreeIndex", () => {
|
||||
];
|
||||
|
||||
expect(completionSchemasFromTree(tree, "conn", "hive")).toEqual(["sales_analytics"]);
|
||||
expect(completionTablesFromTree(tree, "conn", "hive", "sales_analytics")).toEqual([{ name: "daily_revenue", schema: "sales_analytics", type: "table" }]);
|
||||
expect(completionTablesFromTree(tree, "conn", "hive")).toEqual([{ name: "daily_revenue", schema: "sales_analytics", type: "table" }]);
|
||||
const expected = [
|
||||
{ name: "daily_revenue", schema: "sales_analytics", type: "table" },
|
||||
{ name: "daily_revenue_view", schema: "sales_analytics", type: "view" },
|
||||
{ name: "daily_revenue_mv", schema: "sales_analytics", type: "materialized_view" },
|
||||
];
|
||||
expect(completionTablesFromTree(tree, "conn", "hive", "sales_analytics")).toEqual(expected);
|
||||
expect(completionTablesFromTree(tree, "conn", "hive")).toEqual(expected);
|
||||
});
|
||||
});
|
||||
+28
-2
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractIdentifierAt, extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTableType, sqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import {
|
||||
extractIdentifierAt,
|
||||
extractIdentifierDetailsAt,
|
||||
isSqlKeyword,
|
||||
matchTable,
|
||||
mergeSqlObjectNavigationType,
|
||||
splitQualifiedIdentifier,
|
||||
sqlObjectHoverDetail,
|
||||
sqlObjectNavigationSourceKind,
|
||||
sqlObjectNavigationTableType,
|
||||
sqlObjectNavigationTarget,
|
||||
sqlObjectNavigationTypeFromTableType,
|
||||
} from "@/lib/sql/sqlNavigation";
|
||||
|
||||
describe("extractIdentifierAt", () => {
|
||||
it("extracts unquoted qualified identifiers", () => {
|
||||
@@ -77,8 +89,9 @@ describe("matchTable", () => {
|
||||
|
||||
describe("SQL object navigation metadata", () => {
|
||||
it("preserves view type and schema for command-click navigation", () => {
|
||||
expect(sqlObjectNavigationTarget({ name: "active_users", schema: "dbo", type: "view" })).toEqual({
|
||||
expect(sqlObjectNavigationTarget({ name: "active_users", database: "app", schema: "dbo", type: "view" })).toEqual({
|
||||
name: "active_users",
|
||||
database: "app",
|
||||
schema: "dbo",
|
||||
type: "view",
|
||||
});
|
||||
@@ -86,11 +99,24 @@ describe("SQL object navigation metadata", () => {
|
||||
|
||||
it("uses the object type in hover details", () => {
|
||||
expect(sqlObjectHoverDetail({ name: "active_users", schema: "dbo", type: "view" })).toBe("view in dbo");
|
||||
expect(sqlObjectHoverDetail({ name: "active_users_mv", schema: "dbo", type: "materialized_view" })).toBe("materialized view in dbo");
|
||||
expect(sqlObjectHoverDetail({ name: "users", schema: "dbo", type: "table" })).toBe("table in dbo");
|
||||
});
|
||||
|
||||
it("maps navigation types to table metadata types", () => {
|
||||
expect(sqlObjectNavigationTableType({ name: "active_users", type: "view" })).toBe("VIEW");
|
||||
expect(sqlObjectNavigationTableType({ name: "active_users_mv", type: "materialized_view" })).toBe("MATERIALIZED_VIEW");
|
||||
expect(sqlObjectNavigationTableType({ name: "users", type: "table" })).toBe("TABLE");
|
||||
expect(sqlObjectNavigationSourceKind({ name: "active_users", type: "view" })).toBe("VIEW");
|
||||
expect(sqlObjectNavigationSourceKind({ name: "active_users_mv", type: "materialized_view" })).toBe("MATERIALIZED_VIEW");
|
||||
expect(sqlObjectNavigationSourceKind({ name: "users", type: "table" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes relation metadata without collapsing materialized views", () => {
|
||||
expect(sqlObjectNavigationTypeFromTableType("BASE TABLE")).toBe("table");
|
||||
expect(sqlObjectNavigationTypeFromTableType("VIEW")).toBe("view");
|
||||
expect(sqlObjectNavigationTypeFromTableType("materialized view")).toBe("materialized_view");
|
||||
expect(mergeSqlObjectNavigationType("view", "materialized_view")).toBe("materialized_view");
|
||||
expect(mergeSqlObjectNavigationType("table", "view")).toBe("view");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export function completionTablesFromTree(nodes: readonly TreeNode[], connectionI
|
||||
tables.push({
|
||||
name: node.tableName || node.label,
|
||||
schema: node.schema,
|
||||
type: node.type === "view" || node.type === "materialized_view" ? "view" : "table",
|
||||
type: node.type === "materialized_view" ? "materialized_view" : node.type === "view" ? "view" : "table",
|
||||
});
|
||||
});
|
||||
return dedupeCompletionTables(tables);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isSqlKeyword } from "@/lib/sql/sqlNavigation";
|
||||
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
|
||||
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
|
||||
|
||||
export interface QueryCursorTableCandidate {
|
||||
@@ -10,99 +11,28 @@ export interface QueryCursorTableCandidate {
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export interface IdentifierPart {
|
||||
value: string;
|
||||
quoted: boolean;
|
||||
export interface QueryTableCandidateAtPositionInput {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
databaseType?: DatabaseType;
|
||||
sql: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
function identifierChar(ch: string | undefined): boolean {
|
||||
return !!ch && (/[A-Za-z0-9_$."`-]/.test(ch) || ch === "[" || ch === "]");
|
||||
}
|
||||
export type QueryContextObjectAction = "view-data" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
|
||||
|
||||
function readIdentifierWindow(sql: string, pos: number): string {
|
||||
const clamped = Math.max(0, Math.min(pos, sql.length));
|
||||
let from = clamped;
|
||||
let to = clamped;
|
||||
export type QueryContextObjectRoute =
|
||||
| { event: "viewTableData"; payload: [target: SqlObjectNavigationTarget] }
|
||||
| { event: "editTableStructure"; payload: [target: SqlObjectNavigationTarget] }
|
||||
| { event: "openObjectSource"; payload: [target: SqlObjectNavigationTarget, initialEditing: boolean] }
|
||||
| { event: "viewTableDdl"; payload: [target: SqlObjectNavigationTarget] };
|
||||
|
||||
if (!identifierChar(sql[from]) && identifierChar(sql[from - 1])) {
|
||||
from -= 1;
|
||||
to = from + 1;
|
||||
}
|
||||
|
||||
if (!identifierChar(sql[from])) return "";
|
||||
|
||||
while (from > 0 && identifierChar(sql[from - 1])) from -= 1;
|
||||
while (to < sql.length && identifierChar(sql[to])) to += 1;
|
||||
return sql.slice(from, to).replace(/^\.+|\.+$/g, "");
|
||||
}
|
||||
|
||||
function splitQualifiedIdentifier(text: string): IdentifierPart[] {
|
||||
const parts: IdentifierPart[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
let quote: "`" | '"' | "]" | null = null;
|
||||
|
||||
function pushPart() {
|
||||
const value = current.trim();
|
||||
if (value) parts.push({ value, quoted });
|
||||
current = "";
|
||||
quoted = false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
|
||||
if (quote) {
|
||||
if (quote === "]" && ch === "]") {
|
||||
if (text[i + 1] === "]") {
|
||||
current += "]";
|
||||
i += 1;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((quote === "`" || quote === '"') && ch === quote) {
|
||||
if (text[i + 1] === quote) {
|
||||
current += ch;
|
||||
i += 1;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ".") {
|
||||
pushPart();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "`" || ch === '"') {
|
||||
quoted = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "[") {
|
||||
quoted = true;
|
||||
quote = "]";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
pushPart();
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function extractQualifiedIdentifierPartsAt(sql: string, pos: number): IdentifierPart[] {
|
||||
const text = readIdentifierWindow(sql, pos);
|
||||
if (!text) return [];
|
||||
const parts = splitQualifiedIdentifier(text);
|
||||
export function extractQualifiedIdentifierPartsAt(sql: string, pos: number) {
|
||||
let parts = extractIdentifierPartsAt(sql, pos);
|
||||
// CodeMirror can report the boundary immediately after the clicked token;
|
||||
// retain the legacy behavior that treats that boundary as part of the identifier.
|
||||
if (parts.length === 0 && pos > 0) parts = extractIdentifierPartsAt(sql, pos - 1);
|
||||
const last = parts[parts.length - 1];
|
||||
if (!last || (!last.quoted && isSqlKeyword(last.value))) return [];
|
||||
return parts;
|
||||
@@ -112,18 +42,29 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
|
||||
if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null;
|
||||
|
||||
const cursor = tab.editorSelection?.head ?? tab.editorSelection?.anchor ?? tab.sql.length;
|
||||
const parts = extractQualifiedIdentifierPartsAt(tab.sql, cursor).map((part) => part.value);
|
||||
return queryTableCandidateAtSqlPosition({
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
schema: tab.schema,
|
||||
databaseType,
|
||||
sql: tab.sql,
|
||||
position: cursor,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
|
||||
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const tableName = parts[parts.length - 1];
|
||||
let database = tab.database;
|
||||
let schema = tab.schema;
|
||||
let database = input.database;
|
||||
let schema = input.schema;
|
||||
|
||||
if (parts.length >= 3) {
|
||||
database = parts[parts.length - 3];
|
||||
schema = parts[parts.length - 2];
|
||||
} else if (parts.length === 2) {
|
||||
if (databaseType && !isSchemaAware(databaseType) && !isSingleDatabase(databaseType)) {
|
||||
if (input.databaseType && !isSchemaAware(input.databaseType) && !isSingleDatabase(input.databaseType)) {
|
||||
database = parts[0];
|
||||
schema = undefined;
|
||||
} else {
|
||||
@@ -131,7 +72,46 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
|
||||
}
|
||||
}
|
||||
|
||||
return { connectionId: tab.connectionId, database, schema, tableName };
|
||||
return { connectionId: input.connectionId, database, schema, tableName };
|
||||
}
|
||||
|
||||
export function resolveQueryContextCandidateDatabase(candidate: QueryCursorTableCandidate, databases: readonly string[]): QueryCursorTableCandidate {
|
||||
const database = databases.find((name) => sameIdentifier(name, candidate.database));
|
||||
return database && database !== candidate.database ? { ...candidate, database } : candidate;
|
||||
}
|
||||
|
||||
export function resolveQueryContextObjectTarget(candidate: QueryCursorTableCandidate, tables: readonly SqlCompletionTable[]): SqlObjectNavigationTarget {
|
||||
const nameMatches = tables.filter((table) => sameIdentifier(table.name, candidate.tableName));
|
||||
const match = candidate.schema ? (nameMatches.find((table) => sameIdentifier(table.schema, candidate.schema)) ?? nameMatches.find((table) => !table.schema)) : nameMatches[0];
|
||||
return sqlObjectNavigationTarget({
|
||||
name: match?.name ?? candidate.tableName,
|
||||
database: candidate.database,
|
||||
schema: match?.schema ?? candidate.schema,
|
||||
type: match?.type,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryContextObjectActions(type?: SqlObjectNavigationType): QueryContextObjectAction[] {
|
||||
if (type === "view" || type === "materialized_view") {
|
||||
return ["view-data", "edit-view", "view-source", "view-ddl"];
|
||||
}
|
||||
// Unknown metadata preserves the historical table actions instead of disabling existing entry points.
|
||||
return ["view-data", "edit-table-structure", "view-ddl"];
|
||||
}
|
||||
|
||||
export function queryContextObjectRoute(action: QueryContextObjectAction, target: SqlObjectNavigationTarget): QueryContextObjectRoute {
|
||||
switch (action) {
|
||||
case "view-data":
|
||||
return { event: "viewTableData", payload: [target] };
|
||||
case "edit-table-structure":
|
||||
return { event: "editTableStructure", payload: [target] };
|
||||
case "edit-view":
|
||||
return { event: "openObjectSource", payload: [target, true] };
|
||||
case "view-source":
|
||||
return { event: "openObjectSource", payload: [target, false] };
|
||||
case "view-ddl":
|
||||
return { event: "viewTableDdl", payload: [target] };
|
||||
}
|
||||
}
|
||||
|
||||
export function qualifiedTableNameAtSqlPosition(sql: string, pos: number): string | null {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Cassandra, MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, SQLite, StandardS
|
||||
import type { DatabaseType, SqlSnippet } from "@/types/database";
|
||||
import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
|
||||
import { CLOUDFLARE_D1_COMMON_FUNCTION_NAMES } from "@/lib/sql/cloudflareD1";
|
||||
import type { SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
@@ -1156,7 +1157,7 @@ function sqlAliasKeywordWords(...sources: Array<string | undefined>): string[] {
|
||||
export interface SqlCompletionTable {
|
||||
name: string;
|
||||
schema?: string;
|
||||
type?: "table" | "view";
|
||||
type?: SqlObjectNavigationType;
|
||||
detail?: string;
|
||||
applyName?: string;
|
||||
boost?: number;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* 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",
|
||||
@@ -128,29 +130,60 @@ export interface ExtractedSqlIdentifier {
|
||||
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?: "table" | "view";
|
||||
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 === "view" ? "view" : "table";
|
||||
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" {
|
||||
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);
|
||||
}
|
||||
@@ -213,12 +246,12 @@ function identifierSearchBounds(doc: string, pos: number): { start: number; end:
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Extract identifier and quote metadata at position `pos` in the document. */
|
||||
export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedSqlIdentifier | null {
|
||||
if (pos < 0 || pos > doc.length) return null;
|
||||
/** 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 null;
|
||||
if (clickPos < 0) return [];
|
||||
|
||||
const bounds = identifierSearchBounds(doc, clickPos);
|
||||
let index = bounds.start;
|
||||
@@ -226,10 +259,7 @@ export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedS
|
||||
const parsed = parseQualifiedIdentifier(doc, index);
|
||||
if (parsed) {
|
||||
if (clickPos >= parsed.start && clickPos < parsed.end) {
|
||||
return {
|
||||
identifier: parsed.parts.map((part) => part.value).join("."),
|
||||
quoted: parsed.parts.some((part) => part.quoted),
|
||||
};
|
||||
return parsed.parts.map((part) => ({ value: part.value, quoted: part.quoted }));
|
||||
}
|
||||
index = Math.max(parsed.end, index + 1);
|
||||
continue;
|
||||
@@ -237,7 +267,17 @@ export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedS
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
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. */
|
||||
|
||||
Reference in new issue
Block a user