feat(redis): add Redis command completion to query editor
- add completion module: command names, two-stage subcommands, and key names - key-name completion is async with a per-db cache capped at 1000 entries, cleared on disconnect - gate key completion by argument position: stop after a single-key command is filled, keep suggesting for variadic key-list commands - reuse the diagnostics command table as the data source, showing group/arity/safety
This commit is contained in:
1 parent
2a59d3143e
commit
1e96937455
4 files changed
+444
-1
No files matched your search
@@ -38,6 +38,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport";
|
||||
import * as api from "@/lib/api";
|
||||
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics";
|
||||
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redisSyntaxDiagnostics";
|
||||
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redisCompletion";
|
||||
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sqlCompletion";
|
||||
import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
|
||||
@@ -874,7 +875,7 @@ function unregisterTableReferenceDropListener() {
|
||||
let completionEpoch = 0;
|
||||
let completionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem;
|
||||
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem;
|
||||
|
||||
function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) {
|
||||
if (items.length === 0) return null;
|
||||
@@ -969,6 +970,29 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror
|
||||
return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor());
|
||||
}
|
||||
|
||||
async function provideRedisCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (!props.connectionId) return null;
|
||||
const epoch = ++completionEpoch;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (!explicit && !shouldAutoOpenRedisCompletion(fullDoc, position)) return null;
|
||||
|
||||
const completionContext = getRedisCompletionContext(fullDoc, position);
|
||||
// Key-name completion needs a reliable db index; props.database may briefly be "" on
|
||||
// the New Query path before the active db resolves, and only key-argument commands warrant it.
|
||||
let keys: string[] = [];
|
||||
if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.mainCommand)) {
|
||||
try {
|
||||
keys = await connectionStore.listRedisCompletionKeys(props.connectionId, props.database);
|
||||
} catch {
|
||||
keys = [];
|
||||
}
|
||||
}
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildRedisCompletionItemsFromContext(completionContext, { keys });
|
||||
return buildCompletionResult(items, completionContext.from, getRedisCompletionResultValidFor());
|
||||
}
|
||||
|
||||
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null;
|
||||
if (!props.connectionId) return null;
|
||||
@@ -978,6 +1002,9 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
|
||||
return provideElasticsearchCompletions(currentState, position, explicit);
|
||||
}
|
||||
}
|
||||
if (props.databaseType === "redis") {
|
||||
return provideRedisCompletions(currentState, position, explicit);
|
||||
}
|
||||
const hasDatabase = props.database != null;
|
||||
|
||||
const epoch = ++completionEpoch;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Redis command autocompletion for the query editor. Mirrors the shape of
|
||||
* `elasticsearchCompletion.ts` so the editor's completion pipeline (the single
|
||||
* `autocompletion({ override })` in QueryEditor.vue) can dispatch to it.
|
||||
*
|
||||
* Data source: the static `REDIS_COMMAND_TABLE` (built for diagnostics). Keys
|
||||
* are UPPER CASE command names; two-token subcommands are keyed as `"MAIN SUB"`
|
||||
* (e.g. `"XGROUP CREATE"`). We split those into a main-name index + subcommand
|
||||
* index here.
|
||||
*/
|
||||
import { REDIS_COMMAND_TABLE } from "@/lib/redisCommandTable";
|
||||
import type { RedisCommandSpec } from "@/lib/redisCommandTable";
|
||||
|
||||
export interface RedisCompletionItem {
|
||||
label: string;
|
||||
type: "keyword" | "text"; // command/subcommand=keyword, key name=text
|
||||
detail?: string; // single-line, e.g. "string · confirm"
|
||||
info?: string; // multi-line: Group / Arity / Safety
|
||||
apply?: string;
|
||||
boost: number;
|
||||
}
|
||||
|
||||
export interface RedisCompletionContext {
|
||||
mode: "command" | "subcommand" | "argument";
|
||||
prefix: string;
|
||||
/** Absolute document offset where the completion starts. */
|
||||
from: number;
|
||||
/** Upper-cased main command already typed, when known. */
|
||||
mainCommand?: string;
|
||||
/** In argument mode: 0-based index of the argument position (after the command head). */
|
||||
argumentIndex?: number;
|
||||
}
|
||||
|
||||
export interface RedisCompletionInput {
|
||||
keys?: string[];
|
||||
}
|
||||
|
||||
// ---- Static indexes derived from the command table ----
|
||||
|
||||
interface MainCommandEntry {
|
||||
name: string;
|
||||
spec: RedisCommandSpec;
|
||||
}
|
||||
|
||||
interface SubcommandEntry {
|
||||
main: string;
|
||||
sub: string;
|
||||
spec: RedisCommandSpec;
|
||||
}
|
||||
|
||||
const MAIN_COMMANDS: MainCommandEntry[] = [];
|
||||
const SUBCOMMANDS: SubcommandEntry[] = [];
|
||||
const SUBCOMMAND_MAINS = new Set<string>();
|
||||
|
||||
for (const [key, spec] of Object.entries(REDIS_COMMAND_TABLE)) {
|
||||
const space = key.indexOf(" ");
|
||||
if (space < 0) {
|
||||
MAIN_COMMANDS.push({ name: key, spec });
|
||||
} else {
|
||||
const main = key.slice(0, space);
|
||||
const sub = key.slice(space + 1);
|
||||
SUBCOMMANDS.push({ main, sub, spec });
|
||||
SUBCOMMAND_MAINS.add(main);
|
||||
// A main command that only has subcommand forms still needs a main-name
|
||||
// entry so the user can complete the main token first.
|
||||
if (!REDIS_COMMAND_TABLE[main]) MAIN_COMMANDS.push({ name: main, spec });
|
||||
}
|
||||
}
|
||||
|
||||
// Groups whose first argument is a key name (enable key completion there).
|
||||
const KEY_ARGUMENT_GROUPS = new Set(["string", "generic", "list", "hash", "set", "zset", "bitmap", "hyperloglog", "geo", "stream"]);
|
||||
|
||||
// Commands that accept a variadic list of keys (keep suggesting keys beyond the
|
||||
// first argument slot). Most key commands take exactly one key; these keep going.
|
||||
const MULTI_KEY_COMMANDS = new Set(["DEL", "UNLINK", "EXISTS", "TOUCH", "MGET"]);
|
||||
|
||||
// Boost tuning: common groups surface higher.
|
||||
const GROUP_BOOST: Record<string, number> = {
|
||||
string: 110,
|
||||
generic: 108,
|
||||
connection: 100,
|
||||
server: 96,
|
||||
};
|
||||
|
||||
function describeArity(arity: number): string {
|
||||
if (arity > 0) {
|
||||
const n = arity - 1;
|
||||
return `exactly ${n} argument${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (arity < 0) {
|
||||
const n = -arity - 1;
|
||||
return `at least ${n} argument${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
return "variable arguments";
|
||||
}
|
||||
|
||||
function matchesPrefix(value: string, prefix: string): boolean {
|
||||
return value.toLowerCase().startsWith(prefix.toLowerCase());
|
||||
}
|
||||
|
||||
function buildSpecDetail(spec: RedisCommandSpec): string {
|
||||
return spec.safety === "allowed" ? spec.group : `${spec.group} · ${spec.safety}`;
|
||||
}
|
||||
|
||||
function buildSpecInfo(spec: RedisCommandSpec, label: string): string {
|
||||
return [`Command: ${label}`, `Group: ${spec.group}`, `Arity: ${describeArity(spec.arity)}`, `Safety: ${spec.safety}`].join("\n");
|
||||
}
|
||||
|
||||
function boostFor(spec: RedisCommandSpec): number {
|
||||
return GROUP_BOOST[spec.group] ?? 90;
|
||||
}
|
||||
|
||||
// ---- Context parsing ----
|
||||
|
||||
export function getRedisCompletionContext(text: string, cursor: number): RedisCompletionContext {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
||||
const lineStart = text.lastIndexOf("\n", safeCursor - 1) + 1;
|
||||
const beforeCursor = text.slice(lineStart, safeCursor);
|
||||
|
||||
// Tokenize the part before the cursor by whitespace.
|
||||
const tokens = beforeCursor.trimStart().length === 0 ? [] : beforeCursor.trim().split(/\s+/);
|
||||
const endsWithSpace = beforeCursor.length > 0 && /\s$/.test(beforeCursor);
|
||||
|
||||
// Current word being typed (no trailing space yet).
|
||||
const currentWord = endsWithSpace ? "" : (tokens[tokens.length - 1] ?? "");
|
||||
const wordStartFromEnd = currentWord.length;
|
||||
const from = safeCursor - wordStartFromEnd;
|
||||
|
||||
const typedTokens = endsWithSpace ? tokens : tokens.slice(0, -1);
|
||||
|
||||
// No command yet (or typing the very first token).
|
||||
if (typedTokens.length === 0) {
|
||||
return { mode: "command", prefix: currentWord, from };
|
||||
}
|
||||
|
||||
const main = typedTokens[0]!.toUpperCase();
|
||||
|
||||
// First token done + space → maybe a subcommand of a command that has them.
|
||||
if (typedTokens.length === 1 && SUBCOMMAND_MAINS.has(main)) {
|
||||
return { mode: "subcommand", prefix: currentWord, from, mainCommand: main };
|
||||
}
|
||||
|
||||
// Past the command (and any subcommand slot) → an argument. Track which
|
||||
// argument position the cursor is at so we only suggest key names for the
|
||||
// first key argument (e.g. GET <key>, not after the key is filled in).
|
||||
const commandHeadTokens = typedTokens.length >= 2 && REDIS_COMMAND_TABLE[`${main} ${typedTokens[1]!.toUpperCase()}`] ? 2 : 1;
|
||||
const argumentIndex = Math.max(typedTokens.length - commandHeadTokens, 0);
|
||||
return { mode: "argument", prefix: currentWord, from, mainCommand: main, argumentIndex };
|
||||
}
|
||||
|
||||
// ---- Item builders ----
|
||||
|
||||
function commandItems(prefix: string): RedisCompletionItem[] {
|
||||
const items = MAIN_COMMANDS.filter((entry) => matchesPrefix(entry.name, prefix)).map((entry) => ({
|
||||
label: entry.name,
|
||||
type: "keyword" as const,
|
||||
detail: buildSpecDetail(entry.spec),
|
||||
info: buildSpecInfo(entry.spec, entry.name),
|
||||
boost: boostFor(entry.spec),
|
||||
}));
|
||||
return items.sort((a, b) => b.boost - a.boost);
|
||||
}
|
||||
|
||||
function subcommandItems(main: string, prefix: string): RedisCompletionItem[] {
|
||||
const items = SUBCOMMANDS.filter((entry) => entry.main === main && matchesPrefix(entry.sub, prefix)).map((entry) => ({
|
||||
label: entry.sub,
|
||||
type: "keyword" as const,
|
||||
detail: buildSpecDetail(entry.spec),
|
||||
info: buildSpecInfo(entry.spec, `${main} ${entry.sub}`),
|
||||
boost: boostFor(entry.spec),
|
||||
}));
|
||||
return items.sort((a, b) => b.boost - a.boost);
|
||||
}
|
||||
|
||||
function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] {
|
||||
if (!prefix) {
|
||||
// No partial key typed yet: offer a bounded sample (sorted) so the menu isn't empty.
|
||||
return keys.slice(0, 100).map((key) => ({
|
||||
label: key,
|
||||
type: "text" as const,
|
||||
detail: "key",
|
||||
boost: 60,
|
||||
}));
|
||||
}
|
||||
return keys
|
||||
.filter((key) => key.toLowerCase().includes(prefix.toLowerCase()))
|
||||
.slice(0, 100)
|
||||
.map((key) => ({
|
||||
label: key,
|
||||
type: "text" as const,
|
||||
detail: "key",
|
||||
boost: key.toLowerCase().startsWith(prefix.toLowerCase()) ? 70 : 55,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildRedisCompletionItemsFromContext(context: RedisCompletionContext, input: RedisCompletionInput = {}): RedisCompletionItem[] {
|
||||
if (context.mode === "command") return commandItems(context.prefix);
|
||||
if (context.mode === "subcommand" && context.mainCommand) {
|
||||
return subcommandItems(context.mainCommand, context.prefix);
|
||||
}
|
||||
// argument mode: offer key names at the key-argument slot only. Most key
|
||||
// commands take a single key (first slot); variadic key-list commands
|
||||
// (DEL/UNLINK/EXISTS/...) keep suggesting at every slot.
|
||||
if (context.mode === "argument" && takesKeyArgument(context.mainCommand) && shouldSuggestKeyAt(context.mainCommand, context.argumentIndex)) {
|
||||
return keyItems(context.prefix, input.keys ?? []);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function shouldSuggestKeyAt(mainCommand: string | undefined, argumentIndex: number | undefined): boolean {
|
||||
if (argumentIndex == null) return false;
|
||||
if (mainCommand && MULTI_KEY_COMMANDS.has(mainCommand)) return true;
|
||||
return argumentIndex === 0;
|
||||
}
|
||||
|
||||
export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput = {}): RedisCompletionItem[] {
|
||||
return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor), input);
|
||||
}
|
||||
|
||||
/** True when the main command's first argument is a key (by group heuristic). */
|
||||
export function takesKeyArgument(mainCommand?: string): boolean {
|
||||
if (!mainCommand) return false;
|
||||
const spec = REDIS_COMMAND_TABLE[mainCommand];
|
||||
if (spec) return KEY_ARGUMENT_GROUPS.has(spec.group);
|
||||
// A main that only exists via subcommands (e.g. XGROUP, CONFIG): treat stream/cluster
|
||||
// subcommand roots as key-taking only for the stream group, conservatively.
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldAutoOpenRedisCompletion(text: string, cursor: number): boolean {
|
||||
const previousChar = text[cursor - 1];
|
||||
if (!previousChar) return false;
|
||||
if (/[\n\r]/.test(previousChar)) return false;
|
||||
// Open while typing command names or key names (letters/digits/_/:/./-).
|
||||
if (/[\w:*.-]/.test(previousChar)) return true;
|
||||
// Just typed a space after a command → open to suggest subcommands / keys.
|
||||
if (/\s/.test(previousChar)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getRedisCompletionResultValidFor(): RegExp {
|
||||
return /[\w:*.-]*$/;
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const completionForeignKeysCache = ref<Record<string, ForeignKeyInfo[]>>({});
|
||||
const completionDatabasesCache = ref<Record<string, string[]>>({});
|
||||
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
|
||||
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
|
||||
const schemaListCache = ref<Record<string, string[]>>({});
|
||||
const sidebarSearchQuery = ref("");
|
||||
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
|
||||
@@ -603,6 +604,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(redisCompletionKeysCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
|
||||
}
|
||||
for (const key of completionTableIndex.keys()) {
|
||||
if (key.startsWith(cachePrefix)) completionTableIndex.delete(key);
|
||||
}
|
||||
@@ -1970,6 +1974,27 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return elasticsearchCompletionIndicesCache.value[cacheKey];
|
||||
}
|
||||
|
||||
// Upper bound on cached key names per db, to keep completion memory bounded
|
||||
// (Redis can hold far more keys than we ever want resident for autocomplete).
|
||||
const REDIS_COMPLETION_KEYS_MAX = 1000;
|
||||
|
||||
async function listRedisCompletionKeys(connectionId: string, database: string): Promise<string[]> {
|
||||
if (!database) return [];
|
||||
const cacheKey = `${connectionId}:${database}`;
|
||||
const cached = redisCompletionKeysCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:redis-keys`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const pageSize = settingsStore.editorSettings.redisScanPageSize;
|
||||
// Bounded multi-round SCAN: trade coverage for latency/memory safety.
|
||||
const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6);
|
||||
const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX);
|
||||
redisCompletionKeysCache.value[cacheKey] = keys;
|
||||
evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX);
|
||||
return keys;
|
||||
});
|
||||
}
|
||||
|
||||
async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise<SqlCompletionTable[]> {
|
||||
const normalizedFilter = filter.trim().toLowerCase();
|
||||
const relaxedFilter = relaxedCompletionTableFilter(normalizedFilter);
|
||||
@@ -2765,6 +2790,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
refreshCompletionSchemas,
|
||||
refreshCompletionDatabases,
|
||||
listElasticsearchCompletionIndices,
|
||||
listRedisCompletionKeys,
|
||||
exportConnectionsToFile,
|
||||
readImportFile,
|
||||
importConnectionsFromFile,
|
||||
|
||||
Reference in new issue
Block a user