feat(completion): complete mongo dropIndex and hint indexes
This commit is contained in:
1 parent
0f81dcdf40
commit
80bd37ebaa
6 files changed
+296
-7
No files matched your search
+16
@@ -60,6 +60,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
|
||||
listRedisCompletionKeys: vi.fn(async () => ["user:1"]),
|
||||
listMongoCompletionCollections: vi.fn(async () => ["users"]),
|
||||
listMongoCompletionFields: vi.fn(async () => []),
|
||||
listMongoCompletionIndexes: vi.fn(async () => []),
|
||||
listElasticsearchCompletionIndices: vi.fn(async () => ["users"]),
|
||||
listElasticsearchCompletionFields: vi.fn(async () => []),
|
||||
};
|
||||
@@ -275,6 +276,21 @@ describe("QueryEditor completion provider ownership", () => {
|
||||
expect(await accept("db.users.find({ na })", 18)).toEqual({ doc: "db.users.find({ name: })", cursor: 22 });
|
||||
});
|
||||
|
||||
it("completes MongoDB index names in dropIndex and replaces closing quote", async () => {
|
||||
const doc = 'db.users.dropIndex("")';
|
||||
const cursor = doc.indexOf('""') + 1;
|
||||
const { store, provide, currentView } = createHarness({ databaseType: "mongodb", modelValue: doc });
|
||||
store.listMongoCompletionIndexes.mockResolvedValue([{ name: "email_1", keyPattern: "{ email: 1 }" }]);
|
||||
currentView.dispatch({ selection: { anchor: cursor } });
|
||||
const result = await provide();
|
||||
expect(store.listMongoCompletionIndexes).toHaveBeenCalledWith("connection", "demo", "users");
|
||||
const option = result?.options.find((candidate) => candidate.displayLabel === "email_1" || candidate.label === "email_1");
|
||||
expect(option).toBeDefined();
|
||||
expect(option?.detail).toBe("{ email: 1 }");
|
||||
(option!.apply as (view: EditorView, completion: unknown, from: number, to: number) => void)(currentView, option, result!.from, cursor);
|
||||
expect(currentView.state.doc.toString()).toBe('db.users.dropIndex("email_1")');
|
||||
});
|
||||
|
||||
it.each(["redis", "mongodb", "mysql"] as const)("does not query %s metadata without a connection", async (databaseType) => {
|
||||
const { store, provide } = createHarness({ databaseType, connectionId: undefined });
|
||||
expect(await provide()).toBeNull();
|
||||
|
||||
@@ -20,7 +20,17 @@ import {
|
||||
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy";
|
||||
import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfileExtensions";
|
||||
import { buildElasticsearchCompletionItemsFromContext, elasticsearchCompletionNeedsFields, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import { buildMongoCompletionItemsFromContext, foldMongoKeySeparator, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsDatabases, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion } from "@/lib/mongo/mongoCompletion";
|
||||
import {
|
||||
buildMongoCompletionItemsFromContext,
|
||||
foldMongoKeySeparator,
|
||||
getMongoCompletionContext,
|
||||
getMongoCompletionResultValidFor,
|
||||
mongoCompletionNeedsCollections,
|
||||
mongoCompletionNeedsDatabases,
|
||||
mongoCompletionNeedsFields,
|
||||
mongoCompletionNeedsIndexes,
|
||||
shouldAutoOpenMongoCompletion,
|
||||
} from "@/lib/mongo/mongoCompletion";
|
||||
import { buildSoqlCompletionItems, getSoqlCompletionContext, getSoqlCompletionResultValidFor, resolveSoqlFieldCandidates, resolveSoqlValueField, shouldAutoOpenSoqlCompletion, soqlCompletionNeedsObjects, type SoqlCompletionField, type SoqlCompletionObject } from "@/lib/soql/soqlCompletion";
|
||||
import {
|
||||
buildSqlServerUseDatabaseCompletionItems,
|
||||
@@ -572,6 +582,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
let databases: string[] = [];
|
||||
let collections: string[] = [];
|
||||
let fields: Awaited<ReturnType<typeof connectionStore.listMongoCompletionFields>> = [];
|
||||
let indexes: Awaited<ReturnType<typeof connectionStore.listMongoCompletionIndexes>> = [];
|
||||
|
||||
// `use` and `getSiblingDB` name a database, which does not depend on the tab having one selected.
|
||||
if (mongoCompletionNeedsDatabases(completionContext.mode)) {
|
||||
@@ -598,12 +609,22 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
}
|
||||
}
|
||||
|
||||
const indexDatabase = completionContext.database ?? props.database;
|
||||
if (indexDatabase && mongoCompletionNeedsIndexes(completionContext.mode) && completionContext.collection) {
|
||||
try {
|
||||
indexes = await connectionStore.listMongoCompletionIndexes(props.connectionId, indexDatabase, completionContext.collection);
|
||||
} catch {
|
||||
indexes = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildMongoCompletionItemsFromContext(completionContext, {
|
||||
databases,
|
||||
collections,
|
||||
fields,
|
||||
indexes,
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
return {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type MongoCompletionMode =
|
||||
| "pullCondition"
|
||||
| "fieldPath"
|
||||
| "fieldRef"
|
||||
| "indexName"
|
||||
| "value"
|
||||
| "valueWrapper"
|
||||
| "queryOperator"
|
||||
@@ -74,6 +75,11 @@ export interface MongoCompletionField {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface MongoCompletionIndex {
|
||||
name: string;
|
||||
keyPattern?: string;
|
||||
}
|
||||
|
||||
export interface MongoCompletionItem {
|
||||
label: string;
|
||||
type: "column" | "function" | "keyword" | "snippet" | "table";
|
||||
@@ -117,6 +123,7 @@ export interface MongoCompletionInput {
|
||||
databases?: string[];
|
||||
collections?: string[];
|
||||
fields?: MongoCompletionField[];
|
||||
indexes?: Array<string | MongoCompletionIndex>;
|
||||
}
|
||||
|
||||
const COLLECTION_METHODS = [
|
||||
@@ -242,7 +249,7 @@ const ROOT_SNIPPET_BOOST: Record<(typeof ROOT_SNIPPETS)[number]["label"], number
|
||||
};
|
||||
|
||||
/** Role of each positional argument, by collection helper. Drives cursor classification. */
|
||||
type MongoArgRole = "filter" | "update" | "replacement" | "document" | "documents" | "operations" | "pipeline" | "projection" | "keys" | "sortKeys" | "fieldName" | "options" | "collation" | "verbosity" | "name";
|
||||
type MongoArgRole = "filter" | "update" | "replacement" | "document" | "documents" | "operations" | "pipeline" | "projection" | "keys" | "sortKeys" | "fieldName" | "indexName" | "options" | "collation" | "verbosity" | "name";
|
||||
|
||||
const METHOD_ARG_ROLES: Record<string, readonly MongoArgRole[]> = {
|
||||
find: ["filter", "projection"],
|
||||
@@ -267,6 +274,8 @@ const METHOD_ARG_ROLES: Record<string, readonly MongoArgRole[]> = {
|
||||
sort: ["sortKeys"],
|
||||
collation: ["collation"],
|
||||
explain: ["verbosity"],
|
||||
dropIndex: ["indexName"],
|
||||
dropIndexes: ["indexName"],
|
||||
// Database-level helpers whose argument is a document.
|
||||
createCollection: ["name", "options"],
|
||||
runCommand: ["options"],
|
||||
@@ -417,7 +426,7 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo
|
||||
|
||||
const call = findInnermostMongoCall(beforeCursor);
|
||||
// Top-level snippets belong at the start of a command. Inside an argument list — of a method
|
||||
// this engine does not model (`limit(`, `drop(`, `dropIndex("`, `runCommand({`, …) or after a
|
||||
// this engine does not model (`limit(`, `drop(`, `renameCollection(`, `runCommand({`, …) or after a
|
||||
// `use` — they are noise: `db.collection.find` is not something you can type there.
|
||||
if (!call) return isInsideCallArguments(beforeCursor) || isAfterUseKeyword(beforeCursor) || isAfterShowKeyword(beforeCursor) ? at("none") : at("root");
|
||||
|
||||
@@ -489,6 +498,9 @@ export function buildMongoCompletionItemsFromContext(context: MongoCompletionCon
|
||||
case "fieldRef":
|
||||
items = fieldRefItems(prefix, fields);
|
||||
break;
|
||||
case "indexName":
|
||||
items = indexNameItems(prefix, input.indexes ?? [], context.method);
|
||||
break;
|
||||
case "value":
|
||||
// Shell constructors first; the extended JSON spellings need their own braces here.
|
||||
items = [...specItems(VALUE_SNIPPETS, prefix, "value", 100), ...specItems(BRACED_EXTENDED_JSON_VALUES, prefix, "extended JSON value", 90)];
|
||||
@@ -561,6 +573,11 @@ export function mongoCompletionNeedsCollections(mode: MongoCompletionMode): bool
|
||||
return mode === "collection" || mode === "collectionOrMethod" || mode === "collectionRef";
|
||||
}
|
||||
|
||||
/** Modes whose items are built from the target collection's indexes. */
|
||||
export function mongoCompletionNeedsIndexes(mode: MongoCompletionMode): boolean {
|
||||
return mode === "indexName";
|
||||
}
|
||||
|
||||
/** Modes whose items are built from the connection's database names. */
|
||||
export function mongoCompletionNeedsDatabases(mode: MongoCompletionMode): boolean {
|
||||
return mode === "database";
|
||||
@@ -712,6 +729,20 @@ export function inferMongoCompletionFields(documents: unknown[]): MongoCompletio
|
||||
return [...typeByPath.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, types]) => ({ name, type: [...types].sort().join(" | ") }));
|
||||
}
|
||||
|
||||
/** Formats MongoDB index keys into a key pattern string, e.g. `{ name: 1, age: -1 }`. */
|
||||
export function formatMongoIndexKeyPattern(keys?: readonly { field: string; direction: string | number }[] | null): string | undefined {
|
||||
if (!keys || keys.length === 0) return undefined;
|
||||
const parts = keys.map((key) => {
|
||||
let dir: string | number = key.direction;
|
||||
if (dir === "1" || dir === 1) dir = 1;
|
||||
else if (dir === "-1" || dir === -1) dir = -1;
|
||||
else if (typeof dir === "string" && /^-?\d+$/.test(dir)) dir = Number(dir);
|
||||
else if (typeof dir === "string") dir = JSON.stringify(dir);
|
||||
return `${key.field}: ${dir}`;
|
||||
});
|
||||
return `{ ${parts.join(", ")} }`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Cursor classification
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -863,6 +894,13 @@ function classifyCursorInCall(method: string, scan: MongoCallScan): MongoCursorC
|
||||
// A bare string argument naming a field, e.g. distinct("category").
|
||||
case "fieldName":
|
||||
return { mode: scan.stack.length === 0 ? "fieldPath" : "none" };
|
||||
case "indexName":
|
||||
if (method === "dropIndexes") {
|
||||
const isBare = scan.stack.length === 0;
|
||||
const isInArray = scan.stack.length === 1 && scan.stack[0].kind === "array";
|
||||
return isBare || isInArray ? { mode: "indexName", method } : { mode: "none" };
|
||||
}
|
||||
return scan.stack.length === 0 ? { mode: "indexName", method } : { mode: "none" };
|
||||
case "pipeline":
|
||||
return classifyPipeline(scan);
|
||||
case "operations":
|
||||
@@ -1100,6 +1138,7 @@ function classifyMethodOptions(method: string, scan: MongoCallScan): MongoCursor
|
||||
|
||||
if (depth === 0) {
|
||||
if (scan.inValue) {
|
||||
if (method === "aggregate" && scan.valueKey === "hint") return { mode: "indexName", method };
|
||||
const enumKey = OPTION_VALUE_ENUMS[scan.valueKey ?? ""];
|
||||
if (enumKey) return { mode: "enumValue", enumKey, method };
|
||||
if (OPTION_COLLECTION_KEYS[method]?.has(scan.valueKey ?? "")) return { mode: "collectionRef", method };
|
||||
@@ -1507,6 +1546,26 @@ function collectionRefItems(prefix: string, collections: string[]): MongoComplet
|
||||
});
|
||||
}
|
||||
|
||||
function indexNameItems(prefix: string, indexes: Array<string | MongoCompletionIndex>, method?: string): MongoCompletionItem[] {
|
||||
const normalizedPrefix = normalizeMongoKeyPrefix(prefix);
|
||||
const normalized = indexes.map((item) => (typeof item === "string" ? { name: item } : item));
|
||||
const excludeProtected = method === "dropIndex" || method === "dropIndexes";
|
||||
const candidates = excludeProtected ? normalized.filter((idx) => idx.name !== "_id_") : normalized;
|
||||
|
||||
return dedupeAndSort(
|
||||
candidates
|
||||
.filter((idx) => matchesFuzzyPrefix(idx.name, normalizedPrefix))
|
||||
.slice(0, 100)
|
||||
.map((idx) => ({
|
||||
label: idx.name,
|
||||
type: "keyword" as const,
|
||||
detail: idx.keyPattern || undefined,
|
||||
apply: quoteMongoString(idx.name, prefix),
|
||||
boost: startsWithPrefix(idx.name, normalizedPrefix) ? 120 : 90,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function methodItems(prefix: string): MongoCompletionItem[] {
|
||||
return dedupeAndSort(
|
||||
COLLECTION_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({
|
||||
|
||||
@@ -1239,6 +1239,57 @@ describe("connectionStore completion assistant", () => {
|
||||
expect(getColumns.mock.calls.map((call) => call[3])).toEqual(["users", "orders", "users"]);
|
||||
});
|
||||
|
||||
it("caches Mongo completion indexes and invalidates them via invalidateCompletionTableCache and invalidateCompletionCache", async () => {
|
||||
const mongoListIndexSpecs = vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: "email_1",
|
||||
keys: [{ field: "email", direction: "1" }],
|
||||
is_unique: true,
|
||||
is_primary: false,
|
||||
is_sparse: false,
|
||||
expire_after_seconds: null,
|
||||
partial_filter_expression: null,
|
||||
background: false,
|
||||
bucket_size: null,
|
||||
hidden: false,
|
||||
properties_complete: true,
|
||||
extra_options: null,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
mongoListIndexSpecs,
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.connections = [
|
||||
{
|
||||
...postgresConnection(),
|
||||
id: "mongo-1",
|
||||
name: "MongoDB",
|
||||
db_type: "mongodb",
|
||||
},
|
||||
];
|
||||
store.connectedIds.add("mongo-1");
|
||||
|
||||
const first = await store.listMongoCompletionIndexes("mongo-1", "app", "users");
|
||||
expect(first).toEqual([{ name: "email_1", keyPattern: "{ email: 1 }" }]);
|
||||
const second = await store.listMongoCompletionIndexes("mongo-1", "app", "users");
|
||||
expect(second).toEqual([{ name: "email_1", keyPattern: "{ email: 1 }" }]);
|
||||
expect(mongoListIndexSpecs).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(store.invalidateCompletionTableCache("mongo-1", "app", "users")).toBeGreaterThan(0);
|
||||
await store.listMongoCompletionIndexes("mongo-1", "app", "users");
|
||||
expect(mongoListIndexSpecs).toHaveBeenCalledTimes(2);
|
||||
|
||||
store.invalidateCompletionCache("mongo-1", "app");
|
||||
await store.listMongoCompletionIndexes("mongo-1", "app", "users");
|
||||
expect(mongoListIndexSpecs).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not let an invalidated column request overwrite fresh metadata", async () => {
|
||||
const staleColumns = deferred<unknown[]>();
|
||||
const freshColumns = deferred<unknown[]>();
|
||||
|
||||
@@ -166,7 +166,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree";
|
||||
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
|
||||
import { formatMongoIndexKeyPattern, inferMongoCompletionFields, type MongoCompletionField, type MongoCompletionIndex } from "@/lib/mongo/mongoCompletion";
|
||||
import type { SoqlCompletionField, SoqlCompletionObject } from "@/lib/soql/soqlCompletion";
|
||||
import type { SalesforceCurrentUser } from "@/types/salesforce";
|
||||
import { flattenElasticsearchMappingFields, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
@@ -537,6 +537,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const redisCommandDocsCacheGeneration = new Map<string, number>();
|
||||
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
|
||||
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
|
||||
const mongoCompletionIndexesCache = ref<Record<string, MongoCompletionIndex[]>>({});
|
||||
const soqlCompletionObjectsCache = ref<Record<string, SoqlCompletionObject[]>>({});
|
||||
const soqlCompletionFieldsCache = ref<Record<string, SoqlCompletionField[]>>({});
|
||||
// One entry per connection: the authenticated Salesforce user never changes for
|
||||
@@ -3940,6 +3941,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
for (const key of Object.keys(mongoCompletionFieldsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionFieldsCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(mongoCompletionIndexesCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionIndexesCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(soqlCompletionObjectsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete soqlCompletionObjectsCache.value[key];
|
||||
}
|
||||
@@ -7901,7 +7905,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!normalizedKey.startsWith(prefix)) return false;
|
||||
const tableToken = `:${tableName.toLowerCase()}`;
|
||||
const tableOffset = normalizedKey.lastIndexOf(tableToken);
|
||||
if (tableOffset < prefix.length) return false;
|
||||
if (tableOffset < prefix.length - 1) return false;
|
||||
const trailing = normalizedKey.slice(tableOffset + tableToken.length);
|
||||
if (trailing && !trailing.startsWith(":")) return false;
|
||||
const normalizedSchema = schema?.trim().toLowerCase();
|
||||
@@ -7919,7 +7923,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
bumpCompletionCacheRevision(connectionId, database);
|
||||
const matches = (key: string) => completionTableCacheKeyMatches(key, connectionId, database, tableName, schema, catalog);
|
||||
let removed = 0;
|
||||
for (const cache of [completionColumnsCache.value, completionForeignKeysCache.value]) {
|
||||
for (const cache of [completionColumnsCache.value, completionForeignKeysCache.value, mongoCompletionFieldsCache.value, mongoCompletionIndexesCache.value]) {
|
||||
for (const key of Object.keys(cache)) {
|
||||
if (!matches(key)) continue;
|
||||
delete cache[key];
|
||||
@@ -8772,6 +8776,24 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function listMongoCompletionIndexes(connectionId: string, database: string, collection: string): Promise<MongoCompletionIndex[]> {
|
||||
if (!database || !collection) return [];
|
||||
const cacheKey = `${connectionId}:${database}:${collection}`;
|
||||
const cached = mongoCompletionIndexesCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:mongo-indexes`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const specs = (await api.mongoListIndexSpecs(connectionId, database, collection)) ?? [];
|
||||
const indexes = specs.map((spec) => ({
|
||||
name: spec.name,
|
||||
keyPattern: formatMongoIndexKeyPattern(spec.keys),
|
||||
}));
|
||||
mongoCompletionIndexesCache.value[cacheKey] = indexes;
|
||||
evictOldestCacheEntries(mongoCompletionIndexesCache.value, COMPLETION_CACHE_MAX);
|
||||
return indexes;
|
||||
});
|
||||
}
|
||||
|
||||
// Map a Salesforce describe column to a SOQL completion field. The backend packs
|
||||
// relationshipName / referenceTo / label into ColumnInfo.extra (JSON) and active
|
||||
// picklist values into enum_values; parse defensively since extra may be absent.
|
||||
@@ -10330,6 +10352,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
listRedisCompletionCommandDocs,
|
||||
listMongoCompletionCollections,
|
||||
listMongoCompletionFields,
|
||||
listMongoCompletionIndexes,
|
||||
listSoqlCompletionObjects,
|
||||
listSoqlCompletionFields,
|
||||
loadSalesforceCurrentUser,
|
||||
|
||||
+120
-1
@@ -1,6 +1,16 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { buildMongoCompletionItems, buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, getMongoDocumentQueryCompletionContext, inferMongoCompletionFields, shouldAutoOpenMongoCompletion } from "../../apps/desktop/src/lib/mongo/mongoCompletion.ts";
|
||||
import {
|
||||
buildMongoCompletionItems,
|
||||
buildMongoCompletionItemsFromContext,
|
||||
formatMongoIndexKeyPattern,
|
||||
getMongoCompletionContext,
|
||||
getMongoCompletionResultValidFor,
|
||||
getMongoDocumentQueryCompletionContext,
|
||||
inferMongoCompletionFields,
|
||||
mongoCompletionNeedsIndexes,
|
||||
shouldAutoOpenMongoCompletion,
|
||||
} from "../../apps/desktop/src/lib/mongo/mongoCompletion.ts";
|
||||
import {
|
||||
ACCUMULATORS,
|
||||
BULK_WRITE_OPERATION_FIELDS,
|
||||
@@ -984,6 +994,115 @@ test("completes both arguments of distinct", () => {
|
||||
assert.ok(labels('db.users.distinct("name", { age: { $g', { fields }).includes("$gte"));
|
||||
});
|
||||
|
||||
test("completes index names in dropIndex, dropIndexes and aggregate hint", () => {
|
||||
const sampleIndexes = [
|
||||
{ name: "_id_", keyPattern: "{ _id: 1 }" },
|
||||
{ name: "email_1", keyPattern: "{ email: 1 }" },
|
||||
{ name: "name_age_idx", keyPattern: "{ name: 1, age: -1 }" },
|
||||
];
|
||||
|
||||
// Needs-helper correctly identifies indexName mode.
|
||||
assert.equal(mongoCompletionNeedsIndexes("indexName"), true);
|
||||
assert.equal(mongoCompletionNeedsIndexes("collection"), false);
|
||||
assert.equal(mongoCompletionNeedsIndexes("none"), false);
|
||||
|
||||
// dropIndex excludes _id_ and completes index names of the collection.
|
||||
const dropIndexText = 'db.users.dropIndex("';
|
||||
const dropIndexCtx = getMongoCompletionContext(dropIndexText, dropIndexText.length);
|
||||
assert.equal(dropIndexCtx.mode, "indexName");
|
||||
assert.equal(dropIndexCtx.collection, "users");
|
||||
assert.equal(dropIndexCtx.prefix, '"');
|
||||
|
||||
const dropIndexItems = buildMongoCompletionItems(dropIndexText, dropIndexText.length, { indexes: sampleIndexes });
|
||||
assert.deepEqual(
|
||||
dropIndexItems.map((item) => item.label),
|
||||
["email_1", "name_age_idx"],
|
||||
);
|
||||
assert.equal(
|
||||
dropIndexItems.find((i) => i.label === "_id_"),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(dropIndexItems.find((i) => i.label === "name_age_idx")?.detail, "{ name: 1, age: -1 }");
|
||||
assert.equal(dropIndexItems[0]?.apply, '"email_1"');
|
||||
|
||||
// dropIndex handles string-only index inputs as well.
|
||||
const stringIndexItems = buildMongoCompletionItems(dropIndexText, dropIndexText.length, { indexes: ["_id_", "username_1"] });
|
||||
assert.deepEqual(
|
||||
stringIndexItems.map((item) => item.label),
|
||||
["username_1"],
|
||||
);
|
||||
|
||||
// dropIndex replaces existing closing quote when completing an emptied quote pair.
|
||||
const dropIndexEmpty = 'db.users.dropIndex("")';
|
||||
const dropIndexEmptyCursor = dropIndexEmpty.indexOf('""') + 1;
|
||||
const dropIndexEmptyContext = getMongoCompletionContext(dropIndexEmpty, dropIndexEmptyCursor);
|
||||
const dropIndexEmptyItem = buildMongoCompletionItems(dropIndexEmpty, dropIndexEmptyCursor, { indexes: sampleIndexes }).find((i) => i.label === "email_1");
|
||||
assert.equal(dropIndexEmptyContext.replaceClosingQuote, '"');
|
||||
assert.equal(dropIndexEmptyItem?.replaceClosingQuote, '"');
|
||||
assert.equal(dropIndexEmpty.slice(0, dropIndexEmptyContext.from) + dropIndexEmptyItem?.apply + dropIndexEmpty.slice(dropIndexEmptyCursor + 1), 'db.users.dropIndex("email_1")');
|
||||
|
||||
// dropIndexes (bare string argument) excludes _id_.
|
||||
const dropIndexesBareText = 'db.users.dropIndexes("';
|
||||
const dropIndexesBareCtx = getMongoCompletionContext(dropIndexesBareText, dropIndexesBareText.length);
|
||||
assert.equal(dropIndexesBareCtx.mode, "indexName");
|
||||
assert.equal(dropIndexesBareCtx.collection, "users");
|
||||
const dropIndexesBareItems = buildMongoCompletionItems(dropIndexesBareText, dropIndexesBareText.length, { indexes: sampleIndexes });
|
||||
assert.deepEqual(
|
||||
dropIndexesBareItems.map((item) => item.label),
|
||||
["email_1", "name_age_idx"],
|
||||
);
|
||||
|
||||
// dropIndexes (array of string index names) excludes _id_.
|
||||
const dropIndexesArrayText = 'db.users.dropIndexes(["';
|
||||
const dropIndexesArrayCtx = getMongoCompletionContext(dropIndexesArrayText, dropIndexesArrayText.length);
|
||||
assert.equal(dropIndexesArrayCtx.mode, "indexName");
|
||||
assert.equal(dropIndexesArrayCtx.collection, "users");
|
||||
const dropIndexesArrayItems = buildMongoCompletionItems(dropIndexesArrayText, dropIndexesArrayText.length, { indexes: sampleIndexes });
|
||||
assert.deepEqual(
|
||||
dropIndexesArrayItems.map((item) => item.label),
|
||||
["email_1", "name_age_idx"],
|
||||
);
|
||||
|
||||
// Subsequent items in dropIndexes array also complete index names.
|
||||
const dropIndexesNextArrayText = 'db.users.dropIndexes(["email_1", "';
|
||||
const dropIndexesNextArrayCtx = getMongoCompletionContext(dropIndexesNextArrayText, dropIndexesNextArrayText.length);
|
||||
assert.equal(dropIndexesNextArrayCtx.mode, "indexName");
|
||||
assert.equal(dropIndexesNextArrayCtx.collection, "users");
|
||||
const dropIndexesNextItems = buildMongoCompletionItems(dropIndexesNextArrayText, dropIndexesNextArrayText.length, { indexes: sampleIndexes });
|
||||
assert.deepEqual(
|
||||
dropIndexesNextItems.map((item) => item.label),
|
||||
["email_1", "name_age_idx"],
|
||||
);
|
||||
|
||||
// aggregate({ hint: "..." }) includes _id_ (hinting _id_ is allowed in MongoDB).
|
||||
const aggHintText = 'db.users.aggregate([], { hint: "';
|
||||
const aggHintCtx = getMongoCompletionContext(aggHintText, aggHintText.length);
|
||||
assert.equal(aggHintCtx.mode, "indexName");
|
||||
assert.equal(aggHintCtx.collection, "users");
|
||||
const aggHintItems = buildMongoCompletionItems(aggHintText, aggHintText.length, { indexes: sampleIndexes });
|
||||
assert.deepEqual(
|
||||
aggHintItems.map((item) => item.label),
|
||||
["_id_", "email_1", "name_age_idx"],
|
||||
);
|
||||
assert.equal(aggHintItems.find((i) => i.label === "_id_")?.detail, "{ _id: 1 }");
|
||||
|
||||
// find().hint() is NOT in find chain and does not offer index completion.
|
||||
const findHintText = 'db.users.find({}).hint("';
|
||||
assert.notEqual(getMongoCompletionContext(findHintText, findHintText.length).mode, "indexName");
|
||||
|
||||
// Key pattern formatter formats directions properly.
|
||||
assert.equal(
|
||||
formatMongoIndexKeyPattern([
|
||||
{ field: "name", direction: "1" },
|
||||
{ field: "age", direction: "-1" },
|
||||
]),
|
||||
"{ name: 1, age: -1 }",
|
||||
);
|
||||
assert.equal(formatMongoIndexKeyPattern([{ field: "loc", direction: "2dsphere" }]), '{ loc: "2dsphere" }');
|
||||
assert.equal(formatMongoIndexKeyPattern([]), undefined);
|
||||
assert.equal(formatMongoIndexKeyPattern(null), undefined);
|
||||
});
|
||||
|
||||
test("completes database names after use and inside getSiblingDB", () => {
|
||||
const databases = ["orders", "order_archive", "admin", "local"];
|
||||
|
||||
|
||||
Reference in new issue
Block a user