refactor(desktop): organize lib modules by domain
This commit is contained in:
1 parent
90f0594e93
commit
7e7c4944e9
700 files changed
+1292
-1264
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { executableStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
|
||||
export interface ExecutableStatementRangeCache {
|
||||
doc: Text;
|
||||
databaseType?: DatabaseType;
|
||||
byStart: Map<number, SqlTextRange>;
|
||||
}
|
||||
|
||||
export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType) => SqlTextRange[];
|
||||
|
||||
export function executableStatementRangeCacheForDoc(cache: ExecutableStatementRangeCache | null, doc: Text, databaseType?: DatabaseType, parse: ExecutableStatementRangeParser = executableStatementRanges): ExecutableStatementRangeCache {
|
||||
if (cache?.doc === doc && cache.databaseType === databaseType) return cache;
|
||||
|
||||
const byStart = new Map<number, SqlTextRange>();
|
||||
for (const range of parse(doc.toString(), databaseType)) {
|
||||
byStart.set(range.from, range);
|
||||
}
|
||||
return { doc, databaseType, byStart };
|
||||
}
|
||||
|
||||
export function executableStatementRangeStartingAt(cache: ExecutableStatementRangeCache, lineFrom: number): SqlTextRange | null {
|
||||
return cache.byStart.get(lineFrom) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SqlFileProgress } from "@/lib/backend/tauri";
|
||||
import { apiUrl } from "@/lib/common/webPath";
|
||||
|
||||
export function listenSqlFileProgressById(executionId: string, handler: (progress: SqlFileProgress) => void): () => void {
|
||||
const es = new EventSource(apiUrl(`/api/sql-file/progress/${executionId}`));
|
||||
es.onmessage = (e) => {
|
||||
const progress: SqlFileProgress = JSON.parse(e.data);
|
||||
handler(progress);
|
||||
if (progress.status === "done" || progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
};
|
||||
return () => es.close();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import type { ConnectionConfig, QueryTab, TreeNode } from "@/types/database";
|
||||
|
||||
export interface NewQueryTarget {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
shouldRefreshDefaultDatabase: boolean;
|
||||
}
|
||||
|
||||
export type NewQueryContextSource = "tab" | "sidebar";
|
||||
|
||||
interface ResolveNewQueryTargetInput {
|
||||
activeTab?: Pick<QueryTab, "connectionId" | "database" | "schema">;
|
||||
selectedTreeNode?: Pick<TreeNode, "connectionId" | "database" | "schema"> | null;
|
||||
activeConnectionId?: string | null;
|
||||
connections: Pick<ConnectionConfig, "id" | "database">[];
|
||||
preferredSource?: NewQueryContextSource;
|
||||
}
|
||||
|
||||
export function findTreeNodeById(nodes: TreeNode[], id: string | null | undefined): TreeNode | null {
|
||||
if (!id) return null;
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
const found = findTreeNodeById(node.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveNewQueryTarget(input: ResolveNewQueryTargetInput): NewQueryTarget | null {
|
||||
const primaryContext = input.preferredSource === "sidebar" ? input.selectedTreeNode || undefined : input.activeTab;
|
||||
const secondaryContext = input.preferredSource === "sidebar" ? input.activeTab : input.selectedTreeNode || undefined;
|
||||
const primaryTarget = targetFromContext(primaryContext, input.connections);
|
||||
if (primaryTarget) return primaryTarget;
|
||||
const secondaryTarget = targetFromContext(secondaryContext, input.connections);
|
||||
if (secondaryTarget) return secondaryTarget;
|
||||
|
||||
const activeConnection = input.activeConnectionId ? input.connections.find((connection) => connection.id === input.activeConnectionId) : undefined;
|
||||
const fallbackConnection = activeConnection || input.connections[0];
|
||||
return fallbackConnection
|
||||
? {
|
||||
connectionId: fallbackConnection.id,
|
||||
database: resolveDefaultDatabase(fallbackConnection, []),
|
||||
shouldRefreshDefaultDatabase: true,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function targetFromContext(context: Pick<QueryTab | TreeNode, "connectionId" | "database" | "schema"> | undefined, connections: Pick<ConnectionConfig, "id" | "database">[]): NewQueryTarget | null {
|
||||
if (!context?.connectionId) return null;
|
||||
const connection = connections.find((item) => item.id === context.connectionId);
|
||||
if (!connection) return null;
|
||||
const database = context.database || resolveDefaultDatabase(connection, []);
|
||||
return {
|
||||
connectionId: context.connectionId,
|
||||
database,
|
||||
schema: "schema" in context ? (context as { schema?: string }).schema : undefined,
|
||||
shouldRefreshDefaultDatabase: !context.database,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isSqlKeyword } from "@/lib/sql/sqlNavigation";
|
||||
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
|
||||
|
||||
export interface QueryCursorTableCandidate {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export interface IdentifierPart {
|
||||
value: string;
|
||||
quoted: boolean;
|
||||
}
|
||||
|
||||
function identifierChar(ch: string | undefined): boolean {
|
||||
return !!ch && (/[A-Za-z0-9_$."`-]/.test(ch) || ch === "[" || ch === "]");
|
||||
}
|
||||
|
||||
function readIdentifierWindow(sql: string, pos: number): string {
|
||||
const clamped = Math.max(0, Math.min(pos, sql.length));
|
||||
let from = clamped;
|
||||
let to = clamped;
|
||||
|
||||
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);
|
||||
const last = parts[parts.length - 1];
|
||||
if (!last || (!last.quoted && isSqlKeyword(last.value))) return [];
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function queryCursorTableCandidate(tab: QueryTab | undefined | null, databaseType?: DatabaseType): QueryCursorTableCandidate | null {
|
||||
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);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const tableName = parts[parts.length - 1];
|
||||
let database = tab.database;
|
||||
let schema = tab.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)) {
|
||||
database = parts[0];
|
||||
schema = undefined;
|
||||
} else {
|
||||
schema = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
return { connectionId: tab.connectionId, database, schema, tableName };
|
||||
}
|
||||
|
||||
export function qualifiedTableNameAtSqlPosition(sql: string, pos: number): string | null {
|
||||
const parts = extractQualifiedIdentifierPartsAt(sql, pos).map((part) => part.value);
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
export function queryContextTargetFromCandidate(tab: QueryTab | undefined | null, candidate?: QueryCursorTableCandidate | null): ActiveTabSidebarTarget | null {
|
||||
if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null;
|
||||
return {
|
||||
type: "query-context",
|
||||
connectionId: tab.connectionId,
|
||||
database: candidate?.database || tab.database,
|
||||
schema: candidate?.schema ?? tab.schema,
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentifier(left: string | undefined, right: string | undefined): boolean {
|
||||
return (left || "").toLowerCase() === (right || "").toLowerCase();
|
||||
}
|
||||
|
||||
function nodeMatchesCandidate(node: TreeNode, candidate: QueryCursorTableCandidate): boolean {
|
||||
if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_view") return false;
|
||||
if (node.connectionId !== candidate.connectionId) return false;
|
||||
if (!sameIdentifier(node.database, candidate.database)) return false;
|
||||
if (candidate.schema && !sameIdentifier(node.schema, candidate.schema)) return false;
|
||||
return sameIdentifier(node.label, candidate.tableName);
|
||||
}
|
||||
|
||||
export function findLoadedTableTargetForCandidate(nodes: readonly TreeNode[], candidate: QueryCursorTableCandidate): ActiveTabSidebarTarget | null {
|
||||
for (const node of nodes) {
|
||||
if (nodeMatchesCandidate(node, candidate)) {
|
||||
return {
|
||||
type: "table",
|
||||
connectionId: candidate.connectionId,
|
||||
database: node.database || candidate.database,
|
||||
schema: node.schema || candidate.schema,
|
||||
tableName: node.label,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const found = findLoadedTableTargetForCandidate(node.children, candidate);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface QueryExecutionStateLike {
|
||||
isExecuting: boolean;
|
||||
isCancelling?: boolean;
|
||||
executionId?: string;
|
||||
}
|
||||
|
||||
export function canCancelQueryExecution(state: QueryExecutionStateLike): boolean {
|
||||
return state.isExecuting && !!state.executionId && !state.isCancelling;
|
||||
}
|
||||
|
||||
export function queryExecutionLabelKey(state: Pick<QueryExecutionStateLike, "isCancelling">): "common.loading" | "common.stopping" {
|
||||
return state.isCancelling ? "common.stopping" : "common.loading";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
|
||||
export const DEFAULT_QUERY_TIMEOUT_SECS = 30;
|
||||
|
||||
export function queryTimeoutSecsForConnection(connection?: Pick<ConnectionConfig, "query_timeout_secs"> | null): number {
|
||||
const value = Number(connection?.query_timeout_secs);
|
||||
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_QUERY_TIMEOUT_SECS;
|
||||
}
|
||||
|
||||
export function frontendQueryTimeoutSecsForSql(sql: string, databaseType: DatabaseType | undefined, queryTimeoutSecs: number): number {
|
||||
if (queryTimeoutSecs === 0) return 0;
|
||||
|
||||
const baseTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60);
|
||||
const statementCount = Math.max(splitSqlStatementRanges(sql, databaseType).length, 1);
|
||||
return baseTimeoutSecs * statementCount;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# SQL Semantic
|
||||
|
||||
This directory owns the pure frontend SQL semantic layer used by editor completion, diagnostics, and navigation.
|
||||
|
||||
- `tokens.ts`: span-preserving SQL tokenization and active statement detection.
|
||||
- `dialect.ts`: dialect adapters for identifier normalization, quoting, qualifier roles, and projection alias visibility.
|
||||
- `model.ts`: active-statement semantic model, row-source parsing, cursor intent, and confidence/fallback output.
|
||||
- `completion.ts`: compatibility adapter from semantic intent to the existing SQL completion item builder.
|
||||
- `references.ts`: shared row-source references for completion, diagnostics, and navigation.
|
||||
- `diagnostics.ts`: SQL semantic diagnostic helpers and viewport range selection.
|
||||
- `fixtures.ts` and `types.ts`: test fixtures and shared semantic types.
|
||||
|
||||
The older `sqlCompletion.ts` and `sqlNavigation.ts` files remain at `lib/` for now because they are broad public editor utilities. Move them only with a separate behavior-preserving migration.
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
|
||||
import { SQL_SEMANTIC_DIALECTS } from "@/lib/sql/semantic/dialect";
|
||||
import type { SqlSemanticModel, SqlSemanticRowSource } from "@/lib/sql/semantic/types";
|
||||
|
||||
export type SqlSemanticCompletionScopeKind = "keyword" | "table" | "schema" | "catalog" | "routine" | "columns" | "local";
|
||||
|
||||
export interface SqlSemanticCompletionScope {
|
||||
kind: SqlSemanticCompletionScopeKind;
|
||||
prefix: string;
|
||||
qualifierParts: string[];
|
||||
targetSource?: SqlSemanticRowSource;
|
||||
useRemoteMetadata: boolean;
|
||||
fallbackReason?: string;
|
||||
}
|
||||
|
||||
export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlCompletionReferencedTable[] {
|
||||
return model.rowSources
|
||||
.filter((source) => source.kind !== "unknown")
|
||||
.map((source) => ({
|
||||
name: source.name,
|
||||
schema: source.qualifierParts[source.qualifierParts.length - 1],
|
||||
alias: source.alias,
|
||||
columns: source.columns,
|
||||
}));
|
||||
}
|
||||
|
||||
export function sqlSemanticLocalColumnsByTable(model: SqlSemanticModel): Map<string, SqlCompletionColumn[]> {
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
for (const source of model.rowSources) {
|
||||
if (!source.columns?.length) continue;
|
||||
columnsByTable.set(
|
||||
source.name,
|
||||
source.columns.map((name) => ({
|
||||
name,
|
||||
table: source.name,
|
||||
schema: source.qualifierParts[source.qualifierParts.length - 1],
|
||||
})),
|
||||
);
|
||||
}
|
||||
return columnsByTable;
|
||||
}
|
||||
|
||||
function activeProjectionAliasClause(model: SqlSemanticModel): "where" | "groupBy" | "having" | "orderBy" | null {
|
||||
const words = model.tokens.filter((token) => token.span.end <= model.cursor && token.kind === "word").map((token) => token.normalized);
|
||||
for (let index = words.length - 1; index >= 0; index -= 1) {
|
||||
const word = words[index];
|
||||
const previous = words[index - 1];
|
||||
if (word === "by" && previous === "order") return "orderBy";
|
||||
if (word === "by" && previous === "group") return "groupBy";
|
||||
if (word === "having") return "having";
|
||||
if (word === "where") return "where";
|
||||
if (word === "from" || word === "join" || word === "select") return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sqlSemanticProjectionAliasColumns(model: SqlSemanticModel): SqlCompletionColumn[] {
|
||||
const clause = activeProjectionAliasClause(model);
|
||||
if (!clause) return [];
|
||||
const adapter = SQL_SEMANTIC_DIALECTS[model.dialectId] ?? SQL_SEMANTIC_DIALECTS.generic;
|
||||
if (!adapter.projectionAliasVisibility[clause]) return [];
|
||||
return model.projections
|
||||
.filter((projection) => projection.name)
|
||||
.map((projection) => ({
|
||||
name: projection.name,
|
||||
table: "__projection__",
|
||||
comment: "Projection alias",
|
||||
}));
|
||||
}
|
||||
|
||||
export function sqlSemanticCompletionScope(model: SqlSemanticModel): SqlSemanticCompletionScope {
|
||||
const intent = model.cursorIntent;
|
||||
const targetSource = intent.targetSourceId ? model.rowSources.find((source) => source.id === intent.targetSourceId) : undefined;
|
||||
switch (intent.kind) {
|
||||
case "table":
|
||||
case "delete_target":
|
||||
return {
|
||||
kind: "table",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: intent.qualifierParts,
|
||||
useRemoteMetadata: intent.confidence !== "low",
|
||||
fallbackReason: intent.fallbackReason,
|
||||
};
|
||||
case "schema":
|
||||
return {
|
||||
kind: "schema",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: intent.qualifierParts,
|
||||
useRemoteMetadata: intent.confidence !== "low",
|
||||
fallbackReason: intent.fallbackReason,
|
||||
};
|
||||
case "catalog":
|
||||
return {
|
||||
kind: "catalog",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: intent.qualifierParts,
|
||||
useRemoteMetadata: intent.confidence !== "low",
|
||||
fallbackReason: intent.fallbackReason,
|
||||
};
|
||||
case "routine":
|
||||
return {
|
||||
kind: "routine",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: intent.qualifierParts,
|
||||
useRemoteMetadata: intent.confidence !== "low",
|
||||
fallbackReason: intent.fallbackReason,
|
||||
};
|
||||
case "column":
|
||||
case "alias_column":
|
||||
case "insert_column":
|
||||
case "update_column":
|
||||
case "join_condition":
|
||||
case "star":
|
||||
return {
|
||||
kind: "columns",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: intent.qualifierParts,
|
||||
targetSource,
|
||||
useRemoteMetadata: intent.confidence !== "low" && (!!targetSource || model.rowSources.length > 0),
|
||||
fallbackReason: intent.fallbackReason,
|
||||
};
|
||||
case "suppressed":
|
||||
return {
|
||||
kind: "local",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: [],
|
||||
useRemoteMetadata: false,
|
||||
fallbackReason: intent.fallbackReason ?? "suppressed",
|
||||
};
|
||||
case "keyword":
|
||||
return {
|
||||
kind: "keyword",
|
||||
prefix: intent.prefix,
|
||||
qualifierParts: intent.qualifierParts,
|
||||
useRemoteMetadata: false,
|
||||
fallbackReason: intent.fallbackReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function semanticContextKind(model: SqlSemanticModel): SqlCompletionContext["contextKind"] {
|
||||
switch (model.cursorIntent.kind) {
|
||||
case "table":
|
||||
case "schema":
|
||||
case "catalog":
|
||||
case "delete_target":
|
||||
return "table";
|
||||
case "routine":
|
||||
return "routine";
|
||||
case "alias_column":
|
||||
return "alias_column";
|
||||
case "insert_column":
|
||||
case "update_column":
|
||||
case "column":
|
||||
case "star":
|
||||
return "column";
|
||||
case "join_condition":
|
||||
return "join";
|
||||
case "keyword":
|
||||
case "suppressed":
|
||||
return "keyword";
|
||||
}
|
||||
}
|
||||
|
||||
function semanticMutationTarget(model: SqlSemanticModel): SqlSemanticRowSource | undefined {
|
||||
const targetId = model.cursorIntent.targetSourceId;
|
||||
return targetId ? model.rowSources.find((source) => source.id === targetId) : model.rowSources.find((source) => source.kind === "mutation_target");
|
||||
}
|
||||
|
||||
export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: SqlCompletionContext): SqlCompletionContext {
|
||||
if (model.cursorIntent.confidence === "low" || model.cursorIntent.kind === "suppressed") {
|
||||
return base;
|
||||
}
|
||||
|
||||
const scope = sqlSemanticCompletionScope(model);
|
||||
const qualifier = model.cursorIntent.qualifierParts.length > 0 ? model.cursorIntent.qualifierParts.join(".") : undefined;
|
||||
const referencedTables = sqlSemanticReferencedTables(model);
|
||||
const mutationTarget = semanticMutationTarget(model);
|
||||
const mutationSchema = mutationTarget?.qualifierParts[mutationTarget.qualifierParts.length - 1];
|
||||
const suggestTables = scope.kind === "table" || scope.kind === "schema" || scope.kind === "catalog";
|
||||
const suggestColumns = scope.kind === "columns";
|
||||
const suggestRoutines = scope.kind === "routine";
|
||||
const projectionAliases = sqlSemanticProjectionAliasColumns(model).map((column) => column.name);
|
||||
|
||||
return {
|
||||
...base,
|
||||
prefix: model.cursorIntent.prefix,
|
||||
qualifier,
|
||||
qualifierParts: model.cursorIntent.qualifierParts.length > 0 ? [...model.cursorIntent.qualifierParts] : undefined,
|
||||
suggestTables,
|
||||
suggestColumns,
|
||||
suggestKeywords: scope.kind === "keyword" || (!suggestTables && !suggestColumns && !suggestRoutines),
|
||||
suggestRoutines,
|
||||
suggestJoinConditions: model.cursorIntent.kind === "join_condition",
|
||||
exclusiveTableSuggestions: suggestTables,
|
||||
exclusiveColumnSuggestions: model.cursorIntent.kind === "alias_column" || model.cursorIntent.kind === "insert_column" || model.cursorIntent.kind === "update_column",
|
||||
exclusiveRoutineSuggestions: suggestRoutines,
|
||||
prioritizeSelectAliases: base.prioritizeSelectAliases || projectionAliases.length > 0,
|
||||
selectAliases: projectionAliases.length > 0 ? projectionAliases : base.selectAliases,
|
||||
referencedTables: referencedTables.length > 0 ? referencedTables : base.referencedTables,
|
||||
insertTable: model.cursorIntent.kind === "insert_column" ? mutationTarget?.name : base.insertTable,
|
||||
insertSchema: model.cursorIntent.kind === "insert_column" ? mutationSchema : base.insertSchema,
|
||||
updateTarget: model.cursorIntent.kind === "update_column" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.updateTarget,
|
||||
deleteTarget: model.cursorIntent.kind === "delete_target" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.deleteTarget,
|
||||
onStar: model.cursorIntent.kind === "star" || base.onStar,
|
||||
contextKind: semanticContextKind(model),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
|
||||
import { executableStatementRanges, isOraclePlSqlStatement, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
|
||||
export interface SqlSemanticDiagnostic {
|
||||
span: SqlTextSpan;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface SqlSemanticDiagnosticSchema {
|
||||
tables: SqlCompletionTable[];
|
||||
columnsByTable: Map<string, SqlCompletionColumn[]>;
|
||||
missingTables?: Set<string>;
|
||||
loadedColumnTables?: Set<string>;
|
||||
sql?: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticDiagnosticVisibleRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export function sqlSemanticDiagnosticRangesForViewport(sql: string, visibleRanges: readonly SqlSemanticDiagnosticVisibleRange[], databaseType?: DatabaseType): SqlTextRange[] {
|
||||
const statements = executableStatementRanges(sql, databaseType);
|
||||
if (statements.length === 0 || visibleRanges.length === 0) return [];
|
||||
|
||||
const selected: SqlTextRange[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const statement of statements) {
|
||||
if (isOraclePlSqlStatement(statement.sql, databaseType)) continue;
|
||||
if (!visibleRanges.some((visibleRange) => rangesIntersect(statement, visibleRange))) continue;
|
||||
const key = `${statement.from}:${statement.to}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
selected.push({ from: statement.from, to: statement.to, sql: sql.slice(statement.from, statement.to) });
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, schema: SqlSemanticDiagnosticSchema): SqlSemanticDiagnostic[] {
|
||||
const diagnostics: SqlSemanticDiagnostic[] = [];
|
||||
const tables = analysis.tables.filter((table) => table.name.trim());
|
||||
const knownTables = new Map<string, SqlTableReference>();
|
||||
const scopesById = scopesByIdMap(analysis.scopes);
|
||||
|
||||
for (const table of tables) {
|
||||
knownTables.set(normalizeName(table.name), table);
|
||||
if (table.alias) knownTables.set(normalizeName(table.alias), table);
|
||||
if (table.schema) knownTables.set(normalizeName(`${table.schema}.${table.name}`), table);
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
if (!schema.missingTables?.has(tableReferenceKey(table))) continue;
|
||||
diagnostics.push({
|
||||
span: trimSqlTextSpanWhitespace(schema.sql, table.span),
|
||||
message: `Unknown table ${displayTableName(table)}`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
for (const column of analysis.columns) {
|
||||
const table = resolveColumnTable(column, tables, knownTables, schema.sql, scopesById);
|
||||
if (!table) continue;
|
||||
if (schema.missingTables?.has(tableReferenceKey(table))) continue;
|
||||
|
||||
const columns = columnsForTable(table, schema.columnsByTable, schema.loadedColumnTables);
|
||||
if (!columns) continue;
|
||||
|
||||
const columnNames = new Set(columns.map((item) => normalizeName(item.name)));
|
||||
if (columnNames.has(normalizeName(column.name))) continue;
|
||||
|
||||
const displayName = column.qualifier ? `${column.qualifier}.${column.name}` : column.name;
|
||||
diagnostics.push({
|
||||
span: trimSqlTextSpanWhitespace(schema.sql, column.span),
|
||||
message: `Unknown column ${displayName}`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function trimSqlTextSpanWhitespace(sql: string | undefined, span: SqlTextSpan): SqlTextSpan {
|
||||
if (!sql) return span;
|
||||
const range = sqlTextSpanToOffsetRange(sql, span);
|
||||
if (!range) return span;
|
||||
|
||||
let from = range.from;
|
||||
let to = range.to;
|
||||
while (from < to && /\s/.test(sql[from] ?? "")) from += 1;
|
||||
while (to > from && /\s/.test(sql[to - 1] ?? "")) to -= 1;
|
||||
if (from === range.from && to === range.to) return span;
|
||||
|
||||
const start = offsetToSqlTextStartPosition(sql, from);
|
||||
const end = offsetToSqlTextEndPosition(sql, to);
|
||||
if (!start || !end) return span;
|
||||
return {
|
||||
start_line: start.line,
|
||||
start_column: start.column,
|
||||
end_line: end.line,
|
||||
end_column: Math.max(end.column, start.column),
|
||||
};
|
||||
}
|
||||
|
||||
function sqlTextSpanToOffsetRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null {
|
||||
if (!span.start_line || !span.start_column) return null;
|
||||
const from = sqlTextPositionToOffset(sql, span.start_line, span.start_column - 1);
|
||||
const to = sqlTextPositionToOffset(sql, Math.max(span.end_line, span.start_line), Math.max(span.end_column, span.start_column));
|
||||
if (from == null || to == null || to <= from) return null;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
function sqlTextPositionToOffset(sql: string, line: number, column: number): number | null {
|
||||
const lines = sql.split(/\r?\n/);
|
||||
if (line < 1 || line > lines.length) return null;
|
||||
let offset = 0;
|
||||
for (let index = 0; index < line - 1; index += 1) {
|
||||
offset += lines[index].length + 1;
|
||||
}
|
||||
return Math.min(offset + Math.max(column, 0), offset + lines[line - 1].length);
|
||||
}
|
||||
|
||||
function offsetToSqlTextStartPosition(sql: string, offset: number): { line: number; column: number } | null {
|
||||
const position = offsetToLineColumn(sql, offset);
|
||||
return position ? { line: position.line, column: position.column + 1 } : null;
|
||||
}
|
||||
|
||||
function offsetToSqlTextEndPosition(sql: string, offset: number): { line: number; column: number } | null {
|
||||
return offsetToLineColumn(sql, offset);
|
||||
}
|
||||
|
||||
function offsetToLineColumn(sql: string, offset: number): { line: number; column: number } | null {
|
||||
if (offset < 0 || offset > sql.length) return null;
|
||||
const lines = sql.split(/\r?\n/);
|
||||
let remaining = offset;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const lineLength = lines[index].length;
|
||||
if (remaining <= lineLength) {
|
||||
return { line: index + 1, column: remaining };
|
||||
}
|
||||
remaining -= lineLength + 1;
|
||||
}
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
return { line: lines.length, column: lastLine.length };
|
||||
}
|
||||
|
||||
export function buildSqlParserErrorDiagnostic(error: unknown, sql: string): SqlSemanticDiagnostic | null {
|
||||
const message = errorMessage(error);
|
||||
const location = /\bat Line:\s*(\d+),\s*Column:\s*(\d+)\b/i.exec(message);
|
||||
if (!location) return null;
|
||||
|
||||
const startLine = Number.parseInt(location[1], 10);
|
||||
const startColumn = Number.parseInt(location[2], 10);
|
||||
if (!Number.isFinite(startLine) || !Number.isFinite(startColumn) || startLine < 1 || startColumn < 1) return null;
|
||||
|
||||
const lineText = sql.split(/\r?\n/)[startLine - 1] ?? "";
|
||||
const startIndex = Math.max(startColumn - 1, 0);
|
||||
const token = /^[\w$]+/.exec(lineText.slice(startIndex))?.[0];
|
||||
const tokenLength = Math.max(token?.length ?? 1, 1);
|
||||
|
||||
return {
|
||||
span: {
|
||||
start_line: startLine,
|
||||
start_column: startColumn,
|
||||
end_line: startLine,
|
||||
end_column: startColumn + tokenLength - 1,
|
||||
},
|
||||
message,
|
||||
severity: "error",
|
||||
};
|
||||
}
|
||||
|
||||
export function areSqlSemanticDiagnosticsEqual(left: readonly SqlSemanticDiagnostic[], right: readonly SqlSemanticDiagnostic[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((item, index) => {
|
||||
const other = right[index];
|
||||
return !!other && item.message === other.message && item.severity === other.severity && item.span.start_line === other.span.start_line && item.span.start_column === other.span.start_column && item.span.end_line === other.span.end_line && item.span.end_column === other.span.end_column;
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
|
||||
if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
|
||||
const context = getSqlCompletionContext(sql, cursor);
|
||||
if (context.exclusiveColumnSuggestions) return false;
|
||||
if (context.qualifier) return false;
|
||||
if ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
|
||||
if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
|
||||
const context = getSqlCompletionContext(sql, cursor);
|
||||
return context.exclusiveColumnSuggestions || !!context.qualifier || ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor));
|
||||
}
|
||||
|
||||
function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReference[], knownTables: Map<string, SqlTableReference>, sql?: string, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference | null {
|
||||
const candidateTables = candidateTablesForColumn(tables, column, sql, scopesById);
|
||||
if (column.qualifier) {
|
||||
return tableLookupFor(candidateTables).get(normalizeName(column.qualifier)) ?? (sql ? null : (knownTables.get(normalizeName(column.qualifier)) ?? null));
|
||||
}
|
||||
if (candidateTables.length !== 1) return null;
|
||||
return candidateTables[0];
|
||||
}
|
||||
|
||||
function candidateTablesForColumn(tables: SqlTableReference[], column: SqlColumnReference, sql?: string, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference[] {
|
||||
const scoped = tablesInVisibleScopes(tables, column, scopesById);
|
||||
if (scoped) return scoped;
|
||||
return sql ? tablesInSameStatement(tables, column, sql) : tables;
|
||||
}
|
||||
|
||||
function tablesInVisibleScopes(tables: SqlTableReference[], column: SqlColumnReference, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference[] | null {
|
||||
if (column.scope_id == null || !scopesById || scopesById.size === 0) return null;
|
||||
if (!column.qualifier) {
|
||||
const currentScopeTables = tables.filter((table) => table.scope_id === column.scope_id);
|
||||
if (currentScopeTables.length > 0) return currentScopeTables;
|
||||
}
|
||||
const visibleScopeIds = scopeAndParents(column.scope_id, scopesById);
|
||||
if (visibleScopeIds.size === 0) return null;
|
||||
return tables.filter((table) => table.scope_id != null && visibleScopeIds.has(table.scope_id));
|
||||
}
|
||||
|
||||
function scopeAndParents(scopeId: number, scopesById: Map<number, SqlReferenceScope>): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
let current: number | undefined = scopeId;
|
||||
while (current != null && !ids.has(current)) {
|
||||
ids.add(current);
|
||||
const scope = scopesById.get(current);
|
||||
current = scope?.parent_id ?? undefined;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function tableLookupFor(tables: SqlTableReference[]): Map<string, SqlTableReference> {
|
||||
const lookup = new Map<string, SqlTableReference>();
|
||||
for (const table of tables) {
|
||||
lookup.set(normalizeName(table.name), table);
|
||||
if (table.alias) lookup.set(normalizeName(table.alias), table);
|
||||
if (table.schema) lookup.set(normalizeName(`${table.schema}.${table.name}`), table);
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function tablesInSameStatement(tables: SqlTableReference[], column: SqlColumnReference, sql: string): SqlTableReference[] {
|
||||
const columnOffset = spanStartOffset(sql, column.span);
|
||||
if (columnOffset == null) return tables;
|
||||
return tables.filter((table) => {
|
||||
const tableOffset = spanStartOffset(sql, table.span);
|
||||
return tableOffset != null && statementIndexAt(sql, tableOffset) === statementIndexAt(sql, columnOffset);
|
||||
});
|
||||
}
|
||||
|
||||
function scopesByIdMap(scopes: readonly SqlReferenceScope[] | undefined): Map<number, SqlReferenceScope> {
|
||||
const map = new Map<number, SqlReferenceScope>();
|
||||
for (const scope of scopes ?? []) map.set(scope.id, scope);
|
||||
return map;
|
||||
}
|
||||
|
||||
function spanStartOffset(sql: string, span: SqlTextSpan): number | null {
|
||||
if (!span.start_line || !span.start_column) return null;
|
||||
const lines = sql.split(/\r?\n/);
|
||||
const lineIndex = span.start_line - 1;
|
||||
if (lineIndex < 0 || lineIndex >= lines.length) return null;
|
||||
let offset = 0;
|
||||
for (let index = 0; index < lineIndex; index++) offset += lines[index].length + 1;
|
||||
return Math.min(offset + span.start_column - 1, offset + lines[lineIndex].length);
|
||||
}
|
||||
|
||||
function statementIndexAt(sql: string, offset: number): number {
|
||||
let statementIndex = 0;
|
||||
for (let index = 0; index < Math.min(offset, sql.length); index++) {
|
||||
if (sql[index] === ";") statementIndex++;
|
||||
}
|
||||
return statementIndex;
|
||||
}
|
||||
|
||||
function columnsForTable(table: SqlTableReference, columnsByTable: Map<string, SqlCompletionColumn[]>, loadedColumnTables?: Set<string>): SqlCompletionColumn[] | null {
|
||||
const inlineColumns = (table as SqlTableReference & { columns?: string[] }).columns;
|
||||
if (inlineColumns && inlineColumns.length > 0) {
|
||||
return inlineColumns.map((name) => ({
|
||||
name,
|
||||
table: table.name,
|
||||
schema: table.schema ?? undefined,
|
||||
}));
|
||||
}
|
||||
const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name, ...keysWithTableName(columnsByTable, table.name)];
|
||||
for (const key of keys) {
|
||||
const normalizedKey = normalizeName(key);
|
||||
const columns = columnsByTable.get(key) ?? columnsByTable.get(normalizedKey);
|
||||
if (!columns) continue;
|
||||
if (columns.length > 0 || loadedColumnTables?.has(normalizedKey)) return columns;
|
||||
}
|
||||
if (loadedColumnTables?.has(tableReferenceKey(table))) return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
function keysWithTableName(columnsByTable: Map<string, SqlCompletionColumn[]>, tableName: string): string[] {
|
||||
const suffix = `.${normalizeName(tableName)}`;
|
||||
return [...columnsByTable.keys()].filter((key) => normalizeName(key).endsWith(suffix));
|
||||
}
|
||||
|
||||
function rangesIntersect(left: SqlSemanticDiagnosticVisibleRange, right: SqlSemanticDiagnosticVisibleRange): boolean {
|
||||
return left.from < right.to && right.from < left.to;
|
||||
}
|
||||
|
||||
export function tableReferenceKey(table: Pick<SqlTableReference, "name" | "schema">): string {
|
||||
return normalizeName(table.schema ? `${table.schema}.${table.name}` : table.name);
|
||||
}
|
||||
|
||||
function displayTableName(table: SqlTableReference): string {
|
||||
return table.schema ? `${table.schema}.${table.name}` : table.name;
|
||||
}
|
||||
|
||||
function isCursorAfterTableTrigger(sql: string, cursor: number): boolean {
|
||||
const beforeCursor = sql.slice(0, cursor).trimEnd();
|
||||
return /\b(from|join|update|into|table)(?:\s+[\w$`"'[\].]*)?$/i.test(beforeCursor);
|
||||
}
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
let normalized = value;
|
||||
while (normalized && `"'\`[]`.includes(normalized[0])) normalized = normalized.slice(1);
|
||||
while (normalized && `"'\`[]`.includes(normalized[normalized.length - 1])) normalized = normalized.slice(0, -1);
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export interface SqlSemanticProjectionAliasVisibility {
|
||||
where: boolean;
|
||||
groupBy: boolean;
|
||||
having: boolean;
|
||||
orderBy: boolean;
|
||||
}
|
||||
|
||||
export interface SqlSemanticDialectAdapter {
|
||||
id: string;
|
||||
identifierQuotes: Array<{ open: string; close: string }>;
|
||||
supportsAsForTableAlias: boolean;
|
||||
projectionAliasVisibility: SqlSemanticProjectionAliasVisibility;
|
||||
normalizeIdentifier(identifier: string, quoted?: boolean): string;
|
||||
quoteIdentifier(identifier: string): string;
|
||||
qualifierRole(parts: string[], context: "table" | "column" | "routine"): "catalog" | "schema" | "table" | "package" | "unknown";
|
||||
}
|
||||
|
||||
function quoteWith(identifier: string, quote: string): string {
|
||||
return `${quote}${identifier.replaceAll(quote, quote + quote)}${quote}`;
|
||||
}
|
||||
|
||||
function defaultNormalize(identifier: string): string {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function lowerUnquoted(identifier: string, quoted?: boolean): string {
|
||||
return quoted ? identifier : identifier.toLowerCase();
|
||||
}
|
||||
|
||||
function upperUnquoted(identifier: string, quoted?: boolean): string {
|
||||
return quoted ? identifier : identifier.toUpperCase();
|
||||
}
|
||||
|
||||
const defaultProjectionAliasVisibility: SqlSemanticProjectionAliasVisibility = {
|
||||
where: false,
|
||||
groupBy: false,
|
||||
having: false,
|
||||
orderBy: true,
|
||||
};
|
||||
|
||||
function roleForGenericQualifier(parts: string[], context: "table" | "column" | "routine"): "catalog" | "schema" | "table" | "package" | "unknown" {
|
||||
if (parts.length <= 0) return "unknown";
|
||||
if (context === "column") return parts.length >= 2 ? "table" : "table";
|
||||
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
|
||||
if (parts.length >= 2) return "schema";
|
||||
return "schema";
|
||||
}
|
||||
|
||||
export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> = {
|
||||
generic: {
|
||||
id: "generic",
|
||||
identifierQuotes: [{ open: '"', close: '"' }],
|
||||
supportsAsForTableAlias: true,
|
||||
projectionAliasVisibility: defaultProjectionAliasVisibility,
|
||||
normalizeIdentifier: defaultNormalize,
|
||||
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
|
||||
qualifierRole: roleForGenericQualifier,
|
||||
},
|
||||
postgres: {
|
||||
id: "postgres",
|
||||
identifierQuotes: [{ open: '"', close: '"' }],
|
||||
supportsAsForTableAlias: true,
|
||||
projectionAliasVisibility: defaultProjectionAliasVisibility,
|
||||
normalizeIdentifier: lowerUnquoted,
|
||||
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
|
||||
qualifierRole: roleForGenericQualifier,
|
||||
},
|
||||
mysql: {
|
||||
id: "mysql",
|
||||
identifierQuotes: [
|
||||
{ open: "`", close: "`" },
|
||||
{ open: '"', close: '"' },
|
||||
],
|
||||
supportsAsForTableAlias: true,
|
||||
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
|
||||
normalizeIdentifier: defaultNormalize,
|
||||
quoteIdentifier: (identifier) => quoteWith(identifier, "`"),
|
||||
qualifierRole(parts, context) {
|
||||
if (context === "column") return parts.length >= 2 ? "table" : "table";
|
||||
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
|
||||
return parts.length >= 1 ? "schema" : "unknown";
|
||||
},
|
||||
},
|
||||
sqlserver: {
|
||||
id: "sqlserver",
|
||||
identifierQuotes: [
|
||||
{ open: "[", close: "]" },
|
||||
{ open: '"', close: '"' },
|
||||
],
|
||||
supportsAsForTableAlias: true,
|
||||
projectionAliasVisibility: defaultProjectionAliasVisibility,
|
||||
normalizeIdentifier: defaultNormalize,
|
||||
quoteIdentifier: (identifier) => `[${identifier.replaceAll("]", "]]")}]`,
|
||||
qualifierRole(parts, context) {
|
||||
if (context === "column") return parts.length >= 2 ? "table" : "table";
|
||||
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
|
||||
if (parts.length >= 2) return "schema";
|
||||
return "schema";
|
||||
},
|
||||
},
|
||||
sqlite: {
|
||||
id: "sqlite",
|
||||
identifierQuotes: [
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "`", close: "`" },
|
||||
{ open: "[", close: "]" },
|
||||
],
|
||||
supportsAsForTableAlias: true,
|
||||
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
|
||||
normalizeIdentifier: defaultNormalize,
|
||||
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
|
||||
qualifierRole: roleForGenericQualifier,
|
||||
},
|
||||
duckdb: {
|
||||
id: "duckdb",
|
||||
identifierQuotes: [{ open: '"', close: '"' }],
|
||||
supportsAsForTableAlias: true,
|
||||
projectionAliasVisibility: defaultProjectionAliasVisibility,
|
||||
normalizeIdentifier: defaultNormalize,
|
||||
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
|
||||
qualifierRole: roleForGenericQualifier,
|
||||
},
|
||||
oracle: {
|
||||
id: "oracle",
|
||||
identifierQuotes: [{ open: '"', close: '"' }],
|
||||
supportsAsForTableAlias: false,
|
||||
projectionAliasVisibility: defaultProjectionAliasVisibility,
|
||||
normalizeIdentifier: upperUnquoted,
|
||||
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
|
||||
qualifierRole(parts, context) {
|
||||
if (context === "column") return parts.length >= 2 ? "table" : "table";
|
||||
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
|
||||
return parts.length >= 1 ? "schema" : "unknown";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" }): SqlSemanticDialectAdapter {
|
||||
if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect];
|
||||
switch (options.databaseType) {
|
||||
case "postgres":
|
||||
case "redshift":
|
||||
case "opengauss":
|
||||
case "gaussdb":
|
||||
case "highgo":
|
||||
return SQL_SEMANTIC_DIALECTS.postgres;
|
||||
case "mysql":
|
||||
case "doris":
|
||||
case "starrocks":
|
||||
return SQL_SEMANTIC_DIALECTS.mysql;
|
||||
case "sqlserver":
|
||||
return SQL_SEMANTIC_DIALECTS.sqlserver;
|
||||
case "sqlite":
|
||||
case "rqlite":
|
||||
case "turso":
|
||||
return SQL_SEMANTIC_DIALECTS.sqlite;
|
||||
case "duckdb":
|
||||
return SQL_SEMANTIC_DIALECTS.duckdb;
|
||||
case "oracle":
|
||||
case "oceanbase-oracle":
|
||||
case "dameng":
|
||||
case "kingbase":
|
||||
case "vastbase":
|
||||
case "goldendb":
|
||||
case "yashandb":
|
||||
return SQL_SEMANTIC_DIALECTS.oracle;
|
||||
default:
|
||||
return SQL_SEMANTIC_DIALECTS.generic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { SqlSemanticCompletionScopeKind } from "@/lib/sql/semantic/completion";
|
||||
import type { SqlSemanticConfidence, SqlSemanticCursorKind, SqlSemanticStatementKind } from "@/lib/sql/semantic/types";
|
||||
|
||||
export interface SqlSemanticFixture {
|
||||
name: string;
|
||||
sql: string;
|
||||
databaseType?: "postgres" | "mysql" | "sqlserver" | "sqlite" | "duckdb" | "oracle";
|
||||
expected: {
|
||||
statementKind: SqlSemanticStatementKind;
|
||||
cursorKind: SqlSemanticCursorKind;
|
||||
completionScope: SqlSemanticCompletionScopeKind;
|
||||
prefix: string;
|
||||
qualifierParts?: string[];
|
||||
confidence: SqlSemanticConfidence;
|
||||
rowSources?: Array<{ name: string; alias?: string; kind?: string; columns?: string[] }>;
|
||||
completionLabels?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function sqlFixtureCursor(input: string): { sql: string; cursor: number } {
|
||||
const cursor = input.indexOf("|");
|
||||
if (cursor < 0) {
|
||||
throw new Error("SQL semantic fixture is missing a | cursor marker");
|
||||
}
|
||||
return {
|
||||
sql: input.slice(0, cursor) + input.slice(cursor + 1),
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export const SQL_SEMANTIC_BASELINE_FIXTURES: SqlSemanticFixture[] = [
|
||||
{
|
||||
name: "select alias column",
|
||||
sql: "SELECT * FROM users u JOIN orders o ON o.user_id = u.id WHERE u.|",
|
||||
expected: {
|
||||
statementKind: "select",
|
||||
cursorKind: "alias_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: ["u"],
|
||||
confidence: "high",
|
||||
rowSources: [
|
||||
{ name: "users", alias: "u", kind: "table" },
|
||||
{ name: "orders", alias: "o", kind: "table" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with cte table source",
|
||||
sql: "WITH recent_orders(id, total) AS (SELECT id, total FROM orders) SELECT * FROM recent_orders ro WHERE ro.|",
|
||||
expected: {
|
||||
statementKind: "select",
|
||||
cursorKind: "alias_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: ["ro"],
|
||||
confidence: "high",
|
||||
rowSources: [{ name: "recent_orders", alias: "ro", kind: "cte", columns: ["id", "total"] }],
|
||||
completionLabels: ["id", "total"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subquery projection columns",
|
||||
sql: "SELECT * FROM (SELECT id, name AS user_name FROM users) sq WHERE sq.|",
|
||||
expected: {
|
||||
statementKind: "select",
|
||||
cursorKind: "alias_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: ["sq"],
|
||||
confidence: "high",
|
||||
rowSources: [{ name: "sq", alias: "sq", kind: "subquery", columns: ["id", "user_name"] }],
|
||||
completionLabels: ["id", "user_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "insert target columns",
|
||||
sql: "INSERT INTO dbo.Users (|",
|
||||
databaseType: "sqlserver",
|
||||
expected: {
|
||||
statementKind: "insert",
|
||||
cursorKind: "insert_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: [],
|
||||
confidence: "medium",
|
||||
rowSources: [{ name: "Users", kind: "mutation_target" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update set columns",
|
||||
sql: "UPDATE dbo.Users SET |",
|
||||
databaseType: "sqlserver",
|
||||
expected: {
|
||||
statementKind: "update",
|
||||
cursorKind: "update_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: [],
|
||||
confidence: "medium",
|
||||
rowSources: [{ name: "Users", kind: "mutation_target" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "call routine",
|
||||
sql: "CALL app.refresh_|",
|
||||
databaseType: "postgres",
|
||||
expected: {
|
||||
statementKind: "call",
|
||||
cursorKind: "routine",
|
||||
completionScope: "routine",
|
||||
prefix: "refresh_",
|
||||
qualifierParts: ["app"],
|
||||
confidence: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete target",
|
||||
sql: "DELETE FROM audit_events ae WHERE ae.|",
|
||||
expected: {
|
||||
statementKind: "delete",
|
||||
cursorKind: "alias_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: ["ae"],
|
||||
confidence: "high",
|
||||
rowSources: [{ name: "audit_events", alias: "ae", kind: "mutation_target" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "table function alias",
|
||||
sql: "SELECT * FROM JSON_TABLE(payload, '$' COLUMNS(id INT PATH '$.id')) jt WHERE jt.|",
|
||||
databaseType: "oracle",
|
||||
expected: {
|
||||
statementKind: "select",
|
||||
cursorKind: "alias_column",
|
||||
completionScope: "columns",
|
||||
prefix: "",
|
||||
qualifierParts: ["JT"],
|
||||
confidence: "high",
|
||||
rowSources: [{ name: "JT", alias: "JT", kind: "table_function" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema qualified table",
|
||||
sql: "SELECT * FROM reporting.|",
|
||||
databaseType: "postgres",
|
||||
expected: {
|
||||
statementKind: "select",
|
||||
cursorKind: "table",
|
||||
completionScope: "table",
|
||||
prefix: "",
|
||||
qualifierParts: ["reporting"],
|
||||
confidence: "medium",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "comment suppressed",
|
||||
sql: "SELECT * FROM users -- u.|",
|
||||
expected: {
|
||||
statementKind: "select",
|
||||
cursorKind: "suppressed",
|
||||
completionScope: "local",
|
||||
prefix: "",
|
||||
confidence: "high",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,580 @@
|
||||
import { sqlSemanticDialectFor, type SqlSemanticDialectAdapter } from "@/lib/sql/semantic/dialect";
|
||||
import { findActiveSqlStatementSpan, isSuppressedSqlSemanticContext, tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
|
||||
import type {
|
||||
SqlSemanticBuildOptions,
|
||||
SqlSemanticClauseSpans,
|
||||
SqlSemanticCursorIntent,
|
||||
SqlSemanticIdentifierPart,
|
||||
SqlSemanticModel,
|
||||
SqlSemanticProjection,
|
||||
SqlSemanticQualifiedName,
|
||||
SqlSemanticRowSource,
|
||||
SqlSemanticScope,
|
||||
SqlSemanticSpan,
|
||||
SqlSemanticStatement,
|
||||
SqlSemanticStatementKind,
|
||||
SqlSemanticToken,
|
||||
} from "@/lib/sql/semantic/types";
|
||||
|
||||
const TABLE_INTRODUCERS = new Set(["from", "join", "update", "into", "using", "apply"]);
|
||||
const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]);
|
||||
const CLAUSE_BOUNDARIES = new Set(["where", "group", "having", "order", "limit", "offset", "union", "intersect", "except", "on", "set", "values", "returning"]);
|
||||
const ALIAS_BLACKLIST = new Set([...CLAUSE_BOUNDARIES, "join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from"]);
|
||||
|
||||
interface ParseState {
|
||||
dialect: SqlSemanticDialectAdapter;
|
||||
tokens: SqlSemanticToken[];
|
||||
statement: SqlSemanticStatement;
|
||||
cteSources: SqlSemanticRowSource[];
|
||||
}
|
||||
|
||||
interface TrailingIdentifier {
|
||||
prefix: string;
|
||||
replacementRange: SqlSemanticSpan;
|
||||
qualifierParts: string[];
|
||||
}
|
||||
|
||||
function significantTokens(tokens: readonly SqlSemanticToken[]): SqlSemanticToken[] {
|
||||
return tokens.filter((item) => item.kind !== "comment");
|
||||
}
|
||||
|
||||
function tokenTextAt(sql: string, span: SqlSemanticSpan): string {
|
||||
return sql.slice(span.start, span.end);
|
||||
}
|
||||
|
||||
function firstWord(tokens: readonly SqlSemanticToken[]): string {
|
||||
return tokens.find((item) => item.kind === "word")?.normalized ?? "";
|
||||
}
|
||||
|
||||
function statementKind(tokens: readonly SqlSemanticToken[]): SqlSemanticStatementKind {
|
||||
const word = firstWord(tokens);
|
||||
if (word === "with" || word === "select") return "select";
|
||||
if (word === "insert") return "insert";
|
||||
if (word === "update") return "update";
|
||||
if (word === "delete") return "delete";
|
||||
if (word === "call" || word === "exec" || word === "execute") return "call";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function identifierPart(tokenValue: SqlSemanticToken, dialect: SqlSemanticDialectAdapter): SqlSemanticIdentifierPart {
|
||||
const quoted = tokenValue.kind === "quoted_identifier";
|
||||
const raw = unquoteSqlSemanticIdentifier(tokenValue);
|
||||
return {
|
||||
raw: tokenValue.text,
|
||||
name: dialect.normalizeIdentifier(raw, quoted),
|
||||
span: tokenValue.span,
|
||||
quote: tokenValue.quote,
|
||||
};
|
||||
}
|
||||
|
||||
function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: number, dialect: SqlSemanticDialectAdapter): { name: SqlSemanticQualifiedName; nextIndex: number } | null {
|
||||
const parts: SqlSemanticIdentifierPart[] = [];
|
||||
let index = startIndex;
|
||||
while (index < tokens.length) {
|
||||
const current = tokens[index];
|
||||
if (!tokenIsIdentifier(current)) break;
|
||||
parts.push(identifierPart(current, dialect));
|
||||
if (tokens[index + 1]?.text !== ".") {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return {
|
||||
name: {
|
||||
parts,
|
||||
span: { start: parts[0]?.span.start ?? tokens[startIndex]?.span.start ?? 0, end: parts[parts.length - 1]?.span.end ?? tokens[startIndex]?.span.end ?? 0 },
|
||||
},
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceNameFromQualifiedName(name: SqlSemanticQualifiedName): { name: string; qualifierParts: string[] } {
|
||||
const parts = name.parts.map((part) => part.name);
|
||||
return {
|
||||
name: parts[parts.length - 1] ?? "",
|
||||
qualifierParts: parts.slice(0, -1),
|
||||
};
|
||||
}
|
||||
|
||||
function findMatchingParenToken(tokens: readonly SqlSemanticToken[], openIndex: number): number {
|
||||
if (tokens[openIndex]?.text !== "(") return -1;
|
||||
const startDepth = tokens[openIndex]?.depth ?? 0;
|
||||
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
||||
const item = tokens[index];
|
||||
if (item?.text === ")" && item.depth === startDepth) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function splitTopLevelByComma(tokens: readonly SqlSemanticToken[]): SqlSemanticToken[][] {
|
||||
const groups: SqlSemanticToken[][] = [];
|
||||
let current: SqlSemanticToken[] = [];
|
||||
const baseDepth = tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY);
|
||||
for (const item of tokens) {
|
||||
if (item.text === "," && item.depth === baseDepth) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
} else {
|
||||
current.push(item);
|
||||
}
|
||||
}
|
||||
if (current.length > 0) groups.push(current);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function projectionNameFromTokens(tokens: readonly SqlSemanticToken[], dialect: SqlSemanticDialectAdapter): SqlSemanticProjection | null {
|
||||
const useful = tokens.filter((item) => item.kind !== "comment");
|
||||
if (useful.length === 0) return null;
|
||||
let asIndex = -1;
|
||||
for (let index = useful.length - 1; index >= 0; index -= 1) {
|
||||
if (useful[index]?.kind === "word" && useful[index]?.normalized === "as") {
|
||||
asIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const aliasToken = asIndex >= 0 ? useful[asIndex + 1] : undefined;
|
||||
if (tokenIsIdentifier(aliasToken)) {
|
||||
const name = identifierPart(aliasToken, dialect).name;
|
||||
return {
|
||||
name,
|
||||
alias: name,
|
||||
aliasSpan: aliasToken.span,
|
||||
sourceExpression: useful.map((item) => item.text).join(" "),
|
||||
span: { start: useful[0]?.span.start ?? 0, end: useful[useful.length - 1]?.span.end ?? 0 },
|
||||
};
|
||||
}
|
||||
const lastIdentifier = [...useful].reverse().find(tokenIsIdentifier);
|
||||
if (!lastIdentifier) return null;
|
||||
const name = identifierPart(lastIdentifier, dialect).name;
|
||||
return {
|
||||
name,
|
||||
sourceExpression: useful.map((item) => item.text).join(" "),
|
||||
span: { start: useful[0]?.span.start ?? 0, end: useful[useful.length - 1]?.span.end ?? 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelectProjections(tokens: readonly SqlSemanticToken[], dialect: SqlSemanticDialectAdapter): SqlSemanticProjection[] {
|
||||
const baseDepth = tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY);
|
||||
const selectDepth = Number.isFinite(baseDepth) ? baseDepth : 0;
|
||||
const selectIndex = tokens.findIndex((item) => item.depth === selectDepth && item.kind === "word" && item.normalized === "select");
|
||||
if (selectIndex < 0) return [];
|
||||
let fromIndex = tokens.findIndex((item, index) => index > selectIndex && item.depth === selectDepth && item.kind === "word" && item.normalized === "from");
|
||||
if (fromIndex < 0) fromIndex = tokens.length;
|
||||
const projectionTokens = tokens.slice(selectIndex + 1, fromIndex);
|
||||
return splitTopLevelByComma(projectionTokens)
|
||||
.map((group) => projectionNameFromTokens(group, dialect))
|
||||
.filter((projection): projection is SqlSemanticProjection => projection != null && projection.name !== "*");
|
||||
}
|
||||
|
||||
function parseCteSources(state: ParseState): SqlSemanticRowSource[] {
|
||||
const tokens = state.tokens;
|
||||
const first = tokens.findIndex((item) => item.kind === "word" && item.normalized === "with");
|
||||
if (first < 0) return [];
|
||||
const sources: SqlSemanticRowSource[] = [];
|
||||
let index = first + 1;
|
||||
if (tokens[index]?.normalized === "recursive") index += 1;
|
||||
|
||||
while (index < tokens.length) {
|
||||
while (tokens[index]?.text === ",") index += 1;
|
||||
const nameToken = tokens[index];
|
||||
if (!tokenIsIdentifier(nameToken)) break;
|
||||
const namePart = identifierPart(nameToken, state.dialect);
|
||||
index += 1;
|
||||
|
||||
const explicitColumns: string[] = [];
|
||||
if (tokens[index]?.text === "(") {
|
||||
const close = findMatchingParenToken(tokens, index);
|
||||
if (close > index) {
|
||||
for (const part of splitTopLevelByComma(tokens.slice(index + 1, close))) {
|
||||
const identifier = part.find(tokenIsIdentifier);
|
||||
if (identifier) explicitColumns.push(identifierPart(identifier, state.dialect).name);
|
||||
}
|
||||
index = close + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens[index]?.normalized === "as") index += 1;
|
||||
if (tokens[index]?.text !== "(") break;
|
||||
const bodyOpen = index;
|
||||
const bodyClose = findMatchingParenToken(tokens, bodyOpen);
|
||||
const safeBodyClose = bodyClose < 0 ? tokens.length - 1 : bodyClose;
|
||||
const bodyTokens = tokens.slice(bodyOpen + 1, safeBodyClose);
|
||||
const bodyColumns = explicitColumns.length > 0 ? explicitColumns : parseSelectProjections(bodyTokens, state.dialect).map((projection) => projection.name);
|
||||
sources.push({
|
||||
id: `cte:${namePart.name}:${sources.length}`,
|
||||
kind: "cte",
|
||||
name: namePart.name,
|
||||
qualifierParts: [],
|
||||
sourceSpan: { start: nameToken.span.start, end: tokens[safeBodyClose]?.span.end ?? nameToken.span.end },
|
||||
columns: bodyColumns,
|
||||
});
|
||||
index = safeBodyClose + 1;
|
||||
|
||||
if (tokens[index]?.text !== ",") break;
|
||||
index += 1;
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function aliasAfter(tokens: readonly SqlSemanticToken[], index: number, dialect: SqlSemanticDialectAdapter): { alias?: string; aliasSpan?: SqlSemanticSpan; nextIndex: number } {
|
||||
let cursor = index;
|
||||
if (tokens[cursor]?.kind === "word" && tokens[cursor]?.normalized === "as") cursor += 1;
|
||||
const aliasToken = tokens[cursor];
|
||||
if (tokenIsIdentifier(aliasToken)) {
|
||||
const alias = identifierPart(aliasToken, dialect).name;
|
||||
if (!ALIAS_BLACKLIST.has(alias.toLowerCase())) {
|
||||
return { alias, aliasSpan: aliasToken.span, nextIndex: cursor + 1 };
|
||||
}
|
||||
}
|
||||
return { nextIndex: index };
|
||||
}
|
||||
|
||||
function parseSubquerySource(state: ParseState, openIndex: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
||||
const close = findMatchingParenToken(state.tokens, openIndex);
|
||||
if (close < 0) return null;
|
||||
const alias = aliasAfter(state.tokens, close + 1, state.dialect);
|
||||
if (!alias.alias) return null;
|
||||
const bodyTokens = state.tokens.slice(openIndex + 1, close);
|
||||
const columns = parseSelectProjections(bodyTokens, state.dialect).map((projection) => projection.name);
|
||||
return {
|
||||
source: {
|
||||
id: `${introducer}:subquery:${sourceIndex}`,
|
||||
kind: "subquery",
|
||||
name: alias.alias,
|
||||
qualifierParts: [],
|
||||
alias: alias.alias,
|
||||
aliasSpan: alias.aliasSpan,
|
||||
sourceSpan: { start: state.tokens[openIndex]?.span.start ?? 0, end: alias.aliasSpan?.end ?? state.tokens[close]?.span.end ?? 0 },
|
||||
columns,
|
||||
},
|
||||
nextIndex: alias.nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTableFunctionSource(state: ParseState, nameIndex: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
||||
const nameToken = state.tokens[nameIndex];
|
||||
if (!nameToken || nameToken.kind !== "word" || !["table", "xmltable", "json_table", "the", "read_csv", "read_parquet", "read_json", "unnest"].includes(nameToken.normalized)) return null;
|
||||
if (state.tokens[nameIndex + 1]?.text !== "(") return null;
|
||||
const close = findMatchingParenToken(state.tokens, nameIndex + 1);
|
||||
const safeClose = close < 0 ? nameIndex + 1 : close;
|
||||
const alias = aliasAfter(state.tokens, safeClose + 1, state.dialect);
|
||||
const sourceName = alias.alias ?? nameToken.normalized;
|
||||
return {
|
||||
source: {
|
||||
id: `${introducer}:table_function:${sourceIndex}`,
|
||||
kind: "table_function",
|
||||
name: sourceName,
|
||||
qualifierParts: [],
|
||||
alias: alias.alias,
|
||||
aliasSpan: alias.aliasSpan,
|
||||
sourceSpan: { start: nameToken.span.start, end: alias.aliasSpan?.end ?? state.tokens[safeClose]?.span.end ?? nameToken.span.end },
|
||||
unresolved: close < 0,
|
||||
},
|
||||
nextIndex: alias.nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTableSource(state: ParseState, nameIndex: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
||||
const qualified = readQualifiedName(state.tokens, nameIndex, state.dialect);
|
||||
if (!qualified) return null;
|
||||
const { name, qualifierParts } = sourceNameFromQualifiedName(qualified.name);
|
||||
const alias = aliasAfter(state.tokens, qualified.nextIndex, state.dialect);
|
||||
const cte = state.cteSources.find((source) => source.name.toLowerCase() === name.toLowerCase());
|
||||
const kind = cte ? "cte" : introducer === "update" || introducer === "into" || (state.statement.kind === "delete" && introducer === "from") ? "mutation_target" : "table";
|
||||
const source: SqlSemanticRowSource = {
|
||||
id: `${introducer}:${name}:${sourceIndex}`,
|
||||
kind,
|
||||
name,
|
||||
qualifiedName: qualified.name,
|
||||
qualifierParts,
|
||||
alias: alias.alias,
|
||||
aliasSpan: alias.aliasSpan,
|
||||
sourceSpan: { start: qualified.name.span.start, end: alias.aliasSpan?.end ?? qualified.name.span.end },
|
||||
columns: cte?.columns,
|
||||
metadataTarget: {
|
||||
schema: qualifierParts[qualifierParts.length - 1],
|
||||
table: name,
|
||||
},
|
||||
};
|
||||
return { source, nextIndex: alias.nextIndex };
|
||||
}
|
||||
|
||||
function parseRowSources(state: ParseState): SqlSemanticRowSource[] {
|
||||
const sources: SqlSemanticRowSource[] = [...state.cteSources];
|
||||
const rootDepth = state.tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY);
|
||||
const sourceDepth = Number.isFinite(rootDepth) ? rootDepth : 0;
|
||||
for (let index = 0; index < state.tokens.length; index += 1) {
|
||||
const item = state.tokens[index];
|
||||
if (!item || item.kind !== "word") continue;
|
||||
if (item.depth !== sourceDepth) continue;
|
||||
const normalized = item.normalized;
|
||||
if (!TABLE_INTRODUCERS.has(normalized)) continue;
|
||||
if (JOIN_MODIFIERS.has(normalized)) continue;
|
||||
let target = index + 1;
|
||||
while (JOIN_MODIFIERS.has(state.tokens[target]?.normalized ?? "")) target += 1;
|
||||
if (state.tokens[target]?.text === "(") {
|
||||
const subquery = parseSubquerySource(state, target, normalized, sources.length);
|
||||
if (subquery) {
|
||||
sources.push(subquery.source);
|
||||
index = subquery.nextIndex - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const tableFunction = parseTableFunctionSource(state, target, normalized, sources.length);
|
||||
if (tableFunction) {
|
||||
sources.push(tableFunction.source);
|
||||
index = tableFunction.nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
const table = parseTableSource(state, target, normalized, sources.length);
|
||||
if (table) {
|
||||
sources.push(table.source);
|
||||
index = table.nextIndex - 1;
|
||||
}
|
||||
}
|
||||
return dedupeSources(sources);
|
||||
}
|
||||
|
||||
function dedupeSources(sources: SqlSemanticRowSource[]): SqlSemanticRowSource[] {
|
||||
const seen = new Set<string>();
|
||||
const result: SqlSemanticRowSource[] = [];
|
||||
for (const source of sources) {
|
||||
const key = `${source.kind}:${source.name}:${source.alias ?? ""}:${source.sourceSpan.start}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(source);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function clauseSpans(tokens: readonly SqlSemanticToken[]): SqlSemanticClauseSpans {
|
||||
const spans: SqlSemanticClauseSpans = {};
|
||||
const depth = tokens[0]?.depth ?? 0;
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const item = tokens[index];
|
||||
if (!item || item.depth !== depth || item.kind !== "word") continue;
|
||||
const next = tokens[index + 1];
|
||||
const start = item.span.start;
|
||||
const end = next?.span.start ?? tokens[tokens.length - 1]?.span.end ?? item.span.end;
|
||||
if (item.normalized === "select") spans.select = { start, end };
|
||||
if (item.normalized === "from") spans.from = { start, end };
|
||||
if (item.normalized === "where") spans.where = { start, end };
|
||||
if (item.normalized === "having") spans.having = { start, end };
|
||||
if (item.normalized === "limit") spans.limit = { start, end };
|
||||
if (item.normalized === "group" && next?.normalized === "by") spans.groupBy = { start, end: next.span.end };
|
||||
if (item.normalized === "order" && next?.normalized === "by") spans.orderBy = { start, end: next.span.end };
|
||||
if (item.normalized === "set") spans.updateSet = { start, end };
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function trailingIdentifier(tokens: readonly SqlSemanticToken[], cursor: number, dialect: SqlSemanticDialectAdapter): TrailingIdentifier {
|
||||
const before = tokens.filter((item) => item.span.start < cursor && item.kind !== "comment" && item.kind !== "string");
|
||||
const last = before[before.length - 1];
|
||||
if (!last) return { prefix: "", replacementRange: { start: cursor, end: cursor }, qualifierParts: [] };
|
||||
if (last.span.end < cursor && last.text !== ".") {
|
||||
return { prefix: "", replacementRange: { start: cursor, end: cursor }, qualifierParts: [] };
|
||||
}
|
||||
|
||||
let prefix = "";
|
||||
let replacementRange: SqlSemanticSpan = { start: cursor, end: cursor };
|
||||
let index = before.length - 1;
|
||||
if (tokenIsIdentifier(last) && cursor <= last.span.end) {
|
||||
const rawPrefix = tokenTextAt(last.text, { start: 0, end: Math.max(0, cursor - last.span.start) });
|
||||
prefix = last.kind === "quoted_identifier" ? unquoteSqlSemanticIdentifier({ ...last, text: rawPrefix.endsWith(last.quote ?? "") ? rawPrefix : rawPrefix + (last.quote === "[" ? "]" : (last.quote ?? "")) }) : rawPrefix;
|
||||
replacementRange = { start: last.span.start, end: cursor };
|
||||
index -= 1;
|
||||
if (before[index]?.text === ".") index -= 1;
|
||||
} else if (last.text === ".") {
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
const qualifierParts: string[] = [];
|
||||
while (index >= 0) {
|
||||
const identifier = before[index];
|
||||
if (!tokenIsIdentifier(identifier)) break;
|
||||
qualifierParts.unshift(identifierPart(identifier, dialect).name);
|
||||
if (before[index - 1]?.text !== ".") break;
|
||||
index -= 2;
|
||||
}
|
||||
|
||||
return { prefix, replacementRange, qualifierParts };
|
||||
}
|
||||
|
||||
function previousWord(tokens: readonly SqlSemanticToken[], cursor: number): string {
|
||||
const before = tokens.filter((item) => item.span.end <= cursor && item.kind === "word");
|
||||
return before[before.length - 1]?.normalized ?? "";
|
||||
}
|
||||
|
||||
function wordBeforePosition(tokens: readonly SqlSemanticToken[], position: number): string {
|
||||
const before = tokens.filter((item) => item.span.end <= position && item.kind === "word");
|
||||
return before[before.length - 1]?.normalized ?? "";
|
||||
}
|
||||
|
||||
function isTableListContinuation(tokens: readonly SqlSemanticToken[], position: number): boolean {
|
||||
const before = tokens.filter((item) => item.span.end <= position && item.kind !== "comment");
|
||||
const commaIndex = before.length - 1;
|
||||
const comma = before[commaIndex];
|
||||
if (comma?.text !== ",") return false;
|
||||
const depth = comma.depth;
|
||||
for (let index = commaIndex - 1; index >= 0; index -= 1) {
|
||||
const token = before[index];
|
||||
if (!token || token.depth !== depth || token.kind !== "word") continue;
|
||||
if (TABLE_INTRODUCERS.has(token.normalized) || token.normalized === "from" || token.normalized === "join") return true;
|
||||
if (CLAUSE_BOUNDARIES.has(token.normalized) || token.normalized === "select") return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasWordBefore(tokens: readonly SqlSemanticToken[], cursor: number, word: string): boolean {
|
||||
return tokens.some((item) => item.span.end <= cursor && item.kind === "word" && item.normalized === word);
|
||||
}
|
||||
|
||||
function isBeforeWord(tokens: readonly SqlSemanticToken[], cursor: number, word: string): boolean {
|
||||
const target = tokens.find((item) => item.span.start >= cursor && item.kind === "word" && item.normalized === word);
|
||||
return !!target;
|
||||
}
|
||||
|
||||
function sourceForQualifier(sources: readonly SqlSemanticRowSource[], qualifierParts: readonly string[]): SqlSemanticRowSource | undefined {
|
||||
const qualifier = qualifierParts[qualifierParts.length - 1]?.toLowerCase();
|
||||
if (!qualifier) return undefined;
|
||||
return sources.find((source) => source.alias?.toLowerCase() === qualifier || source.name.toLowerCase() === qualifier);
|
||||
}
|
||||
|
||||
function starQualifierParts(before: readonly SqlSemanticToken[], starIndex: number, dialect: SqlSemanticDialectAdapter): string[] {
|
||||
let index = starIndex - 1;
|
||||
if (before[index]?.text === ".") index -= 1;
|
||||
const qualifierParts: string[] = [];
|
||||
while (index >= 0) {
|
||||
const identifier = before[index];
|
||||
if (!tokenIsIdentifier(identifier)) break;
|
||||
qualifierParts.unshift(identifierPart(identifier, dialect).name);
|
||||
if (before[index - 1]?.text !== ".") break;
|
||||
index -= 2;
|
||||
}
|
||||
return qualifierParts;
|
||||
}
|
||||
|
||||
function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, sources: readonly SqlSemanticRowSource[], dialect: SqlSemanticDialectAdapter, suppressed: boolean, kind: SqlSemanticStatementKind): SqlSemanticCursorIntent {
|
||||
if (suppressed) {
|
||||
return { kind: "suppressed", prefix: "", replacementRange: { start: cursor, end: cursor }, qualifierParts: [], expectedObjectKinds: [], confidence: "high", fallbackReason: "comment_or_string" };
|
||||
}
|
||||
const trailing = trailingIdentifier(tokens, cursor, dialect);
|
||||
const previous = previousWord(tokens, cursor);
|
||||
const targetSource = sourceForQualifier(sources, trailing.qualifierParts);
|
||||
const before = tokens.filter((item) => item.span.end <= cursor);
|
||||
const last = before[before.length - 1];
|
||||
const wordBeforeReplacement = wordBeforePosition(tokens, trailing.replacementRange.start);
|
||||
const tableListContinuation = isTableListContinuation(tokens, trailing.replacementRange.start);
|
||||
|
||||
if (last?.text === "*" || trailing.prefix === "*") {
|
||||
const starIndex = last?.text === "*" ? before.length - 1 : -1;
|
||||
const qualifierParts = starIndex >= 0 ? starQualifierParts(before, starIndex, dialect) : trailing.qualifierParts;
|
||||
const starTarget = sourceForQualifier(sources, qualifierParts);
|
||||
const replacementRange = last?.text === "*" ? { start: last.span.start, end: Math.min(cursor, last.span.end) } : trailing.replacementRange;
|
||||
return { kind: "star", prefix: "*", replacementRange, qualifierParts, targetSourceId: starTarget?.id, expectedObjectKinds: ["column"], confidence: "high" };
|
||||
}
|
||||
|
||||
if (kind === "call") {
|
||||
return { kind: "routine", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["routine", "procedure", "function"], confidence: "high" };
|
||||
}
|
||||
|
||||
if (kind === "insert" && hasWordBefore(tokens, cursor, "into") && !hasWordBefore(tokens, cursor, "values")) {
|
||||
const mutationTarget = sources.find((source) => source.kind === "mutation_target");
|
||||
return {
|
||||
kind: "insert_column",
|
||||
prefix: trailing.prefix,
|
||||
replacementRange: trailing.replacementRange,
|
||||
qualifierParts: trailing.qualifierParts,
|
||||
targetSourceId: mutationTarget?.id,
|
||||
expectedObjectKinds: ["column"],
|
||||
confidence: mutationTarget ? "medium" : "low",
|
||||
fallbackReason: mutationTarget ? undefined : "unresolved_insert_target",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
trailing.qualifierParts.length > 0 &&
|
||||
(previous === "from" || previous === "join" || TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || (!!targetSource && !targetSource.alias && TABLE_INTRODUCERS.has(wordBeforePosition(tokens, targetSource.sourceSpan.start))))
|
||||
) {
|
||||
const role = dialect.qualifierRole(trailing.qualifierParts, "table");
|
||||
return { kind: role === "catalog" ? "catalog" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "medium" };
|
||||
}
|
||||
|
||||
if (trailing.qualifierParts.length > 0 && targetSource) {
|
||||
return { kind: "alias_column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, targetSourceId: targetSource.id, expectedObjectKinds: ["column"], confidence: "high" };
|
||||
}
|
||||
|
||||
if (TABLE_INTRODUCERS.has(previous) || JOIN_MODIFIERS.has(previous) || previous === "from" || previous === "join" || tableListContinuation) {
|
||||
return { kind: previous === "join" ? "table" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "high" };
|
||||
}
|
||||
|
||||
if (previous === "call" || previous === "exec" || previous === "execute") {
|
||||
return { kind: "routine", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["routine", "procedure", "function"], confidence: "high" };
|
||||
}
|
||||
|
||||
if (previous === "set") {
|
||||
return {
|
||||
kind: "update_column",
|
||||
prefix: trailing.prefix,
|
||||
replacementRange: trailing.replacementRange,
|
||||
qualifierParts: trailing.qualifierParts,
|
||||
expectedObjectKinds: ["column"],
|
||||
confidence: sources.length > 0 ? "medium" : "low",
|
||||
fallbackReason: sources.length > 0 ? undefined : "unresolved_update_target",
|
||||
};
|
||||
}
|
||||
|
||||
if (["where", "on", "and", "or", "having", "by", "select"].includes(previous) && sources.length > 0 && !isBeforeWord(tokens, cursor, "from")) {
|
||||
return { kind: previous === "on" ? "join_condition" : "column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["column"], confidence: "medium" };
|
||||
}
|
||||
|
||||
return { kind: "keyword", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: [], confidence: "low", fallbackReason: "keyword_context" };
|
||||
}
|
||||
|
||||
function buildScope(statement: SqlSemanticStatement, rowSources: SqlSemanticRowSource[], projections: SqlSemanticProjection[], tokens: SqlSemanticToken[]): SqlSemanticScope {
|
||||
return {
|
||||
id: "root",
|
||||
kind: statement.kind,
|
||||
span: statement.span,
|
||||
rowSources,
|
||||
projections,
|
||||
clauseSpans: clauseSpans(tokens),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSqlSemanticModel(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): SqlSemanticModel {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, sql.length));
|
||||
const dialect = sqlSemanticDialectFor(options);
|
||||
const allTokens = tokenizeSqlSemantic(sql);
|
||||
const statementSpan = findActiveSqlStatementSpan(sql, allTokens, safeCursor);
|
||||
const tokens = significantTokens(allTokens.filter((item) => item.span.end > statementSpan.start && item.span.start < statementSpan.end));
|
||||
const kind = statementKind(tokens);
|
||||
const statement: SqlSemanticStatement = {
|
||||
kind,
|
||||
span: statementSpan,
|
||||
text: sql.slice(statementSpan.start, statementSpan.end),
|
||||
};
|
||||
const suppressed = isSuppressedSqlSemanticContext(allTokens, safeCursor);
|
||||
const parseState: ParseState = { dialect, tokens, statement, cteSources: [] };
|
||||
parseState.cteSources = parseCteSources(parseState);
|
||||
const rowSources = parseRowSources(parseState);
|
||||
const projections = parseSelectProjections(tokens, dialect);
|
||||
const cursorIntent = buildCursorIntent(tokens, safeCursor, rowSources, dialect, suppressed, kind);
|
||||
const scopes = [buildScope(statement, rowSources, projections, tokens)];
|
||||
return {
|
||||
databaseType: options.databaseType,
|
||||
dialectId: dialect.id,
|
||||
sql,
|
||||
cursor: safeCursor,
|
||||
statement,
|
||||
tokens: allTokens,
|
||||
scopes,
|
||||
rowSources,
|
||||
projections,
|
||||
cursorIntent,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
|
||||
import type { SqlSemanticModel, SqlSemanticRowSource, SqlSemanticSpan } from "@/lib/sql/semantic/types";
|
||||
import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
|
||||
export type SqlSemanticTableReference = SqlTableReference & {
|
||||
columns?: string[];
|
||||
semanticSourceId?: string;
|
||||
semanticSourceKind?: SqlSemanticRowSource["kind"];
|
||||
};
|
||||
|
||||
export interface SqlSemanticNavigationTarget {
|
||||
name: string;
|
||||
schema?: string;
|
||||
alias?: string;
|
||||
columns?: string[];
|
||||
source: SqlSemanticRowSource;
|
||||
}
|
||||
|
||||
function offsetToLineColumn(sql: string, offset: number): { line: number; column: number } {
|
||||
const safeOffset = Math.max(0, Math.min(offset, sql.length));
|
||||
let line = 1;
|
||||
let lineStart = 0;
|
||||
for (let index = 0; index < safeOffset; index += 1) {
|
||||
if (sql[index] === "\n") {
|
||||
line += 1;
|
||||
lineStart = index + 1;
|
||||
}
|
||||
}
|
||||
return { line, column: safeOffset - lineStart };
|
||||
}
|
||||
|
||||
function semanticSpanToSqlTextSpan(sql: string, span: SqlSemanticSpan): SqlTextSpan {
|
||||
const start = offsetToLineColumn(sql, span.start);
|
||||
const end = offsetToLineColumn(sql, Math.max(span.end, span.start));
|
||||
return {
|
||||
start_line: start.line,
|
||||
start_column: start.column + 1,
|
||||
end_line: end.line,
|
||||
end_column: Math.max(end.column, start.column + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function normalized(value: string | null | undefined): string {
|
||||
let result = value ?? "";
|
||||
while (result && '`"['.includes(result[0])) result = result.slice(1);
|
||||
while (result && '`"]'.includes(result[result.length - 1])) result = result.slice(0, -1);
|
||||
return result.toLowerCase();
|
||||
}
|
||||
|
||||
function sourceSchema(source: SqlSemanticRowSource): string | undefined {
|
||||
return source.metadataTarget?.schema ?? source.qualifierParts[source.qualifierParts.length - 1];
|
||||
}
|
||||
|
||||
function sourceTableName(source: SqlSemanticRowSource): string {
|
||||
return source.metadataTarget?.table ?? source.name;
|
||||
}
|
||||
|
||||
function sourceReferenceKey(source: SqlSemanticTableReference): string {
|
||||
return [normalized(source.schema), normalized(source.name), normalized(source.alias), source.span.start_line, source.span.start_column].join(":");
|
||||
}
|
||||
|
||||
function existingScopeId(analysis: SqlReferenceAnalysis): number {
|
||||
return analysis.tables[0]?.scope_id ?? analysis.columns[0]?.scope_id ?? 0;
|
||||
}
|
||||
|
||||
export function sqlSemanticTableReferences(model: SqlSemanticModel, scopeId = 0): SqlSemanticTableReference[] {
|
||||
if (model.cursorIntent.kind === "suppressed" || model.cursorIntent.confidence === "low") return [];
|
||||
return model.rowSources
|
||||
.filter((source) => source.kind !== "unknown")
|
||||
.map((source) => {
|
||||
const span = source.qualifiedName?.span ?? source.aliasSpan ?? source.sourceSpan;
|
||||
const schema = sourceSchema(source);
|
||||
return {
|
||||
name: sourceTableName(source),
|
||||
schema,
|
||||
alias: source.alias,
|
||||
span: semanticSpanToSqlTextSpan(model.sql, span),
|
||||
scope_id: scopeId,
|
||||
columns: source.columns?.length ? [...source.columns] : undefined,
|
||||
semanticSourceId: source.id,
|
||||
semanticSourceKind: source.kind,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeSqlSemanticReferenceAnalysis(analysis: SqlReferenceAnalysis, model: SqlSemanticModel): SqlReferenceAnalysis {
|
||||
const semanticTables = sqlSemanticTableReferences(model, existingScopeId(analysis));
|
||||
if (semanticTables.length === 0) return analysis;
|
||||
|
||||
const merged = new Map<string, SqlSemanticTableReference>();
|
||||
for (const table of analysis.tables as SqlSemanticTableReference[]) {
|
||||
merged.set(sourceReferenceKey(table), table);
|
||||
}
|
||||
|
||||
for (const table of semanticTables) {
|
||||
const existing = [...merged.values()].find((candidate) => {
|
||||
if (normalized(candidate.name) !== normalized(table.name)) return false;
|
||||
if (candidate.alias && table.alias && normalized(candidate.alias) !== normalized(table.alias)) return false;
|
||||
if (candidate.schema && table.schema && normalized(candidate.schema) !== normalized(table.schema)) return false;
|
||||
return true;
|
||||
});
|
||||
if (existing) {
|
||||
existing.schema = existing.schema ?? table.schema;
|
||||
existing.alias = existing.alias ?? table.alias;
|
||||
existing.columns = existing.columns ?? table.columns;
|
||||
existing.semanticSourceId = existing.semanticSourceId ?? table.semanticSourceId;
|
||||
} else {
|
||||
merged.set(sourceReferenceKey(table), table);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...analysis,
|
||||
tables: [...merged.values()],
|
||||
};
|
||||
}
|
||||
|
||||
export function sqlSemanticCompletionReferenceTables(model: SqlSemanticModel): SqlCompletionReferencedTable[] {
|
||||
return sqlSemanticTableReferences(model).map((table) => ({
|
||||
name: table.name,
|
||||
schema: table.schema ?? undefined,
|
||||
alias: table.alias ?? undefined,
|
||||
columns: table.columns,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveSqlSemanticNavigationTarget(model: SqlSemanticModel, identifierParts: readonly string[]): SqlSemanticNavigationTarget | null {
|
||||
if (model.cursorIntent.kind === "suppressed" || model.cursorIntent.confidence === "low" || identifierParts.length === 0) return null;
|
||||
const normalizedParts = identifierParts.map(normalized).filter(Boolean);
|
||||
const name = normalizedParts[normalizedParts.length - 1];
|
||||
const qualifier = normalizedParts.length > 1 ? normalizedParts[normalizedParts.length - 2] : undefined;
|
||||
|
||||
const source =
|
||||
model.rowSources.find((candidate) => {
|
||||
const candidateName = normalized(sourceTableName(candidate));
|
||||
const candidateAlias = normalized(candidate.alias);
|
||||
const candidateSchema = normalized(sourceSchema(candidate));
|
||||
if (qualifier) {
|
||||
if (candidateAlias && candidateAlias === qualifier) return true;
|
||||
return candidateName === name && (!candidateSchema || candidateSchema === qualifier);
|
||||
}
|
||||
return candidateName === name || candidateAlias === name;
|
||||
}) ?? null;
|
||||
|
||||
if (!source) return null;
|
||||
const schema = sourceSchema(source);
|
||||
return {
|
||||
name: sourceTableName(source),
|
||||
schema,
|
||||
alias: source.alias,
|
||||
columns: source.columns,
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { SqlSemanticSpan, SqlSemanticToken } from "@/lib/sql/semantic/types";
|
||||
|
||||
const WORD_START = /[A-Za-z_@$#]/;
|
||||
const WORD_PART = /[A-Za-z0-9_@$#]/;
|
||||
|
||||
function token(kind: SqlSemanticToken["kind"], text: string, start: number, end: number, depth: number, quote?: string): SqlSemanticToken {
|
||||
return {
|
||||
kind,
|
||||
text,
|
||||
normalized: kind === "word" ? text.toLowerCase() : text,
|
||||
span: { start, end },
|
||||
depth,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
function readQuoted(input: string, start: number, open: string, close: string): number {
|
||||
let index = start + open.length;
|
||||
while (index < input.length) {
|
||||
if (input.startsWith(close, index)) {
|
||||
if (input.startsWith(close + close, index)) {
|
||||
index += close.length * 2;
|
||||
continue;
|
||||
}
|
||||
return index + close.length;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return input.length;
|
||||
}
|
||||
|
||||
export function tokenizeSqlSemantic(input: string): SqlSemanticToken[] {
|
||||
const tokens: SqlSemanticToken[] = [];
|
||||
let index = 0;
|
||||
let depth = 0;
|
||||
|
||||
while (index < input.length) {
|
||||
const start = index;
|
||||
const ch = input[index] ?? "";
|
||||
const next = input[index + 1] ?? "";
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "-" && next === "-") {
|
||||
index += 2;
|
||||
while (index < input.length && input[index] !== "\n" && input[index] !== "\r") index += 1;
|
||||
tokens.push(token("comment", input.slice(start, index), start, index, depth));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "#") {
|
||||
index += 1;
|
||||
while (index < input.length && input[index] !== "\n" && input[index] !== "\r") index += 1;
|
||||
tokens.push(token("comment", input.slice(start, index), start, index, depth));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) index += 1;
|
||||
index = Math.min(input.length, index + (index < input.length ? 2 : 0));
|
||||
tokens.push(token("comment", input.slice(start, index), start, index, depth));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'") {
|
||||
index = readQuoted(input, start, "'", "'");
|
||||
tokens.push(token("string", input.slice(start, index), start, index, depth, "'"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
index = readQuoted(input, start, '"', '"');
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "`") {
|
||||
index = readQuoted(input, start, "`", "`");
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "`"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "[") {
|
||||
index = readQuoted(input, start, "[", "]");
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "["));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ":" || ch === "?") {
|
||||
index += 1;
|
||||
while (index < input.length && WORD_PART.test(input[index] ?? "")) index += 1;
|
||||
tokens.push(token("parameter", input.slice(start, index), start, index, depth));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[0-9]/.test(ch)) {
|
||||
index += 1;
|
||||
while (index < input.length && /[0-9.]/.test(input[index] ?? "")) index += 1;
|
||||
tokens.push(token("number", input.slice(start, index), start, index, depth));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (WORD_START.test(ch)) {
|
||||
index += 1;
|
||||
while (index < input.length && WORD_PART.test(input[index] ?? "")) index += 1;
|
||||
tokens.push(token("word", input.slice(start, index), start, index, depth));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("(),.;*".includes(ch)) {
|
||||
if (ch === ")") depth = Math.max(0, depth - 1);
|
||||
tokens.push(token("punctuation", ch, start, start + 1, depth));
|
||||
if (ch === "(") depth += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
tokens.push(token("operator", ch, start, index, depth));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function tokenContainsPosition(tokenValue: SqlSemanticToken, position: number): boolean {
|
||||
return tokenValue.span.start <= position && position <= tokenValue.span.end;
|
||||
}
|
||||
|
||||
export function isSuppressedSqlSemanticContext(tokens: readonly SqlSemanticToken[], cursor: number): boolean {
|
||||
return tokens.some((item) => (item.kind === "comment" || item.kind === "string") && item.span.start < cursor && cursor <= item.span.end);
|
||||
}
|
||||
|
||||
export function findActiveSqlStatementSpan(sql: string, tokens: readonly SqlSemanticToken[], cursor: number): SqlSemanticSpan {
|
||||
let start = 0;
|
||||
let end = sql.length;
|
||||
for (const item of tokens) {
|
||||
if (item.kind !== "punctuation" || item.text !== ";" || item.depth !== 0) continue;
|
||||
if (item.span.end <= cursor) start = item.span.end;
|
||||
if (item.span.start >= cursor) {
|
||||
end = item.span.start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (start < end && /\s/.test(sql[start] ?? "")) start += 1;
|
||||
while (end > start && /\s/.test(sql[end - 1] ?? "")) end -= 1;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function unquoteSqlSemanticIdentifier(tokenValue: SqlSemanticToken): string {
|
||||
if (tokenValue.kind !== "quoted_identifier") return tokenValue.text;
|
||||
if (tokenValue.quote === "[") return tokenValue.text.slice(1, -1).replaceAll("]]", "]");
|
||||
const quote = tokenValue.quote ?? tokenValue.text[0] ?? "";
|
||||
return tokenValue.text.slice(1, -1).replaceAll(quote + quote, quote);
|
||||
}
|
||||
|
||||
export function tokenIsIdentifier(tokenValue: SqlSemanticToken | undefined): tokenValue is SqlSemanticToken {
|
||||
return !!tokenValue && (tokenValue.kind === "word" || tokenValue.kind === "quoted_identifier");
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export interface SqlSemanticSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export type SqlSemanticConfidence = "high" | "medium" | "low";
|
||||
|
||||
export type SqlSemanticStatementKind = "select" | "insert" | "update" | "delete" | "call" | "unknown";
|
||||
|
||||
export type SqlSemanticTokenKind = "word" | "quoted_identifier" | "string" | "number" | "comment" | "parameter" | "punctuation" | "operator";
|
||||
|
||||
export interface SqlSemanticToken {
|
||||
kind: SqlSemanticTokenKind;
|
||||
text: string;
|
||||
normalized: string;
|
||||
span: SqlSemanticSpan;
|
||||
depth: number;
|
||||
quote?: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticIdentifierPart {
|
||||
raw: string;
|
||||
name: string;
|
||||
span: SqlSemanticSpan;
|
||||
quote?: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticQualifiedName {
|
||||
parts: SqlSemanticIdentifierPart[];
|
||||
span: SqlSemanticSpan;
|
||||
}
|
||||
|
||||
export type SqlSemanticRowSourceKind = "table" | "cte" | "subquery" | "table_function" | "mutation_target" | "unknown";
|
||||
|
||||
export interface SqlSemanticMetadataTarget {
|
||||
database?: string;
|
||||
schema?: string;
|
||||
table?: string;
|
||||
packageName?: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticProjection {
|
||||
name: string;
|
||||
sourceExpression: string;
|
||||
span: SqlSemanticSpan;
|
||||
alias?: string;
|
||||
aliasSpan?: SqlSemanticSpan;
|
||||
}
|
||||
|
||||
export interface SqlSemanticRowSource {
|
||||
id: string;
|
||||
kind: SqlSemanticRowSourceKind;
|
||||
name: string;
|
||||
qualifiedName?: SqlSemanticQualifiedName;
|
||||
qualifierParts: string[];
|
||||
alias?: string;
|
||||
aliasSpan?: SqlSemanticSpan;
|
||||
sourceSpan: SqlSemanticSpan;
|
||||
columns?: string[];
|
||||
metadataTarget?: SqlSemanticMetadataTarget;
|
||||
unresolved?: boolean;
|
||||
}
|
||||
|
||||
export interface SqlSemanticClauseSpans {
|
||||
select?: SqlSemanticSpan;
|
||||
from?: SqlSemanticSpan;
|
||||
where?: SqlSemanticSpan;
|
||||
groupBy?: SqlSemanticSpan;
|
||||
having?: SqlSemanticSpan;
|
||||
orderBy?: SqlSemanticSpan;
|
||||
limit?: SqlSemanticSpan;
|
||||
insertColumns?: SqlSemanticSpan;
|
||||
updateSet?: SqlSemanticSpan;
|
||||
}
|
||||
|
||||
export interface SqlSemanticScope {
|
||||
id: string;
|
||||
kind: SqlSemanticStatementKind | "subquery" | "cte";
|
||||
span: SqlSemanticSpan;
|
||||
parentId?: string;
|
||||
rowSources: SqlSemanticRowSource[];
|
||||
projections: SqlSemanticProjection[];
|
||||
clauseSpans: SqlSemanticClauseSpans;
|
||||
}
|
||||
|
||||
export type SqlSemanticCursorKind = "table" | "schema" | "catalog" | "routine" | "column" | "alias_column" | "insert_column" | "update_column" | "delete_target" | "join_condition" | "star" | "keyword" | "suppressed";
|
||||
|
||||
export interface SqlSemanticCursorIntent {
|
||||
kind: SqlSemanticCursorKind;
|
||||
prefix: string;
|
||||
replacementRange: SqlSemanticSpan;
|
||||
qualifierParts: string[];
|
||||
targetSourceId?: string;
|
||||
expectedObjectKinds: Array<"database" | "schema" | "table" | "view" | "routine" | "procedure" | "function" | "column">;
|
||||
confidence: SqlSemanticConfidence;
|
||||
fallbackReason?: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticStatement {
|
||||
kind: SqlSemanticStatementKind;
|
||||
span: SqlSemanticSpan;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SqlSemanticDiagnostic {
|
||||
message: string;
|
||||
span: SqlSemanticSpan;
|
||||
severity: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
export interface SqlSemanticModel {
|
||||
databaseType?: DatabaseType;
|
||||
dialectId: string;
|
||||
sql: string;
|
||||
cursor: number;
|
||||
statement: SqlSemanticStatement;
|
||||
tokens: SqlSemanticToken[];
|
||||
scopes: SqlSemanticScope[];
|
||||
rowSources: SqlSemanticRowSource[];
|
||||
projections: SqlSemanticProjection[];
|
||||
cursorIntent: SqlSemanticCursorIntent;
|
||||
diagnostics: SqlSemanticDiagnostic[];
|
||||
}
|
||||
|
||||
export interface SqlSemanticBuildOptions {
|
||||
databaseType?: DatabaseType;
|
||||
dialect?: "mysql" | "postgres" | "sqlserver";
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// 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;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
export interface SqlErrorLocation {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
function toZeroBased(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return null;
|
||||
return parsed - 1;
|
||||
}
|
||||
|
||||
export function parseSqlErrorLocation(message: string): SqlErrorLocation | null {
|
||||
const lineColumn = /\bline\s+(\d+)\s*[,:\s]\s*column\s+(\d+)\b/i.exec(message) ?? /\bline\s+(\d+)\b[\s\S]{0,80}?\bcol(?:umn)?\s+(\d+)\b/i.exec(message);
|
||||
if (lineColumn) {
|
||||
const line = toZeroBased(lineColumn[1]);
|
||||
const column = toZeroBased(lineColumn[2]);
|
||||
if (line != null && column != null) return { line, column };
|
||||
}
|
||||
|
||||
const lines = message.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const lineMatch = /^LINE\s+(\d+):/i.exec(lines[index] ?? "");
|
||||
if (!lineMatch) continue;
|
||||
const caretLine = lines.slice(index + 1).find((line) => line.includes("^"));
|
||||
const line = toZeroBased(lineMatch[1]);
|
||||
const caretIndex = caretLine?.indexOf("^") ?? -1;
|
||||
if (line != null && caretIndex >= 0) return { line, column: caretIndex };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function lineColumnToOffset(sql: string, location: SqlErrorLocation): number | null {
|
||||
const lines = sql.split(/\r?\n/);
|
||||
if (location.line < 0 || location.line >= lines.length) return null;
|
||||
|
||||
let offset = 0;
|
||||
for (let index = 0; index < location.line; index++) {
|
||||
offset += lines[index].length + 1;
|
||||
}
|
||||
|
||||
return Math.min(offset + location.column, offset + lines[location.line].length);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export type ExecuteMode = "all" | "current";
|
||||
|
||||
export interface SqlExecutionSnapshot {
|
||||
fullSql: string;
|
||||
selectedSql: string;
|
||||
cursorPos: number;
|
||||
}
|
||||
|
||||
export type SqlExecutionOverride = string | SqlExecutionSnapshot;
|
||||
|
||||
export type SqlExecutionTargetKind = "cursor" | "all";
|
||||
|
||||
/**
|
||||
* A candidate execution target surfaced by the execution target picker.
|
||||
* `from`/`to` are offsets into the full document so the editor can highlight
|
||||
* the corresponding range as a preview while the picker is open.
|
||||
*/
|
||||
export interface SqlExecutionCandidate {
|
||||
kind: SqlExecutionTargetKind;
|
||||
label: string;
|
||||
sql: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface SqlExecutionChoiceRequest {
|
||||
fullSql: string;
|
||||
selectedSql: string;
|
||||
cursorPos: number;
|
||||
candidates: SqlExecutionCandidate[];
|
||||
}
|
||||
|
||||
export function isSqlExecutionSnapshot(value: SqlExecutionOverride | undefined): value is SqlExecutionSnapshot {
|
||||
return typeof value === "object" && value !== null && typeof value.fullSql === "string" && typeof value.selectedSql === "string" && typeof value.cursorPos === "number";
|
||||
}
|
||||
|
||||
export function resolveExecutableSql(fullSql: string, selectedSql: string, options?: { mode?: ExecuteMode; cursorPos?: number }): string {
|
||||
const trimmedSelection = selectedSql.trim();
|
||||
if (trimmedSelection) return trimmedSelection;
|
||||
|
||||
if (options?.mode === "current" && options.cursorPos !== undefined) {
|
||||
return fullSql;
|
||||
}
|
||||
|
||||
return fullSql;
|
||||
}
|
||||
|
||||
export async function resolveExecutableSqlWithBackend(fullSql: string, selectedSql: string, options?: { mode?: ExecuteMode; cursorPos?: number; databaseType?: DatabaseType }): Promise<string> {
|
||||
const trimmedSelection = selectedSql.trim();
|
||||
if (trimmedSelection) return trimmedSelection;
|
||||
|
||||
// MongoDB uses dedicated per-command gutter actions for "current command";
|
||||
// the main editor execute action keeps its long-standing "run all text"
|
||||
// behavior when nothing is selected.
|
||||
if (options?.databaseType === "mongodb") return fullSql;
|
||||
|
||||
if (options?.mode === "current" && options.cursorPos !== undefined) {
|
||||
return await api.findStatementAtCursor(fullSql, options.cursorPos, options.databaseType);
|
||||
}
|
||||
|
||||
return fullSql;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function isSqlFilePath(path: string): boolean {
|
||||
return /\.sql$/i.test(path.trim());
|
||||
}
|
||||
|
||||
export function sqlFileTitleFromPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
const normalized = trimmed.replace(/\\/g, "/");
|
||||
const name = normalized.split("/").filter(Boolean).pop();
|
||||
return name || "script.sql";
|
||||
}
|
||||
|
||||
export function externalSqlFilePaths(paths: string[]): string[] {
|
||||
return paths.filter(isSqlFilePath);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
|
||||
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "generic";
|
||||
|
||||
export const MAX_SQL_FORMAT_CHARS = 1_000_000;
|
||||
|
||||
/**
|
||||
* Maps a connection's database type to the SQL-formatter dialect to use.
|
||||
*
|
||||
* Postgres-compatible engines (GaussDB/openGauss/Kingbase/...) reuse the
|
||||
* "postgres" grammar, SQLite-compatible ones reuse "sqlite", and anything
|
||||
* unrecognized falls back to the permissive "generic" dialect. Centralized
|
||||
* here so every surface that formats SQL (editor, object source, DDL viewers)
|
||||
* stays in sync.
|
||||
*/
|
||||
export function sqlFormatDialectForDbType(dbType: string | null | undefined): SqlFormatDialect {
|
||||
switch (dbType) {
|
||||
case "mysql":
|
||||
return "mysql";
|
||||
case "postgres":
|
||||
case "kwdb":
|
||||
case "gaussdb":
|
||||
case "opengauss":
|
||||
case "questdb":
|
||||
case "kingbase":
|
||||
case "highgo":
|
||||
case "vastbase":
|
||||
case "redshift":
|
||||
return "postgres";
|
||||
case "sqlite":
|
||||
case "rqlite":
|
||||
case "turso":
|
||||
return "sqlite";
|
||||
case "sqlserver":
|
||||
return "sqlserver";
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
}
|
||||
|
||||
function formatterLanguage(dialect: SqlFormatDialect) {
|
||||
switch (dialect) {
|
||||
case "mysql":
|
||||
return "mysql";
|
||||
case "postgres":
|
||||
return "postgresql";
|
||||
case "sqlite":
|
||||
return "sqlite";
|
||||
case "sqlserver":
|
||||
return "transactsql";
|
||||
default:
|
||||
return "sql";
|
||||
}
|
||||
}
|
||||
|
||||
export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "generic", settings: Partial<SqlFormatterSettings> = DEFAULT_SQL_FORMATTER_SETTINGS): Promise<string> {
|
||||
if (!sql.trim()) return sql;
|
||||
if (sql.length > MAX_SQL_FORMAT_CHARS) {
|
||||
throw new Error("SQL is too large to format safely.");
|
||||
}
|
||||
|
||||
const { format } = await import("sql-formatter");
|
||||
const options = sqlFormatterOptions(settings);
|
||||
const language = formatterLanguage(dialect);
|
||||
try {
|
||||
return format(sql, { language, ...options });
|
||||
} catch (err) {
|
||||
// The generic "sql" dialect can't parse many real-world constructs (PostgreSQL
|
||||
// `::` casts, GaussDB/openGauss materialized-view DDL, T-SQL specifics, ...).
|
||||
// Retry once with the more permissive PostgreSQL grammar, which is a superset
|
||||
// that tolerates most of these, before surfacing the failure.
|
||||
if (language !== "postgresql") {
|
||||
try {
|
||||
return format(sql, { language: "postgresql", ...options });
|
||||
} catch {
|
||||
// fall through to the original error below
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format SQL for *display* (object source, view/table DDL viewers).
|
||||
*
|
||||
* Unlike `formatSqlText`, this never throws: if the SQL can't be parsed by the
|
||||
* formatter (vendor-specific DDL, oversized input, ...) the original text is
|
||||
* returned unchanged so the viewer still shows the source. Use this for
|
||||
* read-only/auto-format surfaces; use `formatSqlText` where a thrown error
|
||||
* should surface to the user (e.g. the explicit "Format SQL" command).
|
||||
*/
|
||||
export async function formatSqlForDisplay(sql: string, dialect: SqlFormatDialect = "generic", settings: Partial<SqlFormatterSettings> = DEFAULT_SQL_FORMATTER_SETTINGS): Promise<string> {
|
||||
if (!sql.trim()) return sql;
|
||||
try {
|
||||
return await formatSqlText(sql, dialect, settings);
|
||||
} catch {
|
||||
return sql;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
export const SQL_FORMATTER_CONFIG_VERSION = 1;
|
||||
export const SQL_FORMATTER_CONFIG_FORMATTER = "sql-formatter";
|
||||
|
||||
const CASE_VALUES = ["preserve", "upper", "lower"] as const;
|
||||
const INDENT_STYLE_VALUES = ["standard", "tabularLeft", "tabularRight"] as const;
|
||||
const LOGICAL_OPERATOR_NEWLINE_VALUES = ["before", "after"] as const;
|
||||
const TAB_WIDTH_VALUES = [2, 4] as const;
|
||||
const EXPRESSION_WIDTH_VALUES = [50, 80, 120] as const;
|
||||
const LINES_BETWEEN_QUERIES_VALUES = [0, 1, 2] as const;
|
||||
const SQL_FORMATTER_PARAM_TYPE_MARKERS = ["?", ":", "$"] as const;
|
||||
const SQL_FORMATTER_NAMED_PARAM_TYPE_MARKERS = [":", "@", "$"] as const;
|
||||
const SQL_FORMATTER_LEGACY_OPTION_KEYS = new Set(["params"]);
|
||||
|
||||
export type SqlFormatterCase = (typeof CASE_VALUES)[number];
|
||||
export type SqlFormatterIndentStyle = (typeof INDENT_STYLE_VALUES)[number];
|
||||
export type SqlFormatterLogicalOperatorNewline = (typeof LOGICAL_OPERATOR_NEWLINE_VALUES)[number];
|
||||
export type SqlFormatterTabWidth = (typeof TAB_WIDTH_VALUES)[number];
|
||||
export type SqlFormatterExpressionWidth = (typeof EXPRESSION_WIDTH_VALUES)[number];
|
||||
export type SqlFormatterLinesBetweenQueries = (typeof LINES_BETWEEN_QUERIES_VALUES)[number];
|
||||
|
||||
export interface SqlFormatterCustomParameter {
|
||||
regex: string;
|
||||
}
|
||||
|
||||
export interface SqlFormatterParamTypes {
|
||||
positional?: boolean;
|
||||
numbered?: ("?" | ":" | "$")[];
|
||||
named?: (":" | "@" | "$")[];
|
||||
quoted?: (":" | "@" | "$")[];
|
||||
custom?: SqlFormatterCustomParameter[];
|
||||
}
|
||||
|
||||
export interface SqlFormatterOptionSettings {
|
||||
keywordCase: SqlFormatterCase;
|
||||
dataTypeCase: SqlFormatterCase;
|
||||
functionCase: SqlFormatterCase;
|
||||
identifierCase: SqlFormatterCase;
|
||||
indentStyle: SqlFormatterIndentStyle;
|
||||
useTabs: boolean;
|
||||
tabWidth: SqlFormatterTabWidth;
|
||||
logicalOperatorNewline: SqlFormatterLogicalOperatorNewline;
|
||||
expressionWidth: SqlFormatterExpressionWidth;
|
||||
linesBetweenQueries: SqlFormatterLinesBetweenQueries;
|
||||
denseOperators: boolean;
|
||||
newlineBeforeSemicolon: boolean;
|
||||
paramTypes: SqlFormatterParamTypes | null;
|
||||
}
|
||||
|
||||
export type SqlFormatterSettings = SqlFormatterOptionSettings;
|
||||
|
||||
export interface SqlFormatterConfigFile {
|
||||
version: typeof SQL_FORMATTER_CONFIG_VERSION;
|
||||
formatter: typeof SQL_FORMATTER_CONFIG_FORMATTER;
|
||||
options: SqlFormatterOptionSettings;
|
||||
}
|
||||
|
||||
export type SqlFormatterConfigParseResult = { ok: true; settings: SqlFormatterSettings } | { ok: false; message: string };
|
||||
|
||||
export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
|
||||
keywordCase: "upper",
|
||||
dataTypeCase: "preserve",
|
||||
functionCase: "preserve",
|
||||
identifierCase: "preserve",
|
||||
indentStyle: "standard",
|
||||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
logicalOperatorNewline: "before",
|
||||
expressionWidth: 50,
|
||||
linesBetweenQueries: 1,
|
||||
denseOperators: false,
|
||||
newlineBeforeSemicolon: false,
|
||||
paramTypes: null,
|
||||
};
|
||||
|
||||
const SQL_FORMATTER_OPTION_KEYS = new Set<keyof SqlFormatterOptionSettings>([
|
||||
"keywordCase",
|
||||
"dataTypeCase",
|
||||
"functionCase",
|
||||
"identifierCase",
|
||||
"indentStyle",
|
||||
"useTabs",
|
||||
"tabWidth",
|
||||
"logicalOperatorNewline",
|
||||
"expressionWidth",
|
||||
"linesBetweenQueries",
|
||||
"denseOperators",
|
||||
"newlineBeforeSemicolon",
|
||||
"paramTypes",
|
||||
]);
|
||||
|
||||
const SQL_FORMATTER_OPTION_VALIDATORS: Record<keyof SqlFormatterOptionSettings, (value: unknown) => boolean> = {
|
||||
keywordCase: (value) => isStringChoice(value, CASE_VALUES),
|
||||
dataTypeCase: (value) => isStringChoice(value, CASE_VALUES),
|
||||
functionCase: (value) => isStringChoice(value, CASE_VALUES),
|
||||
identifierCase: (value) => isStringChoice(value, CASE_VALUES),
|
||||
indentStyle: (value) => isStringChoice(value, INDENT_STYLE_VALUES),
|
||||
useTabs: (value) => typeof value === "boolean",
|
||||
tabWidth: (value) => isNumberChoice(value, TAB_WIDTH_VALUES),
|
||||
logicalOperatorNewline: (value) => isStringChoice(value, LOGICAL_OPERATOR_NEWLINE_VALUES),
|
||||
expressionWidth: (value) => isNumberChoice(value, EXPRESSION_WIDTH_VALUES),
|
||||
linesBetweenQueries: (value) => isNumberChoice(value, LINES_BETWEEN_QUERIES_VALUES),
|
||||
denseOperators: (value) => typeof value === "boolean",
|
||||
newlineBeforeSemicolon: (value) => typeof value === "boolean",
|
||||
paramTypes: isSqlFormatterParamTypes,
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringChoice(value: unknown, values: readonly string[]): boolean {
|
||||
return typeof value === "string" && values.includes(value);
|
||||
}
|
||||
|
||||
function isNumberChoice(value: unknown, values: readonly number[]): boolean {
|
||||
return typeof value === "number" && values.includes(value);
|
||||
}
|
||||
|
||||
function isMarkerArray<T extends readonly string[]>(value: unknown, markers: T): value is T[number][] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string" && markers.includes(item));
|
||||
}
|
||||
|
||||
function isCustomParameter(value: unknown): value is SqlFormatterCustomParameter {
|
||||
if (!isObject(value) || typeof value.regex !== "string" || value.regex.length === 0 || !Object.keys(value).every((key) => key === "regex")) return false;
|
||||
try {
|
||||
new RegExp(`(?:${value.regex})`, "uy");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSqlFormatterParamTypes(value: unknown): value is SqlFormatterParamTypes | null {
|
||||
if (value === null) return true;
|
||||
if (!isObject(value)) return false;
|
||||
if (!Object.keys(value).every((key) => ["positional", "numbered", "named", "quoted", "custom"].includes(key))) return false;
|
||||
if (value.positional !== undefined && typeof value.positional !== "boolean") return false;
|
||||
if (value.numbered !== undefined && !isMarkerArray(value.numbered, SQL_FORMATTER_PARAM_TYPE_MARKERS)) return false;
|
||||
if (value.named !== undefined && !isMarkerArray(value.named, SQL_FORMATTER_NAMED_PARAM_TYPE_MARKERS)) return false;
|
||||
if (value.quoted !== undefined && !isMarkerArray(value.quoted, SQL_FORMATTER_NAMED_PARAM_TYPE_MARKERS)) return false;
|
||||
if (value.custom !== undefined && (!Array.isArray(value.custom) || !value.custom.every(isCustomParameter))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeChoice<T extends readonly string[]>(value: unknown, values: T, fallback: T[number]): T[number] {
|
||||
return typeof value === "string" && values.includes(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeNumberChoice<T extends readonly number[]>(value: unknown, values: T, fallback: T[number]): T[number] {
|
||||
return typeof value === "number" && values.includes(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeParamTypes(value: unknown, fallback: SqlFormatterParamTypes | null): SqlFormatterParamTypes | null {
|
||||
if (value === null) return null;
|
||||
if (!isSqlFormatterParamTypes(value)) return fallback;
|
||||
return {
|
||||
...(value.positional !== undefined ? { positional: value.positional } : {}),
|
||||
...(value.numbered ? { numbered: [...value.numbered] } : {}),
|
||||
...(value.named ? { named: [...value.named] } : {}),
|
||||
...(value.quoted ? { quoted: [...value.quoted] } : {}),
|
||||
...(value.custom ? { custom: value.custom.map((item) => ({ regex: item.regex })) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function sqlFormatterOptionSettings(settings: unknown): SqlFormatterOptionSettings {
|
||||
const input = isObject(settings) ? settings : {};
|
||||
return {
|
||||
keywordCase: normalizeChoice(input.keywordCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.keywordCase),
|
||||
dataTypeCase: normalizeChoice(input.dataTypeCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.dataTypeCase),
|
||||
functionCase: normalizeChoice(input.functionCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.functionCase),
|
||||
identifierCase: normalizeChoice(input.identifierCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.identifierCase),
|
||||
indentStyle: normalizeChoice(input.indentStyle, INDENT_STYLE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.indentStyle),
|
||||
useTabs: normalizeBoolean(input.useTabs, DEFAULT_SQL_FORMATTER_SETTINGS.useTabs),
|
||||
tabWidth: normalizeNumberChoice(input.tabWidth, TAB_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.tabWidth),
|
||||
logicalOperatorNewline: normalizeChoice(input.logicalOperatorNewline, LOGICAL_OPERATOR_NEWLINE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.logicalOperatorNewline),
|
||||
expressionWidth: normalizeNumberChoice(input.expressionWidth, EXPRESSION_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.expressionWidth),
|
||||
linesBetweenQueries: normalizeNumberChoice(input.linesBetweenQueries, LINES_BETWEEN_QUERIES_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.linesBetweenQueries),
|
||||
denseOperators: normalizeBoolean(input.denseOperators, DEFAULT_SQL_FORMATTER_SETTINGS.denseOperators),
|
||||
newlineBeforeSemicolon: normalizeBoolean(input.newlineBeforeSemicolon, DEFAULT_SQL_FORMATTER_SETTINGS.newlineBeforeSemicolon),
|
||||
paramTypes: normalizeParamTypes(input.paramTypes, DEFAULT_SQL_FORMATTER_SETTINGS.paramTypes),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSqlFormatterSettings(value: unknown): SqlFormatterSettings {
|
||||
const input = isObject(value) ? value : {};
|
||||
const optionSource = isObject(input.options) ? input.options : input;
|
||||
return sqlFormatterOptionSettings(optionSource);
|
||||
}
|
||||
|
||||
export function sqlFormatterConfigFile(settings: unknown): SqlFormatterConfigFile {
|
||||
const normalized = normalizeSqlFormatterSettings(settings);
|
||||
return {
|
||||
version: SQL_FORMATTER_CONFIG_VERSION,
|
||||
formatter: SQL_FORMATTER_CONFIG_FORMATTER,
|
||||
options: sqlFormatterOptionSettings(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSqlFormatterConfig(settings: unknown): string {
|
||||
return JSON.stringify(sqlFormatterConfigFile(settings), null, 2);
|
||||
}
|
||||
|
||||
export function parseSqlFormatterConfig(text: string): SqlFormatterConfigParseResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, message: "Invalid JSON." };
|
||||
}
|
||||
|
||||
if (!isObject(parsed)) return { ok: false, message: "Config must be a JSON object." };
|
||||
if (parsed.version !== SQL_FORMATTER_CONFIG_VERSION) return { ok: false, message: "Unsupported config version." };
|
||||
if (parsed.formatter !== SQL_FORMATTER_CONFIG_FORMATTER) return { ok: false, message: "Unsupported formatter." };
|
||||
if (!isObject(parsed.options)) return { ok: false, message: "Config options must be a JSON object." };
|
||||
|
||||
const unknownOption = Object.keys(parsed.options).find((key) => !SQL_FORMATTER_OPTION_KEYS.has(key as keyof SqlFormatterOptionSettings) && !SQL_FORMATTER_LEGACY_OPTION_KEYS.has(key));
|
||||
if (unknownOption) return { ok: false, message: `Unknown formatter option: ${unknownOption}.` };
|
||||
if ("params" in parsed.options && parsed.options.params !== null) return { ok: false, message: "Unsupported formatter option: params." };
|
||||
|
||||
const invalidOption = Object.entries(parsed.options).find(([key, value]) => {
|
||||
if (SQL_FORMATTER_LEGACY_OPTION_KEYS.has(key)) return false;
|
||||
const optionKey = key as keyof SqlFormatterOptionSettings;
|
||||
return !SQL_FORMATTER_OPTION_VALIDATORS[optionKey](value);
|
||||
});
|
||||
if (invalidOption) return { ok: false, message: `Invalid formatter option value: ${invalidOption[0]}.` };
|
||||
|
||||
return { ok: true, settings: normalizeSqlFormatterSettings(parsed.options) };
|
||||
}
|
||||
|
||||
export function syncSqlFormatterConfigDraft(text: string, syncSettings: (settings: SqlFormatterSettings) => void): SqlFormatterConfigParseResult {
|
||||
const result = parseSqlFormatterConfig(text);
|
||||
if (result.ok) syncSettings(result.settings);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sqlFormatterOptions(settings: unknown) {
|
||||
const normalized = sqlFormatterOptionSettings(settings);
|
||||
return {
|
||||
keywordCase: normalized.keywordCase,
|
||||
dataTypeCase: normalized.dataTypeCase,
|
||||
functionCase: normalized.functionCase,
|
||||
identifierCase: normalized.identifierCase,
|
||||
indentStyle: normalized.indentStyle,
|
||||
useTabs: normalized.useTabs,
|
||||
tabWidth: normalized.tabWidth,
|
||||
logicalOperatorNewline: normalized.logicalOperatorNewline,
|
||||
expressionWidth: normalized.expressionWidth,
|
||||
linesBetweenQueries: normalized.linesBetweenQueries,
|
||||
denseOperators: normalized.denseOperators,
|
||||
newlineBeforeSemicolon: normalized.newlineBeforeSemicolon,
|
||||
...(normalized.paramTypes !== null ? { paramTypes: normalized.paramTypes } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AppThemeAppearance } from "@/lib/app/appTheme";
|
||||
|
||||
export type SqlHighlighter = (content: string, appearance?: AppThemeAppearance) => string;
|
||||
|
||||
interface ShikiSqlHighlighterOptions {
|
||||
appearance: () => AppThemeAppearance;
|
||||
}
|
||||
|
||||
const SHIKI_THEMES = {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
} as const;
|
||||
|
||||
type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki/core").createHighlighterCore>>;
|
||||
type ShikiCodeHighlighter = Pick<ShikiHighlighter, "codeToHtml">;
|
||||
|
||||
let highlighterPromise: Promise<ShikiHighlighter> | undefined;
|
||||
|
||||
export async function createShikiSqlHighlighter(options: ShikiSqlHighlighterOptions): Promise<SqlHighlighter> {
|
||||
try {
|
||||
const highlighter = await getShikiSqlHighlighter();
|
||||
return createSafeSqlHighlighter(highlighter, options);
|
||||
} catch (error) {
|
||||
console.warn("[DBX][sqlHighlighter] Failed to initialize SQL highlighter:", error);
|
||||
return escapeHtml;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSafeSqlHighlighter(highlighter: ShikiCodeHighlighter, options: ShikiSqlHighlighterOptions): SqlHighlighter {
|
||||
return (content, appearance = options.appearance()) => {
|
||||
try {
|
||||
return highlighter.codeToHtml(content, {
|
||||
lang: "sql",
|
||||
structure: "inline",
|
||||
theme: SHIKI_THEMES[appearance],
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DBX][sqlHighlighter] Failed to highlight SQL:", error);
|
||||
return escapeHtml(content);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function escapeHtml(content: string): string {
|
||||
return content.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getShikiSqlHighlighter(): Promise<ShikiHighlighter> {
|
||||
highlighterPromise ??= loadShikiSqlHighlighter();
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
async function loadShikiSqlHighlighter(): Promise<ShikiHighlighter> {
|
||||
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }, githubDark, githubLight, sql] = await Promise.all([import("shiki/core"), import("shiki/engine/javascript"), import("shiki/themes/github-dark.mjs"), import("shiki/themes/github-light.mjs"), import("shiki/langs/sql.mjs")]);
|
||||
|
||||
return createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
langs: [sql.default],
|
||||
themes: [githubDark.default, githubLight.default],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export type SqlMetadataRefreshScope = "none" | "connection" | "database";
|
||||
export type SqlMetadataRefreshTarget = { scope: "none" } | { scope: "connection" } | { scope: "database"; schema?: string };
|
||||
|
||||
const DATABASE_DDL_RE = /\b(CREATE|DROP)\s+DATABASE\b/i;
|
||||
const SCHEMA_DDL_RE = /\b(CREATE|DROP)\s+SCHEMA\b/i;
|
||||
const OBJECT_DDL_RE = /\b(CREATE|ALTER|DROP|RENAME)\s+(OR\s+REPLACE\s+)?(((GLOBAL|LOCAL)\s+)?TEMP(ORARY)?\s+)?(MATERIALIZED\s+)?(TABLE|VIEW|INDEX|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\b/i;
|
||||
const OBJECT_NAME_DDL_RE =
|
||||
/\b(?:CREATE|ALTER|DROP|RENAME)\s+(?:OR\s+REPLACE\s+)?(?:(?:(?:GLOBAL|LOCAL)\s+)?TEMP(?:ORARY)?\s+)?(?:MATERIALIZED\s+)?(?:TABLE|VIEW|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i;
|
||||
const INDEX_TABLE_DDL_RE = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s+ON\s+((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i;
|
||||
|
||||
function stripSqlMetadataComments(sql: string): string {
|
||||
return sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/--.*$/gm, " ")
|
||||
.replace(/#.*$/gm, " ");
|
||||
}
|
||||
|
||||
export function sqlMetadataRefreshScope(sql: string): SqlMetadataRefreshScope {
|
||||
return sqlMetadataRefreshTarget(sql).scope;
|
||||
}
|
||||
|
||||
function splitSqlMetadataStatements(sql: string): string[] {
|
||||
return stripSqlMetadataComments(sql)
|
||||
.split(";")
|
||||
.map((stmt) => stmt.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unquoteIdentifier(identifier: string): string {
|
||||
const trimmed = identifier.trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("`") && trimmed.endsWith("`")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function schemaFromQualifiedName(qualifiedName: string): string | undefined {
|
||||
const schema = qualifiedName.split(".")[0]?.trim();
|
||||
return schema ? unquoteIdentifier(schema) : undefined;
|
||||
}
|
||||
|
||||
function schemaFromObjectDdl(statement: string): string | undefined {
|
||||
const match = statement.match(INDEX_TABLE_DDL_RE) || statement.match(OBJECT_NAME_DDL_RE);
|
||||
return match?.[1] ? schemaFromQualifiedName(match[1]) : undefined;
|
||||
}
|
||||
|
||||
export function sqlMetadataRefreshTarget(sql: string, activeSchema?: string): SqlMetadataRefreshTarget {
|
||||
const statements = splitSqlMetadataStatements(sql);
|
||||
if (statements.some((stmt) => DATABASE_DDL_RE.test(stmt))) return { scope: "connection" };
|
||||
|
||||
const schemaTargets = new Set<string>();
|
||||
let hasDatabaseRefresh = false;
|
||||
|
||||
for (const statement of statements) {
|
||||
if (SCHEMA_DDL_RE.test(statement)) {
|
||||
hasDatabaseRefresh = true;
|
||||
continue;
|
||||
}
|
||||
if (!OBJECT_DDL_RE.test(statement)) continue;
|
||||
hasDatabaseRefresh = true;
|
||||
const schema = schemaFromObjectDdl(statement) || activeSchema;
|
||||
if (schema) schemaTargets.add(schema);
|
||||
}
|
||||
|
||||
if (!hasDatabaseRefresh) return { scope: "none" };
|
||||
if (schemaTargets.size === 1) return { scope: "database", schema: [...schemaTargets][0] };
|
||||
return { scope: "database" };
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Utilities for SQL identifier navigation (Ctrl/Cmd + click on table/column names).
|
||||
*/
|
||||
|
||||
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 };
|
||||
|
||||
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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
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 identifier at position `pos` in the document. */
|
||||
export function extractIdentifierAt(doc: string, pos: number): string | null {
|
||||
if (pos < 0 || pos > doc.length) return null;
|
||||
|
||||
const clickPos = pos === doc.length ? pos - 1 : pos;
|
||||
if (clickPos < 0) return null;
|
||||
|
||||
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) => part.value).join(".");
|
||||
}
|
||||
index = Math.max(parsed.end, index + 1);
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return 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(identifier: string, tables: Array<{ name: string; schema?: string }>): { name: string; schema?: string } | 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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import type { SqlParameterInput } from "@/lib/sql/sqlParameters";
|
||||
|
||||
const STORAGE_KEY = "dbx-sql-parameter-history";
|
||||
export const MAX_SQL_PARAMETER_HISTORY = 8;
|
||||
|
||||
interface StoredSqlParameterHistory {
|
||||
version: 1;
|
||||
parameters: Record<string, SqlParameterInput[]>;
|
||||
}
|
||||
|
||||
function emptyHistory(): StoredSqlParameterHistory {
|
||||
return { version: 1, parameters: {} };
|
||||
}
|
||||
|
||||
function readHistory(): StoredSqlParameterHistory {
|
||||
const raw = safeLocalStorageGet(STORAGE_KEY);
|
||||
if (!raw) return emptyHistory();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<StoredSqlParameterHistory>;
|
||||
if (parsed.version !== 1 || !parsed.parameters || typeof parsed.parameters !== "object") return emptyHistory();
|
||||
return { version: 1, parameters: parsed.parameters };
|
||||
} catch {
|
||||
return emptyHistory();
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(history: StoredSqlParameterHistory) {
|
||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
function normalizeParameterName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeInput(input: SqlParameterInput): SqlParameterInput | null {
|
||||
if (input.kind === "null") return { kind: "null", value: "NULL" };
|
||||
const value = input.value.trim();
|
||||
if (!value) return null;
|
||||
return { kind: input.kind, value };
|
||||
}
|
||||
|
||||
export function loadSqlParameterHistory(name: string): SqlParameterInput[] {
|
||||
return (readHistory().parameters[normalizeParameterName(name)] ?? []).slice(0, MAX_SQL_PARAMETER_HISTORY);
|
||||
}
|
||||
|
||||
export function rememberSqlParameterValue(name: string, input: SqlParameterInput): SqlParameterInput[] {
|
||||
const normalized = normalizeInput(input);
|
||||
if (!normalized) return loadSqlParameterHistory(name);
|
||||
|
||||
const history = readHistory();
|
||||
const key = normalizeParameterName(name);
|
||||
const previous = history.parameters[key] ?? [];
|
||||
history.parameters[key] = [normalized, ...previous.filter((entry) => entry.value !== normalized.value || entry.kind !== normalized.kind)].slice(0, MAX_SQL_PARAMETER_HISTORY);
|
||||
writeHistory(history);
|
||||
return history.parameters[key];
|
||||
}
|
||||
|
||||
export function rememberSqlParameterValues(values: Record<string, SqlParameterInput>): Record<string, SqlParameterInput[]> {
|
||||
const result: Record<string, SqlParameterInput[]> = {};
|
||||
for (const [name, input] of Object.entries(values)) {
|
||||
result[name] = rememberSqlParameterValue(name, input);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
export type SqlParameterValueKind = "string" | "number" | "boolean" | "null" | "raw";
|
||||
|
||||
export interface SqlParameterInput {
|
||||
kind: SqlParameterValueKind;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ParameterOccurrence {
|
||||
name: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const PARAMETER_NAME_RE = /^[\p{L}_][\p{L}\p{N}_]*$/u;
|
||||
|
||||
export function extractSqlParameters(sql: string): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const occurrence of findSqlParameterOccurrences(sql)) {
|
||||
names.add(occurrence.name);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
export function substituteSqlParameters(sql: string, values: Record<string, SqlParameterInput>): string {
|
||||
const occurrences = findSqlParameterOccurrences(sql);
|
||||
if (!occurrences.length) return sql;
|
||||
|
||||
let result = "";
|
||||
let cursor = 0;
|
||||
for (const occurrence of occurrences) {
|
||||
result += sql.slice(cursor, occurrence.start);
|
||||
result += sqlParameterLiteral(values[occurrence.name] ?? { kind: "string", value: "" });
|
||||
cursor = occurrence.end;
|
||||
}
|
||||
result += sql.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sqlParameterLiteral(input: SqlParameterInput): string {
|
||||
if (input.kind === "null") return "NULL";
|
||||
const raw = input.value;
|
||||
if (input.kind === "raw") return raw.trim() || "NULL";
|
||||
if (input.kind === "number") return raw.trim() || "NULL";
|
||||
if (input.kind === "boolean") return normalizeBooleanLiteral(raw);
|
||||
return quoteSqlString(raw);
|
||||
}
|
||||
|
||||
function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] {
|
||||
const occurrences: ParameterOccurrence[] = [];
|
||||
let i = 0;
|
||||
let dollarQuoteEnd = "";
|
||||
|
||||
while (i < sql.length) {
|
||||
if (dollarQuoteEnd) {
|
||||
const end = sql.indexOf(dollarQuoteEnd, i);
|
||||
if (end === -1) break;
|
||||
i = end + dollarQuoteEnd.length;
|
||||
dollarQuoteEnd = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
i = skipQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
i = skipBracketIdentifier(sql, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === "-" && next === "-") {
|
||||
i = skipLine(sql, i + 2);
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") {
|
||||
i = skipLine(sql, i + 1);
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && next === "*") {
|
||||
i = skipBlockComment(sql, i + 2);
|
||||
continue;
|
||||
}
|
||||
if (ch === "$" && next === "{") {
|
||||
const end = sql.indexOf("}", i + 2);
|
||||
if (end !== -1) {
|
||||
const name = sql.slice(i + 2, end).trim();
|
||||
if (PARAMETER_NAME_RE.test(name)) {
|
||||
occurrences.push({ name, start: i, end: end + 1 });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ch === "$") {
|
||||
const marker = readDollarQuoteMarker(sql, i);
|
||||
if (marker) {
|
||||
dollarQuoteEnd = marker;
|
||||
i += marker.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
function skipQuoted(sql: string, start: number, quote: string): number {
|
||||
let i = start + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === "\\" && quote === "'" && i + 1 < sql.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[i] === quote) {
|
||||
if (sql[i + 1] === quote) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipBracketIdentifier(sql: string, start: number): number {
|
||||
let i = start + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === "]") {
|
||||
if (sql[i + 1] === "]") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipLine(sql: string, start: number): number {
|
||||
const nextNewline = sql.indexOf("\n", start);
|
||||
return nextNewline === -1 ? sql.length : nextNewline + 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(sql: string, start: number): number {
|
||||
const end = sql.indexOf("*/", start);
|
||||
return end === -1 ? sql.length : end + 2;
|
||||
}
|
||||
|
||||
function readDollarQuoteMarker(sql: string, start: number): string {
|
||||
const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
function quoteSqlString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function normalizeBooleanLiteral(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "t" || normalized === "yes" || normalized === "y" || normalized === "1") return "TRUE";
|
||||
if (normalized === "false" || normalized === "f" || normalized === "no" || normalized === "n" || normalized === "0") return "FALSE";
|
||||
return quoteSqlString(value);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface SqlSingleQuoteCaretOptions {
|
||||
previousValue: string;
|
||||
nextValue: string;
|
||||
selectionStart: number | null | undefined;
|
||||
}
|
||||
|
||||
export function insertedSqlSingleQuoteAtCaret(options: SqlSingleQuoteCaretOptions): boolean {
|
||||
const { previousValue, nextValue, selectionStart } = options;
|
||||
if (typeof selectionStart !== "number" || selectionStart < 1) return false;
|
||||
if (nextValue.charAt(selectionStart - 1) !== "'") return false;
|
||||
return nextValue.slice(0, selectionStart - 1) + nextValue.slice(selectionStart) === previousValue;
|
||||
}
|
||||
|
||||
export function caretPositionInsideInsertedSqlSingleQuotes(options: SqlSingleQuoteCaretOptions): number | null {
|
||||
const { previousValue, nextValue, selectionStart } = options;
|
||||
if (typeof selectionStart !== "number" || selectionStart < 2) return null;
|
||||
if (nextValue.slice(selectionStart - 2, selectionStart) !== "''") return null;
|
||||
|
||||
const singleQuoteInsertPrevious = nextValue.slice(0, selectionStart - 1) + nextValue.slice(selectionStart);
|
||||
if (singleQuoteInsertPrevious === previousValue) return selectionStart - 1;
|
||||
|
||||
const pairInsertPrevious = nextValue.slice(0, selectionStart - 2) + nextValue.slice(selectionStart);
|
||||
if (pairInsertPrevious === previousValue) return selectionStart - 1;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export function sqlServerStatementForDerivedTable(statement: string): string {
|
||||
const orderBy = findTopLevelTrailingOrderBy(statement);
|
||||
if (orderBy === undefined) return statement;
|
||||
if (hasTopLevelSelectTop(statement) || hasTopLevelForXml(statement)) return statement;
|
||||
return statement.slice(0, orderBy).trimEnd();
|
||||
}
|
||||
|
||||
function findTopLevelTrailingOrderBy(sql: string): number | undefined {
|
||||
const tokens = topLevelSqlTokens(sql);
|
||||
for (let i = tokens.length - 2; i >= 0; i--) {
|
||||
if (tokens[i].text === "ORDER" && tokens[i + 1]?.text === "BY") {
|
||||
return tokens[i].start;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasTopLevelSelectTop(sql: string): boolean {
|
||||
const tokens = topLevelSqlTokens(sql);
|
||||
const selectIndex = tokens.findIndex((token) => token.text === "SELECT");
|
||||
if (selectIndex < 0) return false;
|
||||
const fromIndex = tokens.findIndex((token, index) => index > selectIndex && token.text === "FROM");
|
||||
const end = fromIndex < 0 ? tokens.length : fromIndex;
|
||||
return tokens.slice(selectIndex + 1, end).some((token) => token.text === "TOP");
|
||||
}
|
||||
|
||||
function hasTopLevelForXml(sql: string): boolean {
|
||||
const tokens = topLevelSqlTokens(sql);
|
||||
return tokens.some((token, index) => token.text === "FOR" && tokens[index + 1]?.text === "XML");
|
||||
}
|
||||
|
||||
function topLevelSqlTokens(sql: string): Array<{ text: string; start: number }> {
|
||||
const tokens: Array<{ text: string; start: number }> = [];
|
||||
let i = 0;
|
||||
let depth = 0;
|
||||
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
if (ch === "-" && next === "-") {
|
||||
i += 2;
|
||||
while (i < sql.length && sql[i] !== "\n") i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "*") {
|
||||
i += 2;
|
||||
while (i < sql.length - 1 && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
||||
i = Math.min(i + 2, sql.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
i = skipSqlQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "[") {
|
||||
i = skipSqlBracketIdentifier(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "(") {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ")") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && isSqlTokenStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < sql.length && isSqlTokenPart(sql[i])) i++;
|
||||
tokens.push({ text: sql.slice(start, i).toUpperCase(), start });
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function skipSqlQuoted(sql: string, pos: number, quote: string): number {
|
||||
let i = pos + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === quote) {
|
||||
if (sql[i + 1] === quote) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
if (quote === "'" && sql[i] === "\\") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipSqlBracketIdentifier(sql: string, pos: number): number {
|
||||
let i = pos + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === "]") {
|
||||
if (sql[i + 1] === "]") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function isSqlTokenStart(ch: string | undefined): boolean {
|
||||
return !!ch && /[A-Za-z_]/.test(ch);
|
||||
}
|
||||
|
||||
function isSqlTokenPart(ch: string | undefined): boolean {
|
||||
return !!ch && /[A-Za-z0-9_$#]/.test(ch);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user