feat(completion): suggest database routines

This commit is contained in:
t8y2 2026-06-03 11:27:31 +08:00
parent 6e82fe1e3f
commit cc74fabad7
13 changed files with 463 additions and 11 deletions

View File

@ -57,7 +57,7 @@ import {
shouldRunSqlSemanticDiagnostics,
type SqlSemanticDiagnostic,
} from "@/lib/sqlSemanticDiagnostics";
import type { SqlCompletionColumn, SqlCompletionForeignKey } from "@/lib/sqlCompletion";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject } from "@/lib/sqlCompletion";
import type {
DatabaseType,
ForeignKeyInfo,
@ -201,6 +201,7 @@ function editorThemeAppearance() {
// Completion cache
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
let cachedCompletionObjects: SqlCompletionObject[] = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
@ -892,6 +893,7 @@ async function provideSqlCompletions(
if (!hasDatabase) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
tables: [],
objects: [],
columnsByTable: new Map(),
schemas: [],
translations: completionTranslations.value,
@ -903,6 +905,8 @@ async function provideSqlCompletions(
const needsAsyncData =
completionContext.suggestTables ||
completionContext.suggestRoutines ||
completionContext.exclusiveRoutineSuggestions ||
!!completionContext.qualifier ||
!!completionContext.insertTable ||
completionContext.exclusiveColumnSuggestions ||
@ -911,6 +915,7 @@ async function provideSqlCompletions(
if (!needsAsyncData) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
tables: [],
objects: [],
columnsByTable: new Map(),
schemas: [],
translations: completionTranslations.value,
@ -990,6 +995,35 @@ async function performAsyncCompletionWithResult(
: cachedTables;
if (epoch !== completionEpoch) return null;
const shouldLoadObjects =
completionContext.suggestRoutines ||
completionContext.exclusiveRoutineSuggestions ||
(!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
let completionObjects = shouldLoadObjects
? await connectionStore.listCompletionObjects(
props.connectionId!,
props.database!,
completionContext.qualifier || completionContext.prefix,
MAX_COMPLETION_TABLES,
)
: cachedCompletionObjects;
if (epoch !== completionEpoch) return null;
if (completionContext.qualifier && completionObjects.length === 0) {
const schemaObjects = await connectionStore.listCompletionObjects(
props.connectionId!,
props.database!,
completionContext.prefix,
MAX_COMPLETION_TABLES,
completionContext.qualifier,
);
if (schemaObjects.length > 0) {
completionObjects = schemaObjects;
}
if (epoch !== completionEpoch) return null;
}
cachedCompletionObjects = mergeCompletionObjects(cachedCompletionObjects, completionObjects);
// Fetch schemas for schema completion
let schemaNames: string[] = [];
if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) {
@ -1142,6 +1176,7 @@ async function performAsyncCompletionWithResult(
const items = buildSqlCompletionItemsFromContext(effectiveContext, {
tables,
objects: completionObjects,
columnsByTable,
foreignKeysByTable,
schemas: schemaNames,
@ -1161,8 +1196,25 @@ function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlC
);
}
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
const merged = [...existing];
const seen = new Set(
existing.map((object) =>
`${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase(),
),
);
for (const object of incoming) {
const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(object);
}
return merged;
}
async function refreshCompletionCache() {
cachedTables = [];
cachedCompletionObjects = [];
cachedColumnsByTable.clear();
cachedForeignKeysByTable.clear();
}
@ -1566,6 +1618,7 @@ onMounted(async () => {
registerTableReferenceDropListener();
cachedTables = [];
cachedCompletionObjects = [];
scheduleSemanticDiagnostics();
});

View File

@ -74,6 +74,7 @@ export const deleteSchemaCachePrefix = forward("deleteSchemaCachePrefix");
export const listSchemas = forward("listSchemas");
export const listTables = forward("listTables");
export const listObjects = forward("listObjects");
export const listCompletionObjects = forward("listCompletionObjects");
export const getObjectSource = forward("getObjectSource");
export const getColumns = forward("getColumns");
export const listIndexes = forward("listIndexes");

View File

@ -372,6 +372,14 @@ export async function listObjects(connectionId: string, database: string, schema
return get(`/api/schema/objects?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function listCompletionObjects(
connectionId: string,
database: string,
schema: string,
): Promise<ObjectInfo[]> {
return get(`/api/schema/completion-objects?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function getObjectSource(
connectionId: string,
database: string,

View File

@ -600,6 +600,14 @@ export interface SqlCompletionTable {
type?: "table" | "view";
}
export interface SqlCompletionObject {
name: string;
schema?: string;
type: "procedure" | "function" | "trigger";
parentSchema?: string;
parentName?: string;
}
export interface SqlCompletionColumn {
name: string;
table: string;
@ -639,9 +647,11 @@ export interface SqlCompletionContext {
suggestTables: boolean;
suggestColumns: boolean;
suggestKeywords: boolean;
suggestRoutines: boolean;
suggestJoinConditions: boolean;
exclusiveTableSuggestions: boolean;
exclusiveColumnSuggestions: boolean;
exclusiveRoutineSuggestions: boolean;
prioritizeSelectAliases: boolean;
selectAliases: string[];
referencedTables: SqlCompletionReferencedTable[];
@ -678,6 +688,7 @@ export function buildSqlCompletionItems(
cursor: number,
input: {
tables: SqlCompletionTable[];
objects?: SqlCompletionObject[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>;
schemas?: string[];
@ -693,6 +704,7 @@ export function buildSqlCompletionItemsFromContext(
context: SqlCompletionContext,
input: {
tables: SqlCompletionTable[];
objects?: SqlCompletionObject[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
foreignKeysByTable?: Map<string, SqlCompletionForeignKey[]>;
schemas?: string[];
@ -705,29 +717,44 @@ export function buildSqlCompletionItemsFromContext(
const t = input.translations;
const dialect = input.dialect;
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions) {
if (
!context.exclusiveTableSuggestions &&
!context.exclusiveColumnSuggestions &&
!context.exclusiveRoutineSuggestions
) {
items.push(...buildSnippetItems(context.prefix, input.snippets ?? DEFAULT_SQL_SNIPPETS));
items.push(...buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(t)));
}
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.prioritizeSelectAliases) {
if (
!context.exclusiveTableSuggestions &&
!context.exclusiveColumnSuggestions &&
!context.exclusiveRoutineSuggestions &&
context.prioritizeSelectAliases
) {
items.push(...buildSelectAliasItems(context));
}
if (
!context.exclusiveTableSuggestions &&
!context.exclusiveColumnSuggestions &&
!context.exclusiveRoutineSuggestions &&
context.isGroupBy &&
context.nonAggregatedSelectColumns.length > 0
) {
items.push(...buildNonAggregatedColumnItems(context, input.columnsByTable, dialect));
}
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.suggestJoinConditions) {
if (
!context.exclusiveTableSuggestions &&
!context.exclusiveColumnSuggestions &&
!context.exclusiveRoutineSuggestions &&
context.suggestJoinConditions
) {
items.push(...buildJoinConditionItems(context, input.columnsByTable, input.foreignKeysByTable, dialect));
}
if (context.suggestKeywords) {
if (context.suggestKeywords && !context.exclusiveRoutineSuggestions) {
items.push(...buildKeywordItems(context.prefix, context));
}
@ -747,6 +774,10 @@ export function buildSqlCompletionItemsFromContext(
}
}
if (context.suggestRoutines || context.exclusiveRoutineSuggestions) {
items.push(...buildObjectItems(context, input.objects ?? [], dialect));
}
// Type-aware value hints after comparison operator
if (context.comparisonLeftColumn && context.suggestKeywords) {
items.push(...buildComparisonValueItems(context, input.columnsByTable, t));
@ -765,9 +796,17 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea
const previousChar = sql[cursor - 1];
if (!previousChar) return false;
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(sql.slice(0, cursor))) return true;
if (/[,;()[\]]/.test(previousChar)) return false;
const context = getSqlCompletionContext(sql, cursor);
if (context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions || context.suggestTables) return true;
if (
context.exclusiveTableSuggestions ||
context.exclusiveColumnSuggestions ||
context.exclusiveRoutineSuggestions ||
context.suggestTables
) {
return true;
}
return /[\w$.]/.test(previousChar);
}
@ -859,6 +898,13 @@ function detectStatementKind(previousStatements: string): SqlStatementKind {
return kindMap[firstWord] ?? "unknown";
}
function isCallRoutineContext(beforeToken: string): boolean {
return (
/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(beforeToken) ||
/\bcall\s+(?:[A-Za-z_][\w$]*\.)?[A-Za-z_][\w$]*$/i.test(beforeToken)
);
}
export function getSqlCompletionContext(sql: string, cursor: number): SqlCompletionContext {
// Extract the full statement at cursor position for referenced tables
const fullStatement = extractStatementAt(sql, cursor);
@ -917,6 +963,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
const inColumnContext = isInColumnContext(beforeCursor) || !!insertInfo;
const inJoinConditionContext = isInJoinConditionContext(beforeCursor);
const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor);
const inCallRoutineContext = isCallRoutineContext(beforeCursor);
const statementKind = detectStatementKind(beforeCursor || fullStatement);
@ -925,10 +972,14 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
qualifier: insertInfo ? undefined : qualifier,
suggestTables: insertInfo ? false : afterTableTrigger,
suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0),
suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo,
suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !inCallRoutineContext,
suggestRoutines:
inCallRoutineContext ||
(!exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2),
suggestJoinConditions: insertInfo ? false : inJoinConditionContext && referencedTables.length >= 2,
exclusiveTableSuggestions: insertInfo ? false : exclusiveTableSuggestions,
exclusiveColumnSuggestions: exclusiveColumnSuggestions || !!insertInfo,
exclusiveRoutineSuggestions: inCallRoutineContext,
prioritizeSelectAliases: insertInfo ? false : prioritizeSelectAliases,
selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [],
referencedTables,
@ -1547,6 +1598,39 @@ function buildSchemaItems(
}));
}
function buildObjectItems(
context: SqlCompletionContext,
objects: SqlCompletionObject[],
dialect?: "mysql" | "postgres" | "sqlserver",
): SqlCompletionItem[] {
const onlyProcedures = context.exclusiveRoutineSuggestions;
return objects
.filter((object) => (!onlyProcedures || object.type === "procedure") && matchesPrefix(object.name, context.prefix))
.map((object) => {
const applyName =
context.qualifier && object.schema?.toLowerCase() === context.qualifier.toLowerCase()
? quoteSqlIdentifier(object.name, dialect)
: object.schema
? `${quoteSqlIdentifier(object.schema, dialect)}.${quoteSqlIdentifier(object.name, dialect)}`
: quoteSqlIdentifier(object.name, dialect);
const detail =
object.type === "trigger" && object.parentName
? `trigger on ${object.parentName}`
: object.schema
? `${object.type} in ${object.schema}`
: object.type;
return {
label: object.name,
type: "function" as const,
detail,
apply: object.type === "trigger" ? applyName : `${applyName}()`,
boost: computeBoost(object.name, context.prefix) + (object.type === "procedure" ? 1800 : 900),
};
})
.sort(compareCompletionItems)
.slice(0, MAX_TABLE_COMPLETION_ITEMS);
}
function buildStarExpansionItem(
columnsByTable: Map<string, SqlCompletionColumn[]>,
t?: SqlCompletionTranslations,

View File

@ -407,6 +407,14 @@ export async function listObjects(connectionId: string, database: string, schema
return invoke("list_objects", { connectionId, database, schema });
}
export async function listCompletionObjects(
connectionId: string,
database: string,
schema: string,
): Promise<ObjectInfo[]> {
return invoke("list_completion_objects", { connectionId, database, schema });
}
export async function getObjectSource(
connectionId: string,
database: string,

View File

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { uuid } from "@/lib/utils";
import { ref, computed, watch } from "vue";
import type { ColumnInfo, ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, ObjectInfo, SidebarLayout, TreeNode } from "@/types/database";
import { applyPinnedTreeNodeState, orderPinnedFirst } from "@/lib/pinnedItems";
import {
reconcileLayout,
@ -17,7 +17,7 @@ import {
reorderEntry as reorderEntryOp,
type DropPosition,
} from "@/lib/sidebarLayout";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
import type { SqlCompletionColumn, SqlCompletionObject, SqlCompletionTable } from "@/lib/sqlCompletion";
import * as api from "@/lib/api";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { isSchemaAware, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
@ -98,6 +98,7 @@ export const useConnectionStore = defineStore("connection", () => {
const editingConnectionId = ref<string | null>(null);
const newConnectionGroupId = ref<string | null>(null);
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
const completionObjectsCache = ref<Record<string, SqlCompletionObject[]>>({});
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
@ -552,6 +553,9 @@ export const useConnectionStore = defineStore("connection", () => {
for (const key of Object.keys(completionTablesCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionTablesCache.value[key];
}
for (const key of Object.keys(completionObjectsCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionObjectsCache.value[key];
}
for (const key of Object.keys(completionColumnsCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionColumnsCache.value[key];
}
@ -1579,6 +1583,105 @@ export const useConnectionStore = defineStore("connection", () => {
return deduped;
}
async function listCompletionObjects(
connectionId: string,
database: string,
filter = "",
limit?: number,
schema?: string,
): Promise<SqlCompletionObject[]> {
const normalizedFilter = filter.trim().toLowerCase();
const cacheKey = `${connectionId}:${database}:${schema ?? ""}`;
if (!completionObjectsCache.value[cacheKey]) {
await ensureConnected(connectionId);
const objects = isSchemaAwareDatabase(connectionId)
? await listSchemaAwareCompletionObjects(connectionId, database, schema)
: await api.listCompletionObjects(connectionId, database, schema || database);
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(
objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null),
);
evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX);
}
const objects = completionObjectsCache.value[cacheKey];
const filtered = normalizedFilter
? objects.filter((object) => fuzzyCompletionObjectMatch(object, normalizedFilter))
: objects;
return typeof limit === "number" ? filtered.slice(0, expandedCompletionLimit(limit)) : filtered;
}
async function listSchemaAwareCompletionObjects(
connectionId: string,
database: string,
schema?: string,
): Promise<ObjectInfo[]> {
const schemas = schema ? [schema] : await listCompletionSchemas(connectionId, database);
const batchSize = 5;
const results: ObjectInfo[] = [];
for (let i = 0; i < schemas.length; i += batchSize) {
const batch = schemas.slice(i, i + batchSize);
const groups = await Promise.all(
batch.map(async (s) => {
try {
return await api.listCompletionObjects(connectionId, database, s);
} catch {
return [] as ObjectInfo[];
}
}),
);
for (const group of groups) results.push(...group);
}
return results;
}
function toSqlCompletionObject(object: ObjectInfo): SqlCompletionObject | null {
const objectType = object.object_type.toUpperCase();
const type = objectType.includes("PROCEDURE")
? "procedure"
: objectType.includes("FUNCTION")
? "function"
: objectType.includes("TRIGGER")
? "trigger"
: null;
if (!type) return null;
return {
name: object.name,
schema: object.schema ?? undefined,
type,
parentSchema: object.parent_schema ?? undefined,
parentName: object.parent_name ?? undefined,
};
}
function fuzzyCompletionObjectMatch(object: SqlCompletionObject, filter: string): boolean {
return fuzzyTextMatch(object.name, filter) || (!!object.schema && fuzzyTextMatch(object.schema, filter));
}
function fuzzyTextMatch(value: string, filter: string): boolean {
if (!filter) return true;
const text = value.toLowerCase();
if (text.includes(filter)) return true;
let index = 0;
for (const ch of filter) {
index = text.indexOf(ch, index);
if (index < 0) return false;
index++;
}
return true;
}
function dedupeCompletionObjects(objects: SqlCompletionObject[]): SqlCompletionObject[] {
const seen = new Set<string>();
const deduped: SqlCompletionObject[] = [];
for (const object of objects) {
const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(object);
}
return deduped;
}
async function listCompletionColumns(
connectionId: string,
database: string,
@ -1987,6 +2090,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadForeignKeys,
loadTriggers,
listCompletionTables,
listCompletionObjects,
listCompletionColumns,
listCompletionSchemas,
exportConnectionsToFile,

View File

@ -744,6 +744,7 @@ fn list_tables_objects_sql(database: &str) -> String {
TABLE_COMMENT AS object_comment, \
CREATE_TIME AS created_at, \
UPDATE_TIME AS updated_at, \
NULL AS parent_schema, NULL AS parent_name, \
CASE WHEN TABLE_TYPE = 'VIEW' THEN 1 ELSE 0 END AS sort_order \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {db} \
@ -756,6 +757,7 @@ fn list_routines_sql(database: &str) -> String {
format!(
"SELECT ROUTINE_NAME AS object_name, ROUTINE_TYPE AS object_type, NULL AS object_comment, \
NULL AS created_at, NULL AS updated_at, \
NULL AS parent_schema, NULL AS parent_name, \
CASE WHEN ROUTINE_TYPE = 'PROCEDURE' THEN 2 ELSE 3 END AS sort_order \
FROM information_schema.ROUTINES \
WHERE ROUTINE_SCHEMA = {db} AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION') \
@ -764,6 +766,19 @@ fn list_routines_sql(database: &str) -> String {
)
}
fn list_completion_triggers_sql(database: &str) -> String {
format!(
"SELECT TRIGGER_NAME AS object_name, 'TRIGGER' AS object_type, NULL AS object_comment, \
CREATED AS created_at, NULL AS updated_at, \
TRIGGER_SCHEMA AS parent_schema, EVENT_OBJECT_TABLE AS parent_name, \
4 AS sort_order \
FROM information_schema.TRIGGERS \
WHERE TRIGGER_SCHEMA = {db} \
ORDER BY object_name",
db = quote_value(database),
)
}
fn row_to_object(row: &mysql_async::Row, database: &str) -> ObjectInfo {
ObjectInfo {
name: get_str_by_name(row, "object_name"),
@ -772,8 +787,8 @@ fn row_to_object(row: &mysql_async::Row, database: &str) -> ObjectInfo {
comment: get_opt_str(row, "object_comment").filter(|s| !s.is_empty()),
created_at: get_opt_str(row, "created_at"),
updated_at: get_opt_str(row, "updated_at"),
parent_schema: None,
parent_name: None,
parent_schema: get_opt_str(row, "parent_schema"),
parent_name: get_opt_str(row, "parent_name"),
}
}
@ -806,6 +821,31 @@ pub async fn list_objects(pool: &MySqlPool, database: &str) -> Result<Vec<Object
Ok(objects)
}
pub async fn list_completion_objects(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
let mut objects = Vec::new();
let routines_sql = list_routines_sql(database);
match conn.query_iter(&routines_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(rows) => objects.extend(rows.iter().map(|row| row_to_object(row, database))),
Err(e) => log::warn!("Skipping routines for completion in database `{}`: {}", database, e),
},
Err(e) => log::warn!("Skipping routines for completion in database `{}`: {}", database, e),
}
let triggers_sql = list_completion_triggers_sql(database);
match conn.query_iter(&triggers_sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(rows) => objects.extend(rows.iter().map(|row| row_to_object(row, database))),
Err(e) => log::warn!("Skipping triggers for completion in database `{}`: {}", database, e),
},
Err(e) => log::warn!("Skipping triggers for completion in database `{}`: {}", database, e),
}
Ok(objects)
}
fn columns_sql(database: &str, table: &str) -> String {
format!(
"SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, c.COLUMN_COMMENT, \
@ -1289,6 +1329,16 @@ mod tests {
assert!(!sql.contains("CREATED AS created_at"));
}
#[test]
fn mysql_completion_triggers_sql_lists_database_triggers() {
let sql = list_completion_triggers_sql("app");
assert!(sql.contains("information_schema.TRIGGERS"));
assert!(sql.contains("'TRIGGER' AS object_type"));
assert!(sql.contains("EVENT_OBJECT_TABLE AS parent_name"));
assert!(sql.contains("TRIGGER_SCHEMA = 'app'"));
}
#[test]
fn mysql_columns_sql_joins_key_column_usage_for_primary_keys() {
let sql = columns_sql("app", "users");

View File

@ -519,6 +519,18 @@ pub async fn list_objects_core(
.await
}
pub async fn list_completion_objects_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
) -> Result<Vec<db::ObjectInfo>, String> {
retry_metadata_connection(state, connection_id, Some(database), || {
list_completion_objects_once(state, connection_id, database, schema)
})
.await
}
async fn list_objects_once(
state: &AppState,
connection_id: &str,
@ -608,6 +620,68 @@ async fn list_objects_once(
}
}
async fn list_completion_objects_once(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
) -> Result<Vec<db::ObjectInfo>, String> {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
let db_config = connection_config(state, connection_id).await;
let connections = state.connections.read().await;
if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) {
let config = config.clone();
let session = session.clone();
drop(connections);
return session
.invoke::<Vec<db::ObjectInfo>>(
"listObjects",
serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema }),
)
.await
.map(filter_completion_objects);
}
if let Some(client) = extract_pool!(&connections, &pool_key, Agent) {
let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle);
drop(connections);
let objects = if is_oracle {
oracle_agent_list_objects(client, database, schema).await?
} else {
let mut client = client.lock().await;
client.list_objects(database, schema).await?
};
return Ok(filter_completion_objects(objects));
}
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, mode) if *mode != MysqlMode::OceanBaseOracle => {
db::mysql::list_completion_objects(p, database).await
}
PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => {
db::ob_oracle::list_objects(p, schema).await.map(filter_completion_objects)
}
PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(filter_completion_objects),
PoolKind::SqlServer(_) => {
drop(connections);
let objects = list_objects_once(state, connection_id, database, schema).await?;
Ok(filter_completion_objects(objects))
}
_ => Ok(Vec::new()),
}
}
fn filter_completion_objects(objects: Vec<db::ObjectInfo>) -> Vec<db::ObjectInfo> {
objects
.into_iter()
.filter(|object| {
let object_type = object.object_type.to_ascii_uppercase();
object_type.contains("PROCEDURE") || object_type.contains("FUNCTION") || object_type.contains("TRIGGER")
})
.collect()
}
async fn retry_metadata_connection<T, F, Fut>(
state: &AppState,
connection_id: &str,

View File

@ -120,6 +120,7 @@ async fn main() {
.route("/schema/schemas", get(routes::schema::list_schemas))
.route("/schema/tables", get(routes::schema::list_tables))
.route("/schema/objects", get(routes::schema::list_objects))
.route("/schema/completion-objects", get(routes::schema::list_completion_objects))
.route("/schema/object-source", get(routes::schema::get_object_source))
.route("/schema/columns", get(routes::schema::list_columns))
.route("/schema/indexes", get(routes::schema::list_indexes))

View File

@ -65,6 +65,18 @@ pub async fn list_objects(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn list_completion_objects(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let result = dbx_core::schema::list_completion_objects_core(&state.app, &q.connection_id, database, schema)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn get_object_source(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,

View File

@ -10,6 +10,7 @@ import {
recordCompletionSelection,
type SqlCompletionColumn,
type SqlCompletionForeignKey,
type SqlCompletionObject,
type SqlCompletionTable,
} from "../../apps/desktop/src/lib/sqlCompletion.ts";
@ -39,6 +40,12 @@ const columnsByTable = new Map<string, SqlCompletionColumn[]>([
],
]);
const completionObjects: SqlCompletionObject[] = [
{ name: "refresh_user_stats", schema: "app", type: "procedure" },
{ name: "format_user_name", schema: "app", type: "function" },
{ name: "trg_users_audit", schema: "app", type: "trigger", parentName: "users" },
];
const postgresQuotedTables: SqlCompletionTable[] = [
{ name: "article", schema: "public", type: "table" },
{ name: "order_lines", schema: "public", type: "table" },
@ -515,6 +522,45 @@ test("suggests DATE_FORMAT as parameter snippet", () => {
assert.equal(snippet.apply, "DATE_FORMAT(${date}, ${format})");
});
test("suggests stored procedures after CALL", () => {
const sql = "CALL rfs";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
objects: completionObjects,
columnsByTable,
dialect: "mysql",
});
const procedure = items.find((item) => item.label === "refresh_user_stats");
assert.ok(procedure);
assert.equal(procedure.type, "function");
assert.equal(procedure.apply, "app.refresh_user_stats()");
assert.equal(
items.some((item) => item.label === "format_user_name"),
false,
);
});
test("suggests user functions and triggers with fuzzy matching", () => {
const sql = "select fun";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
objects: completionObjects,
columnsByTable,
dialect: "mysql",
});
assert.ok(items.some((item) => item.label === "format_user_name" && item.detail === "function in app"));
const triggerItems = buildSqlCompletionItems("drop trigger tua", "drop trigger tua".length, {
tables,
objects: completionObjects,
columnsByTable,
dialect: "mysql",
});
assert.ok(triggerItems.some((item) => item.label === "trg_users_audit" && item.detail === "trigger on users"));
});
test("matches alias qualifier case-insensitively", () => {
const sql = "select O. from public.orders o";
const cursor = "select O.".length;

View File

@ -43,6 +43,16 @@ pub async fn list_objects(
dbx_core::schema::list_objects_core(&state, &connection_id, &database, &schema).await
}
#[tauri::command]
pub async fn list_completion_objects(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
) -> Result<Vec<db::ObjectInfo>, String> {
dbx_core::schema::list_completion_objects_core(&state, &connection_id, &database, &schema).await
}
#[tauri::command]
pub async fn get_object_source(
state: State<'_, Arc<AppState>>,

View File

@ -380,6 +380,7 @@ pub fn run() {
commands::schema::list_databases,
commands::schema::list_tables,
commands::schema::list_objects,
commands::schema::list_completion_objects,
commands::schema::get_object_source,
commands::schema::list_schemas,
commands::schema::get_columns,