feat(redis): add command terminal autocomplete

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-12 00:41:31 +08:00
1 parent 80bd8dac06
commit dbe3375739
13 files changed
+1865 -337

No files matched your search

@@ -36,6 +36,17 @@ function mysqlConnection(): ConnectionConfig {
} as ConnectionConfig;
}
function redisConnection(): ConnectionConfig {
return {
...postgresConnection(),
id: "redis-1",
name: "Redis",
db_type: "redis",
port: 6379,
database: "0",
} as ConnectionConfig;
}
function oracleConnection(): ConnectionConfig {
return {
...postgresConnection(),
@@ -153,6 +164,145 @@ describe("connectionStore completion assistant", () => {
expect(tables).toEqual([{ name: "users", schema: "public", type: "table" }]);
});
it("falls back to the server COMMAND catalog when COMMAND DOCS is unsupported", async () => {
const redisExecuteCommand = vi
.fn()
.mockRejectedValueOnce(new Error("unknown subcommand 'DOCS'"))
.mockResolvedValueOnce({
command: "COMMAND",
safety: "allowed",
value: [["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []]],
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
redisExecuteCommand,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [redisConnection()];
store.connectedIds.add("redis-1");
const docs = await store.listRedisCompletionCommandDocs("redis-1", "0");
const cached = await store.listRedisCompletionCommandDocs("redis-1", "0");
expect(redisExecuteCommand).toHaveBeenNthCalledWith(1, "redis-1", 0, "COMMAND DOCS");
expect(redisExecuteCommand).toHaveBeenNthCalledWith(2, "redis-1", 0, "COMMAND");
expect(redisExecuteCommand).toHaveBeenCalledTimes(2);
expect(docs).toEqual([
{
name: "GET",
summary: undefined,
since: undefined,
group: undefined,
arity: 2,
keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }],
},
]);
expect(cached).toEqual(docs);
});
it("merges COMMAND key positions into COMMAND DOCS metadata", async () => {
const redisExecuteCommand = vi
.fn()
.mockResolvedValueOnce({
command: "COMMAND DOCS",
safety: "allowed",
value: { get: { summary: "Returns a value.", arguments: [{ name: "key", type: "key" }] } },
})
.mockResolvedValueOnce({
command: "COMMAND",
safety: "allowed",
value: [["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []]],
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
redisExecuteCommand,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [redisConnection()];
store.connectedIds.add("redis-1");
const docs = await store.listRedisCompletionCommandDocs("redis-1", "0");
expect(docs[0]).toMatchObject({
name: "GET",
arity: 2,
keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }],
});
});
it("does not let an invalidated Redis metadata request overwrite fresh cache", async () => {
const staleDocs = deferred<{ command: string; safety: string; value: unknown }>();
const freshDocs = deferred<{ command: string; safety: string; value: unknown }>();
let docsRequestCount = 0;
const redisExecuteCommand = vi.fn((_connectionId: string, _database: number, command: string) => {
if (command === "COMMAND DOCS") {
docsRequestCount += 1;
return docsRequestCount === 1 ? staleDocs.promise : freshDocs.promise;
}
return Promise.resolve({
command: "COMMAND",
safety: "allowed",
value: [["get", 2, ["readonly"], 1, 1, 1, ["@read"], [], [], []]],
});
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
redisExecuteCommand,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [redisConnection()];
store.connectedIds.add("redis-1");
const staleRequest = store.listRedisCompletionCommandDocs("redis-1", "0");
await vi.waitFor(() => expect(redisExecuteCommand).toHaveBeenCalledTimes(1));
store.invalidateCompletionCache("redis-1");
const freshRequest = store.listRedisCompletionCommandDocs("redis-1", "0");
freshDocs.resolve({ command: "COMMAND DOCS", safety: "allowed", value: { get: { summary: "fresh" } } });
await expect(freshRequest).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: "GET", summary: "fresh" })]));
staleDocs.resolve({ command: "COMMAND DOCS", safety: "allowed", value: { get: { summary: "stale" } } });
await staleRequest;
await expect(store.listRedisCompletionCommandDocs("redis-1", "0")).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: "GET", summary: "fresh" })]));
});
it("does not suggest binary Redis key displays that the command input cannot execute", async () => {
const redisScanKeysBatch = vi.fn().mockResolvedValue({
cursor: 0,
keys: [
{ key_display: "plain", key_raw: "cGxhaW4=", key_type: "string", ttl: -1 },
{ key_display: String.raw`literal\\xAC`, key_raw: "bGl0ZXJhbFx4QUM=", key_type: "string", ttl: -1 },
{ key_display: "\\xac", key_raw: "rA==", key_type: "string", ttl: -1 },
],
total_keys: 3,
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
redisScanKeysBatch,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [redisConnection()];
store.connectedIds.add("redis-1");
await expect(store.listRedisCompletionKeys("redis-1", "0")).resolves.toEqual(["plain", String.raw`literal\\xAC`]);
});
it("preserves TDengine stable type in completion metadata", async () => {
const listTables = vi.fn().mockResolvedValue([
{ name: "test_tb", table_type: "STABLE", comment: null },
+49 -1
View File
@@ -98,6 +98,7 @@ import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/sch
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { mergeRedisCommandDocumentation, parseRedisCommandCatalog, parseRedisCommandDocumentation, type RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import { useSettingsStore } from "@/stores/settingsStore";
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
@@ -355,6 +356,8 @@ export const useConnectionStore = defineStore("connection", () => {
const sqlServerCompletionContextCache = ref<Record<string, SqlServerCompletionContext>>({});
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
const redisCommandDocsCache = ref<Record<string, RedisCommandDocumentation[]>>({});
const redisCommandDocsCacheGeneration = new Map<string, number>();
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
@@ -2378,6 +2381,10 @@ export const useConnectionStore = defineStore("connection", () => {
for (const key of Object.keys(redisCompletionKeysCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
}
if (database == null) {
delete redisCommandDocsCache.value[connectionId];
redisCommandDocsCacheGeneration.set(connectionId, (redisCommandDocsCacheGeneration.get(connectionId) ?? 0) + 1);
}
for (const key of Object.keys(mongoCompletionCollectionsCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionCollectionsCache.value[key];
}
@@ -6214,6 +6221,8 @@ export const useConnectionStore = defineStore("connection", () => {
// 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;
// `\\xNN` is binary only when it has an even number of preceding slashes.
const BINARY_REDIS_KEY_ESCAPE = /(^|[^\\])(?:\\\\)*\\x[0-9a-f]{2}/i;
async function listRedisCompletionKeys(connectionId: string, database: string): Promise<string[]> {
if (!database) return [];
@@ -6225,13 +6234,51 @@ export const useConnectionStore = defineStore("connection", () => {
const pageSize = getConfig(connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT;
// Bounded multi-round SCAN: trade coverage for latency/memory safety.
const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6, false);
const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX);
const keys = result.keys
.map((key) => key.key_display)
.filter((key) => !BINARY_REDIS_KEY_ESCAPE.test(key))
.slice(0, REDIS_COMPLETION_KEYS_MAX);
redisCompletionKeysCache.value[cacheKey] = keys;
evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX);
return keys;
});
}
async function listRedisCompletionCommandDocs(connectionId: string, database: string): Promise<RedisCommandDocumentation[]> {
const cached = redisCommandDocsCache.value[connectionId];
if (cached) return cached;
return withCompletionInFlight(`${connectionId}:redis-command-docs`, async () => {
const generation = redisCommandDocsCacheGeneration.get(connectionId) ?? 0;
await ensureConnected(connectionId);
const db = Number.parseInt(database, 10) || 0;
let docs: RedisCommandDocumentation[];
try {
// Redis recommends COMMAND DOCS for complete, version-aware client metadata.
const docsResult = await api.redisExecuteCommand(connectionId, db, "COMMAND DOCS");
docs = parseRedisCommandDocumentation(docsResult.value);
try {
const catalogResult = await api.redisExecuteCommand(connectionId, db, "COMMAND");
docs = mergeRedisCommandDocumentation(docs, parseRedisCommandCatalog(catalogResult.value));
} catch {
// Documentation still provides useful grammar when COMMAND is restricted.
}
} catch (docsError) {
// Redis before 7.0 lacks COMMAND DOCS; COMMAND still reports its actual command inventory.
try {
const result = await api.redisExecuteCommand(connectionId, db, "COMMAND");
docs = parseRedisCommandCatalog(result.value);
} catch {
throw docsError;
}
}
if ((redisCommandDocsCacheGeneration.get(connectionId) ?? 0) === generation) {
redisCommandDocsCache.value[connectionId] = docs;
evictOldestCacheEntries(redisCommandDocsCache.value, COMPLETION_CACHE_MAX);
}
return docs;
});
}
async function listMongoCompletionCollections(connectionId: string, database: string): Promise<string[]> {
if (!database) return [];
const cacheKey = `${connectionId}:${database}`;
@@ -7436,6 +7483,7 @@ export const useConnectionStore = defineStore("connection", () => {
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
listRedisCompletionKeys,
listRedisCompletionCommandDocs,
listMongoCompletionCollections,
listMongoCompletionFields,
invalidateCompletionCache,