feat(redis): add command terminal autocomplete
This commit is contained in:
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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user