From 6b136903b6267783eeda06bc7c1e97e75d606069 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:20:50 +0200 Subject: [PATCH 01/12] refactor(convertors): split into json/table/tree/metrics modules; add unit tests --- src/utils/convertors.ts | 662 ------------------------ src/utils/convertors/convertors.test.ts | 234 +++++++++ src/utils/convertors/index.ts | 21 + src/utils/convertors/json.ts | 31 ++ src/utils/convertors/metrics.ts | 157 ++++++ src/utils/convertors/table.ts | 279 ++++++++++ src/utils/convertors/tree.ts | 191 +++++++ src/utils/convertors/types.ts | 57 ++ 8 files changed, 970 insertions(+), 662 deletions(-) delete mode 100644 src/utils/convertors.ts create mode 100644 src/utils/convertors/convertors.test.ts create mode 100644 src/utils/convertors/index.ts create mode 100644 src/utils/convertors/json.ts create mode 100644 src/utils/convertors/metrics.ts create mode 100644 src/utils/convertors/table.ts create mode 100644 src/utils/convertors/tree.ts create mode 100644 src/utils/convertors/types.ts diff --git a/src/utils/convertors.ts b/src/utils/convertors.ts deleted file mode 100644 index 75b2cd61d..000000000 --- a/src/utils/convertors.ts +++ /dev/null @@ -1,662 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * NOTE: Mostly of these functions are async to be able to move them to backend in the future - */ - -import { type ItemDefinition, type JSONObject, type PartitionKeyDefinition } from '@azure/cosmos'; -import * as l10n from '@vscode/l10n'; -import { isEmptyObject } from 'es-toolkit'; -import { CosmosDBHiddenFields } from '../cosmosdb/cosmosdb-shared-constants'; -import { type CosmosDBRecordIdentifier, type SerializedQueryResult } from '../cosmosdb/types/queryResult'; -import { extractPartitionKey, extractPartitionKeyValues, getDocumentId } from './document'; -import { - QueryResultMismatchError, - getDocumentCollectionKind, - getQueryColumns, - getQueryResultKind, - isSelectStar, -} from './queryAnalysis'; -import { sanitizeDisplayString } from './sanitization'; -import { leftPadIndex, toStringUniversal } from './strings'; - -export type StatsItem = { - metric: string; - value: string | number; - formattedValue: string; - tooltip: string; -}; - -/** Row metadata — never overlaps with document field names */ -export type TableRowMeta = { - __id: string; - __documentId?: CosmosDBRecordIdentifier; -}; - -/** - * A single table row. - * `__id` and `__documentId` are internal; all other keys are raw document - * values (not pre-serialized). The UI layer calls `toStringUniversal` at render time. - */ -export type TableRecord = TableRowMeta & { - [key: string]: unknown; -}; - -export type TableData = { - headers: string[]; - dataset: TableRecord[]; -}; - -/** - * Tree row format for hierarchical tree view. - * Uses nested children array for tree structure. - */ -export type TreeRow = { - id: string; - documentId?: CosmosDBRecordIdentifier; - field: string; - value: string; - type: string; - children?: TreeRow[]; - isExpanded?: boolean; -}; - -export type ColumnOptions = { - ShowPartitionKey: 'first' | 'none'; // 'first' = show id + partition key first, 'none' = the nested partition key values are hidden + partition key are shown as is (without / prefix) - ShowServiceColumns: 'last' | 'none'; // 'last' = show service columns last, 'none' = hide service columns - Sorting: 'ascending' | 'descending' | 'none'; // 'ascending' = sort columns in ascending order, 'descending' = sort columns in descending order, 'none' = no sorting - TruncateValues: number; // truncate values to this length, 0 = no truncation -}; - -const MAX_TREE_LEVEL_LENGTH = 100; - -/** - * Checks if a value is a NonePartitionKey (empty object used by Cosmos DB SDK). - * NonePartitionKeyLiteral from @azure/cosmos is defined as {} and represents - * a partition key value for items without a value for partition key. - */ -const isNonePartitionKey = (value: unknown): boolean => { - return isEmptyObject(value); -}; - -export const queryResultToJSON = (queryResult: SerializedQueryResult | null, selection?: number[]): string => { - if (!queryResult) { - return ''; - } - - if (selection) { - const selectedDocs = queryResult.documents - .map((doc, index) => { - if (!selection.includes(index)) { - return null; - } - return doc; - }) - .filter((doc) => doc !== null); - - return JSON.stringify(selectedDocs, null, 4); - } - - return JSON.stringify(queryResult.documents, null, 4); -}; - -export const queryResultToTree = async ( - queryResult: SerializedQueryResult | null, - partitionKey: PartitionKeyDefinition | undefined, -): Promise => { - if (!queryResult?.documents?.length) { - return []; - } - - const queryKind = getQueryResultKind(queryResult.query); - const dataKind = getDocumentCollectionKind(queryResult.documents); - - // Tree view only makes sense for structured object documents - if (dataKind === 'empty' || dataKind === 'primitive') { - return []; - } - if (queryKind === 'object' && dataKind !== 'object') { - throw new QueryResultMismatchError(queryKind, dataKind); - } - if (dataKind !== 'object') { - // unknown queryKind with mixed/primitive data — cannot render as tree - return []; - } - - const rows: TreeRow[] = []; - const docsLength = queryResult.documents.length; - - for (let i = 0; i < docsLength; i++) { - // dataKind === 'object' is guaranteed by the guard above - const doc = queryResult.documents[i] as ItemDefinition; - const docRow = await documentToTreeRow(doc, partitionKey, leftPadIndex(i, docsLength)); - rows.push(docRow); - - // Yield to the event loop periodically to avoid UI freezes - if (i % 100 === 0 && i > 0) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - - return rows; -}; - -/** - * Get the type name for a value - */ -const getTypeName = (value: unknown): string => { - if (value === null) return 'Null'; - if (value === undefined) return 'Undefined'; - if (Array.isArray(value)) return 'Array'; - - const type = typeof value; - return type.charAt(0).toUpperCase() + type.slice(1); -}; - -/** - * Get the display value for a tree row - */ -const getDisplayValue = (value: unknown): string => { - if (Array.isArray(value)) return `(elements: ${value.length})`; - if (value && typeof value === 'object') return '{...}'; - return toStringUniversal(value); -}; - -/** - * Convert a value to a TreeRow with nested children - */ -const valueToTreeRow = (id: string, field: string, value: unknown): TreeRow => { - const row: TreeRow = { - id, - field, - value: getDisplayValue(value), - type: getTypeName(value), - isExpanded: false, - }; - - if (Array.isArray(value)) { - const children: TreeRow[] = []; - const arrayLength = Math.min(value.length, MAX_TREE_LEVEL_LENGTH); - - for (let i = 0; i < arrayLength; i++) { - children.push(valueToTreeRow(`${id}-${leftPadIndex(i, arrayLength + 1)}`, `${i}`, value[i])); - } - - if (value.length > MAX_TREE_LEVEL_LENGTH) { - children.push({ - id: `${id}-${leftPadIndex(MAX_TREE_LEVEL_LENGTH + 1, arrayLength + 1)}`, - field: '', - value: l10n.t('Array is too large to be shown'), - type: 'String', - }); - } - - if (children.length > 0) { - row.children = children; - } - } else if (value && typeof value === 'object') { - const children: TreeRow[] = []; - const sortedKeys = Object.keys(value).sort((a, b) => a.localeCompare(b)); - const objectLength = Math.min(sortedKeys.length, MAX_TREE_LEVEL_LENGTH); - - for (let i = 0; i < objectLength; i++) { - const key = sortedKeys[i]; - children.push( - valueToTreeRow( - `${id}-${leftPadIndex(i, objectLength + 1)}`, - key, - (value as Record)[key], - ), - ); - } - - if (sortedKeys.length > MAX_TREE_LEVEL_LENGTH) { - children.push({ - id: `${id}-${leftPadIndex(MAX_TREE_LEVEL_LENGTH + 1, objectLength + 1)}`, - field: '', - value: l10n.t('Object is too large to be shown'), - type: 'String', - }); - } - - if (children.length > 0) { - row.children = children; - } - } - - return row; -}; - -/** - * Convert a document to a hierarchical TreeRow. - * Caller must ensure the document is a plain object (not null / scalar / array). - */ -const documentToTreeRow = async ( - document: JSONObject, - partitionKey: PartitionKeyDefinition | undefined, - rootId: string, -): Promise => { - const headers = buildTableHeadersFromObjectDocuments([document], partitionKey, { - ShowPartitionKey: 'first', - ShowServiceColumns: 'last', - Sorting: 'ascending', - TruncateValues: MAX_TREE_LEVEL_LENGTH, - }); - const partitionKeyValues = extractPartitionKeyValues(document, partitionKey); - - // Build children for all headers - const children: TreeRow[] = []; - for (let index = 0; index < headers.length; index++) { - const header = headers[index]; - const value = header.startsWith('/') ? partitionKeyValues[header] : (document[header] as unknown); - children.push(valueToTreeRow(`${rootId}-${leftPadIndex(index, headers.length)}`, header, value)); - - // Yield to the event loop periodically to avoid UI freezes - if (index % 500 === 0 && index > 0) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - - // Return root document row with children - return { - id: rootId, - documentId: getDocumentId(document, partitionKey), - field: - typeof document['id'] === 'string' && document['id'] - ? document['id'] - : `${rootId} (Index number, id is missing)`, - value: '', - type: 'Document', - children: children.length > 0 ? children : undefined, - isExpanded: false, - }; -}; - -/** - * Get the headers for the table (don't take into account the nested objects) - * - * If `query` is provided and the SELECT clause projects a fixed set of columns - * (i.e. not `SELECT *` / `SELECT VALUE`), those column names are returned in - * declaration order, ignoring the `options` sorting/partition-key settings — - * the user already decided the shape of the result set. - * - * When the column set cannot be determined statically (or no `query` is given), - * the function falls back to scanning all documents for keys as before. - * Documents that are not plain objects (null, scalars, arrays) are skipped - * during the scan. - * - * @param documents Result documents — `QueryResultRecord[]` i.e. `JSONValue[]` - * @param partitionKey - * @param options - */ -const buildTableHeadersFromObjectDocuments = ( - documents: JSONObject[], - partitionKey: PartitionKeyDefinition | undefined, - options: ColumnOptions, -): string[] => { - // At this point the caller guarantees all documents are plain objects (collectionKind === 'object'). - const keys = new Set(); - const serviceKeys = new Set(); - - documents.forEach((doc) => { - Object.keys(doc as object).forEach((key) => { - if (CosmosDBHiddenFields.includes(key)) { - serviceKeys.add(key); - } else { - keys.add(key); - } - }); - }); - - const columns = Array.from(keys); - const serviceColumns = Array.from(serviceKeys); - const partitionKeyPaths = (partitionKey?.paths ?? []).map((path) => (path.startsWith('/') ? path : `/${path}`)); - const resultColumns: string[] = []; - - if (options.ShowPartitionKey === 'first') { - // Remove partition key paths from columns, since partition key paths are always shown first - partitionKeyPaths.forEach((path) => { - const index = columns.indexOf(path.slice(1)); - if (index !== -1) { - columns.splice(index, 1); - } - }); - - // If id is not in the partition key, add it as the first column - if (!partitionKeyPaths.includes('/id')) { - partitionKeyPaths.unshift('id'); - } - - partitionKeyPaths.forEach((path) => resultColumns.push(path)); - } - - if (options.Sorting === 'ascending') { - columns.sort((a, b) => a.localeCompare(b)).forEach((column) => resultColumns.push(column)); - } - - if (options.Sorting === 'descending') { - columns.sort((a, b) => b.localeCompare(a)).forEach((column) => resultColumns.push(column)); - } - - if (options.Sorting === 'none') { - columns.forEach((column) => resultColumns.push(column)); - } - - if (options.ShowServiceColumns === 'last') { - if (options.Sorting === 'ascending') { - serviceColumns.sort((a, b) => a.localeCompare(b)).forEach((column) => resultColumns.push(column)); - } - if (options.Sorting === 'descending') { - serviceColumns.sort((a, b) => b.localeCompare(a)).forEach((column) => resultColumns.push(column)); - } - if (options.Sorting === 'none') { - serviceColumns.forEach((column) => resultColumns.push(column)); - } - } - - // Remove duplicates while keeping order - const uniqueHeaders = new Set(resultColumns); - - return Array.from(uniqueHeaders); -}; - -/** - * Get the dataset for the table. - * - * Uses `getDocumentCollectionKind` to determine the data shape: - * - * - **object** path — each document is a plain object; fields are stored as raw - * values (not pre-serialized). String values are sanitized (control chars - * stripped). Partition key virtual columns are injected when - * `options.ShowPartitionKey === 'first'`. - * - **primitive** path — each document (scalar / null / array) is stored under - * the synthetic key `_value1`. - * - **empty / mixed** — returns an empty array. - * - * The UI layer is responsible for calling `toStringUniversal` / `truncateString` - * at render time; this function stores raw values. - */ -const buildTableRowsFromObjectDocuments = async ( - documents: JSONObject[], - partitionKey: PartitionKeyDefinition | undefined, - options: ColumnOptions, -): Promise => { - const injectPartitionKey = options.ShowPartitionKey === 'first' && !!partitionKey; - - const result = new Array(); - const chunkSize = 1000; - - for (let i = 0; i < documents.length; i += chunkSize) { - const chunk = documents.slice(i, i + chunkSize); - - chunk.forEach((docRaw) => { - // collectionKind === 'object' is guaranteed by the guard above - const doc = docRaw as Record; - const row: TableRecord = { - __id: globalThis.crypto.randomUUID(), - __documentId: getDocumentId(docRaw as unknown as ItemDefinition, partitionKey) ?? undefined, - }; - - // Inject virtual partition key columns (only for SELECT *) - if (injectPartitionKey) { - const partitionKeyPaths = (partitionKey.paths ?? []).map((path) => - path.startsWith('/') ? path.slice(1) : path, - ); - const partitionKeyValues = extractPartitionKey(docRaw as unknown as ItemDefinition, partitionKey); - const valuesArray = Array.isArray(partitionKeyValues) ? partitionKeyValues : [partitionKeyValues]; - - partitionKeyPaths.forEach((path, index) => { - const value: unknown = valuesArray[index]; - row[path] = isNonePartitionKey(value) ? undefined : (value ?? undefined); - }); - } - - // Copy all document fields as raw values; sanitise strings - Object.entries(doc).forEach(([key, value]) => { - row[key] = typeof value === 'string' ? sanitizeDisplayString(value) : value; - }); - - result.push(row); - }); - - if (i + chunkSize < documents.length) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - - return result; -}; - -/** - * Prepare the table data from the query result. - * - * Reconciliation matrix (queryKind × dataKind): - * - * | queryKind | dataKind | Action | - * |-------------|------------|-----------------------------------------------------| - * | any | empty | return empty immediately | - * | object | object | normal object path + partition key for SELECT * | - * | object | primitive | throw QueryResultMismatchError | - * | object | mixed | throw QueryResultMismatchError | - * | primitive | any | _value1 column — SELECT VALUE means "one value" | - * | unknown | object | fallback: scan document keys (legacy) | - * | unknown | primitive | _value1 column | - * | unknown | mixed | return empty (cannot render safely) | - * - * `ShowPartitionKey: 'first'` is automatically set **only** for `SELECT *` - * (i.e. when `queryKind === 'object'` AND the spec is `SelectStarSpec`). - */ -export const queryResultToTable = async ( - queryResult: SerializedQueryResult | null, - partitionKey: PartitionKeyDefinition | undefined, - options: ColumnOptions = { - ShowPartitionKey: 'none', - ShowServiceColumns: 'none', - Sorting: 'none', - TruncateValues: MAX_TREE_LEVEL_LENGTH, - }, -): Promise => { - if (!queryResult?.documents?.length) { - return { headers: [], dataset: [] }; - } - - const query = queryResult.query ?? ''; - const queryKind = getQueryResultKind(query); - const dataKind = getDocumentCollectionKind(queryResult.documents); - - // Empty data — nothing to show - if (dataKind === 'empty') { - return { headers: [], dataset: [] }; - } - - // SELECT VALUE — user explicitly asked for value-as-is, always one _value1 column. - // The expression may evaluate to a scalar, array, or even a full document object — - // all of these are treated as a single opaque value per row. - if (queryKind === 'primitive') { - const headers = ['_value1']; - const dataset: TableRecord[] = queryResult.documents.map((doc) => ({ - __id: globalThis.crypto.randomUUID(), - _value1: typeof doc === 'string' ? sanitizeDisplayString(doc) : doc, - })); - return { headers, dataset }; - } - - // SELECT * / SELECT list returned mixed or scalar data — real server-side error - if (queryKind === 'object' && (dataKind === 'primitive' || dataKind === 'mixed')) { - throw new QueryResultMismatchError(queryKind, dataKind); - } - - // unknown queryKind with mixed data — cannot render safely - if (dataKind === 'mixed') { - return { headers: [], dataset: [] }; - } - - const effectiveOptions = { ...options }; - if (isSelectStar(query)) { - effectiveOptions.ShowPartitionKey = 'first'; - } - - // Fast path: query can have a statically-known set of projected columns - const queryColumns = - getQueryColumns(query) ?? - buildTableHeadersFromObjectDocuments(queryResult.documents as JSONObject[], partitionKey, effectiveOptions); - - // Columns without a resolvable name (arithmetic, function calls, etc.) - // get a synthetic fallback name: _value1, _value2, … - let unnamedCounter = 0; - const headers = queryColumns.map((col) => col ?? `_value${++unnamedCounter}`); - - const dataset = await buildTableRowsFromObjectDocuments( - queryResult.documents as JSONObject[], - partitionKey, - effectiveOptions, - ); - - return { headers, dataset }; -}; - -export const queryMetricsToTable = (queryResult: SerializedQueryResult | null): Promise => { - if (!queryResult || queryResult?.queryMetrics === undefined) { - return Promise.resolve([]); - } - - const { queryMetrics, iteration, metadata } = queryResult; - const documentsCount = queryResult.documents?.length ?? 0; - const countPerPage = metadata.countPerPage ?? 100; - - const recordsCount = - countPerPage === -1 - ? documentsCount - ? `0 - ${documentsCount}` - : l10n.t('All') - : `${(iteration - 1) * countPerPage} - ${iteration * countPerPage}`; - - const stats: StatsItem[] = [ - { - metric: l10n.t('Request Charge', { comment: 'Cosmos DB metrics' }), - value: queryResult.requestCharge, - formattedValue: `${queryResult.requestCharge} RUs`, - tooltip: l10n.t('Request Charge', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Showing Results', { comment: 'Cosmos DB metrics' }), - value: recordsCount, - formattedValue: recordsCount, - tooltip: l10n.t('Showing Results', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Retrieved document count', { comment: 'Cosmos DB metrics' }), - value: queryResult.documents?.length ?? 0, - formattedValue: `${queryResult.documents?.length ?? 0}`, - tooltip: l10n.t('Total number of retrieved documents', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Retrieved document size', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.retrievedDocumentSize ?? 0, - formattedValue: `${queryMetrics.retrievedDocumentSize ?? 0} bytes`, - tooltip: l10n.t('Total size of retrieved documents in bytes', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Output document count', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.outputDocumentCount ?? 0, - formattedValue: `${queryMetrics.outputDocumentCount ?? ''}`, - tooltip: l10n.t('Number of output documents', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Output document size', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.outputDocumentSize ?? 0, - formattedValue: `${queryMetrics.outputDocumentSize ?? 0} bytes`, - tooltip: l10n.t('Total size of output documents in bytes', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Index hit document count', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.indexHitDocumentCount ?? 0, - formattedValue: `${queryMetrics.indexHitDocumentCount ?? ''}`, - tooltip: l10n.t('Total number of documents matched by the filter', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Index lookup time', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.indexLookupTime ?? 0, - formattedValue: `${queryMetrics.indexLookupTime ?? 0} ms`, - tooltip: l10n.t('Time spent in physical index layer', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Document load time', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.documentLoadTime ?? 0, - formattedValue: `${queryMetrics.documentLoadTime ?? 0} ms`, - tooltip: l10n.t('Time spent in loading documents', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Query engine execution time', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.runtimeExecutionTimes.queryEngineExecutionTime ?? 0, - formattedValue: `${queryMetrics.runtimeExecutionTimes.queryEngineExecutionTime ?? 0} ms`, - tooltip: l10n.t( - 'Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)', - { comment: 'Cosmos DB metrics' }, - ), - }, - { - metric: l10n.t('System function execution time', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime ?? 0, - formattedValue: `${queryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime ?? 0} ms`, - tooltip: l10n.t('Total time spent executing system (built-in) functions', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('User defined function execution time', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime ?? 0, - formattedValue: `${queryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime ?? 0} ms`, - tooltip: l10n.t('Total time spent executing user-defined functions', { comment: 'Cosmos DB metrics' }), - }, - { - metric: l10n.t('Document write time', { comment: 'Cosmos DB metrics' }), - value: queryMetrics.documentWriteTime ?? 0, - formattedValue: `${queryMetrics.documentWriteTime ?? 0} ms`, - tooltip: l10n.t('Time spent to write query result set to response buffer', { - comment: 'Cosmos DB metrics', - }), - }, - ]; - - if (queryResult.roundTrips) { - stats.push({ - metric: l10n.t('Round Trips'), - value: queryResult.roundTrips, - formattedValue: `${queryResult.roundTrips}`, - tooltip: l10n.t('Number of round trips'), - }); - } - if (queryResult.activityId) { - stats.push({ - metric: l10n.t('Activity id'), - value: queryResult.activityId, - formattedValue: `${queryResult.activityId}`, - tooltip: '', - }); - } - - return Promise.resolve(stats); -}; - -export const indexMetricsToTableItem = (queryResult: SerializedQueryResult): StatsItem => { - return { - metric: l10n.t('Index Metrics'), - value: queryResult.indexMetrics.trim(), - formattedValue: queryResult.indexMetrics.trim(), - tooltip: '', - }; -}; - -export const queryMetricsToJSON = async (queryResult: SerializedQueryResult | null): Promise => { - if (!queryResult) { - return ''; - } - - const stats = await queryMetricsToTable(queryResult); - - stats.push(indexMetricsToTableItem(queryResult)); - - return JSON.stringify(stats, null, 4); -}; diff --git a/src/utils/convertors/convertors.test.ts b/src/utils/convertors/convertors.test.ts new file mode 100644 index 000000000..4c160a543 --- /dev/null +++ b/src/utils/convertors/convertors.test.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type PartitionKeyDefinition } from '@azure/cosmos'; +import { type SerializedQueryMetrics, type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; +import { QueryResultMismatchError } from '../queryAnalysis'; +import { + indexMetricsToTableItem, + queryMetricsToJSON, + queryMetricsToTable, + queryResultToJSON, + queryResultToTable, + queryResultToTree, +} from './index'; + +/** Build a SerializedQueryResult with sensible defaults, overriding only what a test needs. */ +function makeResult(partial: Partial = {}): SerializedQueryResult { + return { + documents: [], + iteration: 1, + metadata: {}, + indexMetrics: '', + requestCharge: 0, + roundTrips: 0, + hasMoreResults: false, + query: '', + ...partial, + }; +} + +function makeMetrics(partial: Partial = {}): SerializedQueryMetrics { + return { + documentLoadTime: 0, + documentWriteTime: 0, + indexHitDocumentCount: 0, + outputDocumentCount: 0, + outputDocumentSize: 0, + indexLookupTime: 0, + retrievedDocumentCount: 0, + retrievedDocumentSize: 0, + vmExecutionTime: 0, + runtimeExecutionTimes: { + queryEngineExecutionTime: 0, + systemFunctionExecutionTime: 0, + userDefinedFunctionExecutionTime: 0, + }, + totalQueryExecutionTime: 0, + ...partial, + }; +} + +describe('convertors', () => { + // ─── queryResultToJSON ──────────────────────────────────────────────────── + describe('queryResultToJSON', () => { + it('returns an empty string for null', () => { + expect(queryResultToJSON(null)).toBe(''); + }); + + it('serializes all documents pretty-printed', () => { + const result = makeResult({ documents: [{ a: 1 }, { b: 2 }] }); + expect(queryResultToJSON(result)).toBe(JSON.stringify([{ a: 1 }, { b: 2 }], null, 4)); + }); + + it('serializes only the selected documents (by index)', () => { + const result = makeResult({ documents: [{ a: 1 }, { b: 2 }, { c: 3 }] }); + expect(queryResultToJSON(result, [0, 2])).toBe(JSON.stringify([{ a: 1 }, { c: 3 }], null, 4)); + }); + + it('returns "[]" when the selection matches nothing', () => { + const result = makeResult({ documents: [{ a: 1 }] }); + expect(queryResultToJSON(result, [5])).toBe(JSON.stringify([], null, 4)); + }); + }); + + // ─── queryResultToTable ─────────────────────────────────────────────────── + describe('queryResultToTable', () => { + it('returns empty table for null / empty documents', async () => { + expect(await queryResultToTable(null, undefined)).toEqual({ headers: [], dataset: [] }); + expect(await queryResultToTable(makeResult(), undefined)).toEqual({ headers: [], dataset: [] }); + }); + + it('SELECT VALUE scalar → single _value1 column', async () => { + const result = makeResult({ query: 'SELECT VALUE c.name FROM c', documents: ['Alice', 'Bob'] }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['_value1']); + expect(table.dataset.map((r) => r._value1)).toEqual(['Alice', 'Bob']); + }); + + it('SELECT * with object docs → id first, then fields (partition key first)', async () => { + const result = makeResult({ query: 'SELECT * FROM c', documents: [{ id: '1', name: 'x' }] }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['id', 'name']); + expect(table.dataset[0].id).toBe('1'); + expect(table.dataset[0].name).toBe('x'); + // each row carries an internal __id + expect(typeof table.dataset[0].__id).toBe('string'); + }); + + it('SELECT list → fixed projected columns, ignoring extra document fields', async () => { + const result = makeResult({ + query: 'SELECT c.id, c.name FROM c', + documents: [{ id: '1', name: 'x', extra: 'ignored' }], + }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['id', 'name']); + }); + + it('unnamed projected column (arithmetic) → synthetic _value1 header', async () => { + const result = makeResult({ query: 'SELECT c.a + c.b FROM c', documents: [{ result: 5 }] }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['_value1']); + }); + + it('throws QueryResultMismatchError when an object query returns primitive data', async () => { + const result = makeResult({ query: 'SELECT * FROM c', documents: ['scalar'] }); + await expect(queryResultToTable(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); + }); + + it('returns empty table for unknown query with mixed data', async () => { + const result = makeResult({ query: '', documents: [{ a: 1 }, 'scalar'] }); + expect(await queryResultToTable(result, undefined)).toEqual({ headers: [], dataset: [] }); + }); + + it('injects virtual partition-key column for SELECT * when a partition key is provided', async () => { + const partitionKey: PartitionKeyDefinition = { paths: ['/pk'] } as PartitionKeyDefinition; + const result = makeResult({ query: 'SELECT * FROM c', documents: [{ id: '1', pk: 'tenant-a' }] }); + const table = await queryResultToTable(result, partitionKey); + // partition-key path column is shown (prefixed with /), id first + expect(table.headers).toContain('/pk'); + expect(table.headers[0]).toBe('id'); + expect(table.dataset[0]['pk']).toBe('tenant-a'); + }); + }); + + // ─── queryResultToTree ──────────────────────────────────────────────────── + describe('queryResultToTree', () => { + it('returns [] for empty / primitive collections', async () => { + expect(await queryResultToTree(makeResult(), undefined)).toEqual([]); + expect(await queryResultToTree(makeResult({ documents: ['a', 'b'] }), undefined)).toEqual([]); + }); + + it('builds a Document row with nested children for object docs', async () => { + const result = makeResult({ documents: [{ id: 'doc-1', tags: ['a', 'b'] }] }); + const rows = await queryResultToTree(result, undefined); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('Document'); + expect(rows[0].field).toBe('doc-1'); + + const fields = rows[0].children?.map((c) => c.field); + expect(fields).toContain('id'); + expect(fields).toContain('tags'); + + const tagsRow = rows[0].children?.find((c) => c.field === 'tags'); + expect(tagsRow?.type).toBe('Array'); + expect(tagsRow?.children).toHaveLength(2); + }); + + it('throws QueryResultMismatchError when an object query returns mixed data', async () => { + const result = makeResult({ query: 'SELECT * FROM c', documents: [{ a: 1 }, 'scalar'] }); + await expect(queryResultToTree(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); + }); + }); + + // ─── queryMetricsToTable ────────────────────────────────────────────────── + describe('queryMetricsToTable', () => { + it('returns [] when there is no result or no metrics', async () => { + expect(await queryMetricsToTable(null)).toEqual([]); + expect(await queryMetricsToTable(makeResult())).toEqual([]); + }); + + it('formats the request charge and includes core metrics', async () => { + const result = makeResult({ requestCharge: 5, queryMetrics: makeMetrics({ retrievedDocumentSize: 128 }) }); + const stats = await queryMetricsToTable(result); + const charge = stats.find((s) => s.metric === 'Request Charge'); + expect(charge?.formattedValue).toBe('5 RUs'); + const size = stats.find((s) => s.metric === 'Retrieved document size'); + expect(size?.formattedValue).toBe('128 bytes'); + }); + + it('appends Round Trips and Activity id only when present', async () => { + const without = await queryMetricsToTable(makeResult({ roundTrips: 0, queryMetrics: makeMetrics() })); + expect(without.some((s) => s.metric === 'Round Trips')).toBe(false); + expect(without.some((s) => s.metric === 'Activity id')).toBe(false); + + const withExtra = await queryMetricsToTable( + makeResult({ roundTrips: 3, activityId: 'abc', queryMetrics: makeMetrics() }), + ); + expect(withExtra.find((s) => s.metric === 'Round Trips')?.value).toBe(3); + expect(withExtra.find((s) => s.metric === 'Activity id')?.value).toBe('abc'); + }); + + it('renders "All" results when countPerPage is -1 and there are no documents', async () => { + const result = makeResult({ metadata: { countPerPage: -1 }, documents: [], queryMetrics: makeMetrics() }); + const stats = await queryMetricsToTable(result); + expect(stats.find((s) => s.metric === 'Showing Results')?.value).toBe('All'); + }); + + it('renders a 0 - N range when countPerPage is -1 and documents exist', async () => { + const result = makeResult({ + metadata: { countPerPage: -1 }, + documents: [{ a: 1 }, { b: 2 }], + queryMetrics: makeMetrics(), + }); + const stats = await queryMetricsToTable(result); + expect(stats.find((s) => s.metric === 'Showing Results')?.value).toBe('0 - 2'); + }); + }); + + // ─── indexMetricsToTableItem ────────────────────────────────────────────── + describe('indexMetricsToTableItem', () => { + it('trims the raw index metrics string', () => { + const item = indexMetricsToTableItem(makeResult({ indexMetrics: ' some metrics ' })); + expect(item.metric).toBe('Index Metrics'); + expect(item.value).toBe('some metrics'); + expect(item.formattedValue).toBe('some metrics'); + }); + }); + + // ─── queryMetricsToJSON ─────────────────────────────────────────────────── + describe('queryMetricsToJSON', () => { + it('returns an empty string for null', async () => { + expect(await queryMetricsToJSON(null)).toBe(''); + }); + + it('serializes metrics and appends the index metrics item', async () => { + const result = makeResult({ indexMetrics: 'idx', queryMetrics: makeMetrics() }); + const json = await queryMetricsToJSON(result); + const parsed = JSON.parse(json) as Array<{ metric: string; value: unknown }>; + expect(parsed.some((s) => s.metric === 'Index Metrics' && s.value === 'idx')).toBe(true); + }); + }); +}); diff --git a/src/utils/convertors/index.ts b/src/utils/convertors/index.ts new file mode 100644 index 000000000..2525f9c4e --- /dev/null +++ b/src/utils/convertors/index.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Public API for query-result conversion. Split into focused modules: + * - json.ts — document JSON serialization + * - table.ts — table (headers + rows) conversion + * - tree.ts — hierarchical tree conversion + * - metrics.ts — query-metrics → stats table / JSON + * + * Internal helpers (e.g. buildTableHeadersFromObjectDocuments) are intentionally NOT re-exported + * here to keep the public surface identical to the previous single-file module. + */ + +export type { ColumnOptions, StatsItem, TableData, TableRecord, TableRowMeta, TreeRow } from './types'; +export { queryResultToJSON } from './json'; +export { queryResultToTable } from './table'; +export { queryResultToTree } from './tree'; +export { indexMetricsToTableItem, queryMetricsToJSON, queryMetricsToTable } from './metrics'; diff --git a/src/utils/convertors/json.ts b/src/utils/convertors/json.ts new file mode 100644 index 000000000..92ebaccb8 --- /dev/null +++ b/src/utils/convertors/json.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * NOTE: This function is async-friendly by design so it can be moved to the backend in the future. + */ + +import { type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; + +export const queryResultToJSON = (queryResult: SerializedQueryResult | null, selection?: number[]): string => { + if (!queryResult) { + return ''; + } + + if (selection) { + const selectedDocs = queryResult.documents + .map((doc, index) => { + if (!selection.includes(index)) { + return null; + } + return doc; + }) + .filter((doc) => doc !== null); + + return JSON.stringify(selectedDocs, null, 4); + } + + return JSON.stringify(queryResult.documents, null, 4); +}; diff --git a/src/utils/convertors/metrics.ts b/src/utils/convertors/metrics.ts new file mode 100644 index 000000000..4dd94e457 --- /dev/null +++ b/src/utils/convertors/metrics.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Query-metrics conversion: turn a SerializedQueryResult's metrics into StatsItem[] / JSON. + * + * NOTE: Mostly of these functions are async to be able to move them to backend in the future. + */ + +import * as l10n from '@vscode/l10n'; +import { type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; +import { type StatsItem } from './types'; + +export const queryMetricsToTable = (queryResult: SerializedQueryResult | null): Promise => { + if (!queryResult || queryResult?.queryMetrics === undefined) { + return Promise.resolve([]); + } + + const { queryMetrics, iteration, metadata } = queryResult; + const documentsCount = queryResult.documents?.length ?? 0; + const countPerPage = metadata.countPerPage ?? 100; + + const recordsCount = + countPerPage === -1 + ? documentsCount + ? `0 - ${documentsCount}` + : l10n.t('All') + : `${(iteration - 1) * countPerPage} - ${iteration * countPerPage}`; + + const stats: StatsItem[] = [ + { + metric: l10n.t('Request Charge', { comment: 'Cosmos DB metrics' }), + value: queryResult.requestCharge, + formattedValue: `${queryResult.requestCharge} RUs`, + tooltip: l10n.t('Request Charge', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Showing Results', { comment: 'Cosmos DB metrics' }), + value: recordsCount, + formattedValue: recordsCount, + tooltip: l10n.t('Showing Results', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Retrieved document count', { comment: 'Cosmos DB metrics' }), + value: queryResult.documents?.length ?? 0, + formattedValue: `${queryResult.documents?.length ?? 0}`, + tooltip: l10n.t('Total number of retrieved documents', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Retrieved document size', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.retrievedDocumentSize ?? 0, + formattedValue: `${queryMetrics.retrievedDocumentSize ?? 0} bytes`, + tooltip: l10n.t('Total size of retrieved documents in bytes', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Output document count', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.outputDocumentCount ?? 0, + formattedValue: `${queryMetrics.outputDocumentCount ?? ''}`, + tooltip: l10n.t('Number of output documents', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Output document size', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.outputDocumentSize ?? 0, + formattedValue: `${queryMetrics.outputDocumentSize ?? 0} bytes`, + tooltip: l10n.t('Total size of output documents in bytes', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Index hit document count', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.indexHitDocumentCount ?? 0, + formattedValue: `${queryMetrics.indexHitDocumentCount ?? ''}`, + tooltip: l10n.t('Total number of documents matched by the filter', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Index lookup time', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.indexLookupTime ?? 0, + formattedValue: `${queryMetrics.indexLookupTime ?? 0} ms`, + tooltip: l10n.t('Time spent in physical index layer', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Document load time', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.documentLoadTime ?? 0, + formattedValue: `${queryMetrics.documentLoadTime ?? 0} ms`, + tooltip: l10n.t('Time spent in loading documents', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Query engine execution time', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.runtimeExecutionTimes.queryEngineExecutionTime ?? 0, + formattedValue: `${queryMetrics.runtimeExecutionTimes.queryEngineExecutionTime ?? 0} ms`, + tooltip: l10n.t( + 'Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)', + { comment: 'Cosmos DB metrics' }, + ), + }, + { + metric: l10n.t('System function execution time', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime ?? 0, + formattedValue: `${queryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime ?? 0} ms`, + tooltip: l10n.t('Total time spent executing system (built-in) functions', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('User defined function execution time', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime ?? 0, + formattedValue: `${queryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime ?? 0} ms`, + tooltip: l10n.t('Total time spent executing user-defined functions', { comment: 'Cosmos DB metrics' }), + }, + { + metric: l10n.t('Document write time', { comment: 'Cosmos DB metrics' }), + value: queryMetrics.documentWriteTime ?? 0, + formattedValue: `${queryMetrics.documentWriteTime ?? 0} ms`, + tooltip: l10n.t('Time spent to write query result set to response buffer', { + comment: 'Cosmos DB metrics', + }), + }, + ]; + + if (queryResult.roundTrips) { + stats.push({ + metric: l10n.t('Round Trips'), + value: queryResult.roundTrips, + formattedValue: `${queryResult.roundTrips}`, + tooltip: l10n.t('Number of round trips'), + }); + } + if (queryResult.activityId) { + stats.push({ + metric: l10n.t('Activity id'), + value: queryResult.activityId, + formattedValue: `${queryResult.activityId}`, + tooltip: '', + }); + } + + return Promise.resolve(stats); +}; + +export const indexMetricsToTableItem = (queryResult: SerializedQueryResult): StatsItem => { + return { + metric: l10n.t('Index Metrics'), + value: queryResult.indexMetrics.trim(), + formattedValue: queryResult.indexMetrics.trim(), + tooltip: '', + }; +}; + +export const queryMetricsToJSON = async (queryResult: SerializedQueryResult | null): Promise => { + if (!queryResult) { + return ''; + } + + const stats = await queryMetricsToTable(queryResult); + + stats.push(indexMetricsToTableItem(queryResult)); + + return JSON.stringify(stats, null, 4); +}; diff --git a/src/utils/convertors/table.ts b/src/utils/convertors/table.ts new file mode 100644 index 000000000..bf99346b2 --- /dev/null +++ b/src/utils/convertors/table.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Table conversion: turn a SerializedQueryResult into headers + row records for the data grid. + * + * NOTE: Mostly of these functions are async to be able to move them to backend in the future. + */ + +import { type ItemDefinition, type JSONObject, type PartitionKeyDefinition } from '@azure/cosmos'; +import { isEmptyObject } from 'es-toolkit'; +import { CosmosDBHiddenFields } from '../../cosmosdb/cosmosdb-shared-constants'; +import { type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; +import { extractPartitionKey, getDocumentId } from '../document'; +import { + QueryResultMismatchError, + getDocumentCollectionKind, + getQueryColumns, + getQueryResultKind, + isSelectStar, +} from '../queryAnalysis'; +import { sanitizeDisplayString } from '../sanitization'; +import { type ColumnOptions, MAX_TREE_LEVEL_LENGTH, type TableData, type TableRecord } from './types'; + +/** + * Checks if a value is a NonePartitionKey (empty object used by Cosmos DB SDK). + * NonePartitionKeyLiteral from @azure/cosmos is defined as {} and represents + * a partition key value for items without a value for partition key. + */ +const isNonePartitionKey = (value: unknown): boolean => { + return isEmptyObject(value); +}; + +/** + * Get the headers for the table (don't take into account the nested objects) + * + * If `query` is provided and the SELECT clause projects a fixed set of columns + * (i.e. not `SELECT *` / `SELECT VALUE`), those column names are returned in + * declaration order, ignoring the `options` sorting/partition-key settings — + * the user already decided the shape of the result set. + * + * When the column set cannot be determined statically (or no `query` is given), + * the function falls back to scanning all documents for keys as before. + * Documents that are not plain objects (null, scalars, arrays) are skipped + * during the scan. + * + * Exported for reuse by the tree converter; not part of the public barrel. + * + * @param documents Result documents — `QueryResultRecord[]` i.e. `JSONValue[]` + * @param partitionKey + * @param options + */ +export const buildTableHeadersFromObjectDocuments = ( + documents: JSONObject[], + partitionKey: PartitionKeyDefinition | undefined, + options: ColumnOptions, +): string[] => { + // At this point the caller guarantees all documents are plain objects (collectionKind === 'object'). + const keys = new Set(); + const serviceKeys = new Set(); + + documents.forEach((doc) => { + Object.keys(doc as object).forEach((key) => { + if (CosmosDBHiddenFields.includes(key)) { + serviceKeys.add(key); + } else { + keys.add(key); + } + }); + }); + + const columns = Array.from(keys); + const serviceColumns = Array.from(serviceKeys); + const partitionKeyPaths = (partitionKey?.paths ?? []).map((path) => (path.startsWith('/') ? path : `/${path}`)); + const resultColumns: string[] = []; + + if (options.ShowPartitionKey === 'first') { + // Remove partition key paths from columns, since partition key paths are always shown first + partitionKeyPaths.forEach((path) => { + const index = columns.indexOf(path.slice(1)); + if (index !== -1) { + columns.splice(index, 1); + } + }); + + // If id is not in the partition key, add it as the first column + if (!partitionKeyPaths.includes('/id')) { + partitionKeyPaths.unshift('id'); + } + + partitionKeyPaths.forEach((path) => resultColumns.push(path)); + } + + if (options.Sorting === 'ascending') { + columns.sort((a, b) => a.localeCompare(b)).forEach((column) => resultColumns.push(column)); + } + + if (options.Sorting === 'descending') { + columns.sort((a, b) => b.localeCompare(a)).forEach((column) => resultColumns.push(column)); + } + + if (options.Sorting === 'none') { + columns.forEach((column) => resultColumns.push(column)); + } + + if (options.ShowServiceColumns === 'last') { + if (options.Sorting === 'ascending') { + serviceColumns.sort((a, b) => a.localeCompare(b)).forEach((column) => resultColumns.push(column)); + } + if (options.Sorting === 'descending') { + serviceColumns.sort((a, b) => b.localeCompare(a)).forEach((column) => resultColumns.push(column)); + } + if (options.Sorting === 'none') { + serviceColumns.forEach((column) => resultColumns.push(column)); + } + } + + // Remove duplicates while keeping order + const uniqueHeaders = new Set(resultColumns); + + return Array.from(uniqueHeaders); +}; + +/** + * Get the dataset for the table. + * + * Uses `getDocumentCollectionKind` to determine the data shape: + * + * - **object** path — each document is a plain object; fields are stored as raw + * values (not pre-serialized). String values are sanitized (control chars + * stripped). Partition key virtual columns are injected when + * `options.ShowPartitionKey === 'first'`. + * - **primitive** path — each document (scalar / null / array) is stored under + * the synthetic key `_value1`. + * - **empty / mixed** — returns an empty array. + * + * The UI layer is responsible for calling `toStringUniversal` / `truncateString` + * at render time; this function stores raw values. + */ +const buildTableRowsFromObjectDocuments = async ( + documents: JSONObject[], + partitionKey: PartitionKeyDefinition | undefined, + options: ColumnOptions, +): Promise => { + const injectPartitionKey = options.ShowPartitionKey === 'first' && !!partitionKey; + + const result = new Array(); + const chunkSize = 1000; + + for (let i = 0; i < documents.length; i += chunkSize) { + const chunk = documents.slice(i, i + chunkSize); + + chunk.forEach((docRaw) => { + // collectionKind === 'object' is guaranteed by the guard above + const doc = docRaw as Record; + const row: TableRecord = { + __id: globalThis.crypto.randomUUID(), + __documentId: getDocumentId(docRaw as unknown as ItemDefinition, partitionKey) ?? undefined, + }; + + // Inject virtual partition key columns (only for SELECT *) + if (injectPartitionKey) { + const partitionKeyPaths = (partitionKey.paths ?? []).map((path) => + path.startsWith('/') ? path.slice(1) : path, + ); + const partitionKeyValues = extractPartitionKey(docRaw as unknown as ItemDefinition, partitionKey); + const valuesArray = Array.isArray(partitionKeyValues) ? partitionKeyValues : [partitionKeyValues]; + + partitionKeyPaths.forEach((path, index) => { + const value: unknown = valuesArray[index]; + row[path] = isNonePartitionKey(value) ? undefined : (value ?? undefined); + }); + } + + // Copy all document fields as raw values; sanitise strings + Object.entries(doc).forEach(([key, value]) => { + row[key] = typeof value === 'string' ? sanitizeDisplayString(value) : value; + }); + + result.push(row); + }); + + if (i + chunkSize < documents.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + return result; +}; + +/** + * Prepare the table data from the query result. + * + * Reconciliation matrix (queryKind × dataKind): + * + * | queryKind | dataKind | Action | + * |-------------|------------|-----------------------------------------------------| + * | any | empty | return empty immediately | + * | object | object | normal object path + partition key for SELECT * | + * | object | primitive | throw QueryResultMismatchError | + * | object | mixed | throw QueryResultMismatchError | + * | primitive | any | _value1 column — SELECT VALUE means "one value" | + * | unknown | object | fallback: scan document keys (legacy) | + * | unknown | primitive | _value1 column | + * | unknown | mixed | return empty (cannot render safely) | + * + * `ShowPartitionKey: 'first'` is automatically set **only** for `SELECT *` + * (i.e. when `queryKind === 'object'` AND the spec is `SelectStarSpec`). + */ +export const queryResultToTable = async ( + queryResult: SerializedQueryResult | null, + partitionKey: PartitionKeyDefinition | undefined, + options: ColumnOptions = { + ShowPartitionKey: 'none', + ShowServiceColumns: 'none', + Sorting: 'none', + TruncateValues: MAX_TREE_LEVEL_LENGTH, + }, +): Promise => { + if (!queryResult?.documents?.length) { + return { headers: [], dataset: [] }; + } + + const query = queryResult.query ?? ''; + const queryKind = getQueryResultKind(query); + const dataKind = getDocumentCollectionKind(queryResult.documents); + + // Empty data — nothing to show + if (dataKind === 'empty') { + return { headers: [], dataset: [] }; + } + + // SELECT VALUE — user explicitly asked for value-as-is, always one _value1 column. + // The expression may evaluate to a scalar, array, or even a full document object — + // all of these are treated as a single opaque value per row. + if (queryKind === 'primitive') { + const headers = ['_value1']; + const dataset: TableRecord[] = queryResult.documents.map((doc) => ({ + __id: globalThis.crypto.randomUUID(), + _value1: typeof doc === 'string' ? sanitizeDisplayString(doc) : doc, + })); + return { headers, dataset }; + } + + // SELECT * / SELECT list returned mixed or scalar data — real server-side error + if (queryKind === 'object' && (dataKind === 'primitive' || dataKind === 'mixed')) { + throw new QueryResultMismatchError(queryKind, dataKind); + } + + // unknown queryKind with mixed data — cannot render safely + if (dataKind === 'mixed') { + return { headers: [], dataset: [] }; + } + + const effectiveOptions = { ...options }; + if (isSelectStar(query)) { + effectiveOptions.ShowPartitionKey = 'first'; + } + + // Fast path: query can have a statically-known set of projected columns + const queryColumns = + getQueryColumns(query) ?? + buildTableHeadersFromObjectDocuments(queryResult.documents as JSONObject[], partitionKey, effectiveOptions); + + // Columns without a resolvable name (arithmetic, function calls, etc.) + // get a synthetic fallback name: _value1, _value2, … + let unnamedCounter = 0; + const headers = queryColumns.map((col) => col ?? `_value${++unnamedCounter}`); + + const dataset = await buildTableRowsFromObjectDocuments( + queryResult.documents as JSONObject[], + partitionKey, + effectiveOptions, + ); + + return { headers, dataset }; +}; diff --git a/src/utils/convertors/tree.ts b/src/utils/convertors/tree.ts new file mode 100644 index 000000000..582927df0 --- /dev/null +++ b/src/utils/convertors/tree.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Tree conversion: turn a SerializedQueryResult into a hierarchical TreeRow[] for the tree view. + * + * NOTE: Mostly of these functions are async to be able to move them to backend in the future. + */ + +import { type ItemDefinition, type JSONObject, type PartitionKeyDefinition } from '@azure/cosmos'; +import * as l10n from '@vscode/l10n'; +import { type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; +import { extractPartitionKeyValues, getDocumentId } from '../document'; +import { QueryResultMismatchError, getDocumentCollectionKind, getQueryResultKind } from '../queryAnalysis'; +import { leftPadIndex, toStringUniversal } from '../strings'; +import { buildTableHeadersFromObjectDocuments } from './table'; +import { MAX_TREE_LEVEL_LENGTH, type TreeRow } from './types'; + +/** + * Get the type name for a value + */ +const getTypeName = (value: unknown): string => { + if (value === null) return 'Null'; + if (value === undefined) return 'Undefined'; + if (Array.isArray(value)) return 'Array'; + + const type = typeof value; + return type.charAt(0).toUpperCase() + type.slice(1); +}; + +/** + * Get the display value for a tree row + */ +const getDisplayValue = (value: unknown): string => { + if (Array.isArray(value)) return `(elements: ${value.length})`; + if (value && typeof value === 'object') return '{...}'; + return toStringUniversal(value); +}; + +/** + * Convert a value to a TreeRow with nested children + */ +const valueToTreeRow = (id: string, field: string, value: unknown): TreeRow => { + const row: TreeRow = { + id, + field, + value: getDisplayValue(value), + type: getTypeName(value), + isExpanded: false, + }; + + if (Array.isArray(value)) { + const children: TreeRow[] = []; + const arrayLength = Math.min(value.length, MAX_TREE_LEVEL_LENGTH); + + for (let i = 0; i < arrayLength; i++) { + children.push(valueToTreeRow(`${id}-${leftPadIndex(i, arrayLength + 1)}`, `${i}`, value[i])); + } + + if (value.length > MAX_TREE_LEVEL_LENGTH) { + children.push({ + id: `${id}-${leftPadIndex(MAX_TREE_LEVEL_LENGTH + 1, arrayLength + 1)}`, + field: '', + value: l10n.t('Array is too large to be shown'), + type: 'String', + }); + } + + if (children.length > 0) { + row.children = children; + } + } else if (value && typeof value === 'object') { + const children: TreeRow[] = []; + const sortedKeys = Object.keys(value).sort((a, b) => a.localeCompare(b)); + const objectLength = Math.min(sortedKeys.length, MAX_TREE_LEVEL_LENGTH); + + for (let i = 0; i < objectLength; i++) { + const key = sortedKeys[i]; + children.push( + valueToTreeRow( + `${id}-${leftPadIndex(i, objectLength + 1)}`, + key, + (value as Record)[key], + ), + ); + } + + if (sortedKeys.length > MAX_TREE_LEVEL_LENGTH) { + children.push({ + id: `${id}-${leftPadIndex(MAX_TREE_LEVEL_LENGTH + 1, objectLength + 1)}`, + field: '', + value: l10n.t('Object is too large to be shown'), + type: 'String', + }); + } + + if (children.length > 0) { + row.children = children; + } + } + + return row; +}; + +/** + * Convert a document to a hierarchical TreeRow. + * Caller must ensure the document is a plain object (not null / scalar / array). + */ +const documentToTreeRow = async ( + document: JSONObject, + partitionKey: PartitionKeyDefinition | undefined, + rootId: string, +): Promise => { + const headers = buildTableHeadersFromObjectDocuments([document], partitionKey, { + ShowPartitionKey: 'first', + ShowServiceColumns: 'last', + Sorting: 'ascending', + TruncateValues: MAX_TREE_LEVEL_LENGTH, + }); + const partitionKeyValues = extractPartitionKeyValues(document, partitionKey); + + // Build children for all headers + const children: TreeRow[] = []; + for (let index = 0; index < headers.length; index++) { + const header = headers[index]; + const value = header.startsWith('/') ? partitionKeyValues[header] : (document[header] as unknown); + children.push(valueToTreeRow(`${rootId}-${leftPadIndex(index, headers.length)}`, header, value)); + + // Yield to the event loop periodically to avoid UI freezes + if (index % 500 === 0 && index > 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + // Return root document row with children + return { + id: rootId, + documentId: getDocumentId(document, partitionKey), + field: + typeof document['id'] === 'string' && document['id'] + ? document['id'] + : `${rootId} (Index number, id is missing)`, + value: '', + type: 'Document', + children: children.length > 0 ? children : undefined, + isExpanded: false, + }; +}; + +export const queryResultToTree = async ( + queryResult: SerializedQueryResult | null, + partitionKey: PartitionKeyDefinition | undefined, +): Promise => { + if (!queryResult?.documents?.length) { + return []; + } + + const queryKind = getQueryResultKind(queryResult.query); + const dataKind = getDocumentCollectionKind(queryResult.documents); + + // Tree view only makes sense for structured object documents + if (dataKind === 'empty' || dataKind === 'primitive') { + return []; + } + if (queryKind === 'object' && dataKind !== 'object') { + throw new QueryResultMismatchError(queryKind, dataKind); + } + if (dataKind !== 'object') { + // unknown queryKind with mixed/primitive data — cannot render as tree + return []; + } + + const rows: TreeRow[] = []; + const docsLength = queryResult.documents.length; + + for (let i = 0; i < docsLength; i++) { + // dataKind === 'object' is guaranteed by the guard above + const doc = queryResult.documents[i] as ItemDefinition; + const docRow = await documentToTreeRow(doc, partitionKey, leftPadIndex(i, docsLength)); + rows.push(docRow); + + // Yield to the event loop periodically to avoid UI freezes + if (i % 100 === 0 && i > 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + return rows; +}; diff --git a/src/utils/convertors/types.ts b/src/utils/convertors/types.ts new file mode 100644 index 000000000..726cd1232 --- /dev/null +++ b/src/utils/convertors/types.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosDBRecordIdentifier } from '../../cosmosdb/types/queryResult'; + +/** Max number of array elements / object keys rendered per tree level, and default value truncation length. */ +export const MAX_TREE_LEVEL_LENGTH = 100; + +export type StatsItem = { + metric: string; + value: string | number; + formattedValue: string; + tooltip: string; +}; + +/** Row metadata — never overlaps with document field names */ +export type TableRowMeta = { + __id: string; + __documentId?: CosmosDBRecordIdentifier; +}; + +/** + * A single table row. + * `__id` and `__documentId` are internal; all other keys are raw document + * values (not pre-serialized). The UI layer calls `toStringUniversal` at render time. + */ +export type TableRecord = TableRowMeta & { + [key: string]: unknown; +}; + +export type TableData = { + headers: string[]; + dataset: TableRecord[]; +}; + +/** + * Tree row format for hierarchical tree view. + * Uses nested children array for tree structure. + */ +export type TreeRow = { + id: string; + documentId?: CosmosDBRecordIdentifier; + field: string; + value: string; + type: string; + children?: TreeRow[]; + isExpanded?: boolean; +}; + +export type ColumnOptions = { + ShowPartitionKey: 'first' | 'none'; // 'first' = show id + partition key first, 'none' = the nested partition key values are hidden + partition key are shown as is (without / prefix) + ShowServiceColumns: 'last' | 'none'; // 'last' = show service columns last, 'none' = hide service columns + Sorting: 'ascending' | 'descending' | 'none'; // 'ascending' = sort columns in ascending order, 'descending' = sort columns in descending order, 'none' = no sorting + TruncateValues: number; // truncate values to this length, 0 = no truncation +}; From ab758baf7c3f91d1e35964bff9f93a6f4ad671fe Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:56:21 +0200 Subject: [PATCH 02/12] test(convertors): split tests into json/table/tree/metrics with shared fixtures --- src/utils/convertors/convertors.test.ts | 234 ------------------------ src/utils/convertors/json.test.ts | 28 +++ src/utils/convertors/metrics.test.ts | 73 ++++++++ src/utils/convertors/table.test.ts | 68 +++++++ src/utils/convertors/testFixtures.ts | 48 +++++ src/utils/convertors/tree.test.ts | 36 ++++ 6 files changed, 253 insertions(+), 234 deletions(-) delete mode 100644 src/utils/convertors/convertors.test.ts create mode 100644 src/utils/convertors/json.test.ts create mode 100644 src/utils/convertors/metrics.test.ts create mode 100644 src/utils/convertors/table.test.ts create mode 100644 src/utils/convertors/testFixtures.ts create mode 100644 src/utils/convertors/tree.test.ts diff --git a/src/utils/convertors/convertors.test.ts b/src/utils/convertors/convertors.test.ts deleted file mode 100644 index 4c160a543..000000000 --- a/src/utils/convertors/convertors.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type PartitionKeyDefinition } from '@azure/cosmos'; -import { type SerializedQueryMetrics, type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; -import { QueryResultMismatchError } from '../queryAnalysis'; -import { - indexMetricsToTableItem, - queryMetricsToJSON, - queryMetricsToTable, - queryResultToJSON, - queryResultToTable, - queryResultToTree, -} from './index'; - -/** Build a SerializedQueryResult with sensible defaults, overriding only what a test needs. */ -function makeResult(partial: Partial = {}): SerializedQueryResult { - return { - documents: [], - iteration: 1, - metadata: {}, - indexMetrics: '', - requestCharge: 0, - roundTrips: 0, - hasMoreResults: false, - query: '', - ...partial, - }; -} - -function makeMetrics(partial: Partial = {}): SerializedQueryMetrics { - return { - documentLoadTime: 0, - documentWriteTime: 0, - indexHitDocumentCount: 0, - outputDocumentCount: 0, - outputDocumentSize: 0, - indexLookupTime: 0, - retrievedDocumentCount: 0, - retrievedDocumentSize: 0, - vmExecutionTime: 0, - runtimeExecutionTimes: { - queryEngineExecutionTime: 0, - systemFunctionExecutionTime: 0, - userDefinedFunctionExecutionTime: 0, - }, - totalQueryExecutionTime: 0, - ...partial, - }; -} - -describe('convertors', () => { - // ─── queryResultToJSON ──────────────────────────────────────────────────── - describe('queryResultToJSON', () => { - it('returns an empty string for null', () => { - expect(queryResultToJSON(null)).toBe(''); - }); - - it('serializes all documents pretty-printed', () => { - const result = makeResult({ documents: [{ a: 1 }, { b: 2 }] }); - expect(queryResultToJSON(result)).toBe(JSON.stringify([{ a: 1 }, { b: 2 }], null, 4)); - }); - - it('serializes only the selected documents (by index)', () => { - const result = makeResult({ documents: [{ a: 1 }, { b: 2 }, { c: 3 }] }); - expect(queryResultToJSON(result, [0, 2])).toBe(JSON.stringify([{ a: 1 }, { c: 3 }], null, 4)); - }); - - it('returns "[]" when the selection matches nothing', () => { - const result = makeResult({ documents: [{ a: 1 }] }); - expect(queryResultToJSON(result, [5])).toBe(JSON.stringify([], null, 4)); - }); - }); - - // ─── queryResultToTable ─────────────────────────────────────────────────── - describe('queryResultToTable', () => { - it('returns empty table for null / empty documents', async () => { - expect(await queryResultToTable(null, undefined)).toEqual({ headers: [], dataset: [] }); - expect(await queryResultToTable(makeResult(), undefined)).toEqual({ headers: [], dataset: [] }); - }); - - it('SELECT VALUE scalar → single _value1 column', async () => { - const result = makeResult({ query: 'SELECT VALUE c.name FROM c', documents: ['Alice', 'Bob'] }); - const table = await queryResultToTable(result, undefined); - expect(table.headers).toEqual(['_value1']); - expect(table.dataset.map((r) => r._value1)).toEqual(['Alice', 'Bob']); - }); - - it('SELECT * with object docs → id first, then fields (partition key first)', async () => { - const result = makeResult({ query: 'SELECT * FROM c', documents: [{ id: '1', name: 'x' }] }); - const table = await queryResultToTable(result, undefined); - expect(table.headers).toEqual(['id', 'name']); - expect(table.dataset[0].id).toBe('1'); - expect(table.dataset[0].name).toBe('x'); - // each row carries an internal __id - expect(typeof table.dataset[0].__id).toBe('string'); - }); - - it('SELECT list → fixed projected columns, ignoring extra document fields', async () => { - const result = makeResult({ - query: 'SELECT c.id, c.name FROM c', - documents: [{ id: '1', name: 'x', extra: 'ignored' }], - }); - const table = await queryResultToTable(result, undefined); - expect(table.headers).toEqual(['id', 'name']); - }); - - it('unnamed projected column (arithmetic) → synthetic _value1 header', async () => { - const result = makeResult({ query: 'SELECT c.a + c.b FROM c', documents: [{ result: 5 }] }); - const table = await queryResultToTable(result, undefined); - expect(table.headers).toEqual(['_value1']); - }); - - it('throws QueryResultMismatchError when an object query returns primitive data', async () => { - const result = makeResult({ query: 'SELECT * FROM c', documents: ['scalar'] }); - await expect(queryResultToTable(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); - }); - - it('returns empty table for unknown query with mixed data', async () => { - const result = makeResult({ query: '', documents: [{ a: 1 }, 'scalar'] }); - expect(await queryResultToTable(result, undefined)).toEqual({ headers: [], dataset: [] }); - }); - - it('injects virtual partition-key column for SELECT * when a partition key is provided', async () => { - const partitionKey: PartitionKeyDefinition = { paths: ['/pk'] } as PartitionKeyDefinition; - const result = makeResult({ query: 'SELECT * FROM c', documents: [{ id: '1', pk: 'tenant-a' }] }); - const table = await queryResultToTable(result, partitionKey); - // partition-key path column is shown (prefixed with /), id first - expect(table.headers).toContain('/pk'); - expect(table.headers[0]).toBe('id'); - expect(table.dataset[0]['pk']).toBe('tenant-a'); - }); - }); - - // ─── queryResultToTree ──────────────────────────────────────────────────── - describe('queryResultToTree', () => { - it('returns [] for empty / primitive collections', async () => { - expect(await queryResultToTree(makeResult(), undefined)).toEqual([]); - expect(await queryResultToTree(makeResult({ documents: ['a', 'b'] }), undefined)).toEqual([]); - }); - - it('builds a Document row with nested children for object docs', async () => { - const result = makeResult({ documents: [{ id: 'doc-1', tags: ['a', 'b'] }] }); - const rows = await queryResultToTree(result, undefined); - expect(rows).toHaveLength(1); - expect(rows[0].type).toBe('Document'); - expect(rows[0].field).toBe('doc-1'); - - const fields = rows[0].children?.map((c) => c.field); - expect(fields).toContain('id'); - expect(fields).toContain('tags'); - - const tagsRow = rows[0].children?.find((c) => c.field === 'tags'); - expect(tagsRow?.type).toBe('Array'); - expect(tagsRow?.children).toHaveLength(2); - }); - - it('throws QueryResultMismatchError when an object query returns mixed data', async () => { - const result = makeResult({ query: 'SELECT * FROM c', documents: [{ a: 1 }, 'scalar'] }); - await expect(queryResultToTree(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); - }); - }); - - // ─── queryMetricsToTable ────────────────────────────────────────────────── - describe('queryMetricsToTable', () => { - it('returns [] when there is no result or no metrics', async () => { - expect(await queryMetricsToTable(null)).toEqual([]); - expect(await queryMetricsToTable(makeResult())).toEqual([]); - }); - - it('formats the request charge and includes core metrics', async () => { - const result = makeResult({ requestCharge: 5, queryMetrics: makeMetrics({ retrievedDocumentSize: 128 }) }); - const stats = await queryMetricsToTable(result); - const charge = stats.find((s) => s.metric === 'Request Charge'); - expect(charge?.formattedValue).toBe('5 RUs'); - const size = stats.find((s) => s.metric === 'Retrieved document size'); - expect(size?.formattedValue).toBe('128 bytes'); - }); - - it('appends Round Trips and Activity id only when present', async () => { - const without = await queryMetricsToTable(makeResult({ roundTrips: 0, queryMetrics: makeMetrics() })); - expect(without.some((s) => s.metric === 'Round Trips')).toBe(false); - expect(without.some((s) => s.metric === 'Activity id')).toBe(false); - - const withExtra = await queryMetricsToTable( - makeResult({ roundTrips: 3, activityId: 'abc', queryMetrics: makeMetrics() }), - ); - expect(withExtra.find((s) => s.metric === 'Round Trips')?.value).toBe(3); - expect(withExtra.find((s) => s.metric === 'Activity id')?.value).toBe('abc'); - }); - - it('renders "All" results when countPerPage is -1 and there are no documents', async () => { - const result = makeResult({ metadata: { countPerPage: -1 }, documents: [], queryMetrics: makeMetrics() }); - const stats = await queryMetricsToTable(result); - expect(stats.find((s) => s.metric === 'Showing Results')?.value).toBe('All'); - }); - - it('renders a 0 - N range when countPerPage is -1 and documents exist', async () => { - const result = makeResult({ - metadata: { countPerPage: -1 }, - documents: [{ a: 1 }, { b: 2 }], - queryMetrics: makeMetrics(), - }); - const stats = await queryMetricsToTable(result); - expect(stats.find((s) => s.metric === 'Showing Results')?.value).toBe('0 - 2'); - }); - }); - - // ─── indexMetricsToTableItem ────────────────────────────────────────────── - describe('indexMetricsToTableItem', () => { - it('trims the raw index metrics string', () => { - const item = indexMetricsToTableItem(makeResult({ indexMetrics: ' some metrics ' })); - expect(item.metric).toBe('Index Metrics'); - expect(item.value).toBe('some metrics'); - expect(item.formattedValue).toBe('some metrics'); - }); - }); - - // ─── queryMetricsToJSON ─────────────────────────────────────────────────── - describe('queryMetricsToJSON', () => { - it('returns an empty string for null', async () => { - expect(await queryMetricsToJSON(null)).toBe(''); - }); - - it('serializes metrics and appends the index metrics item', async () => { - const result = makeResult({ indexMetrics: 'idx', queryMetrics: makeMetrics() }); - const json = await queryMetricsToJSON(result); - const parsed = JSON.parse(json) as Array<{ metric: string; value: unknown }>; - expect(parsed.some((s) => s.metric === 'Index Metrics' && s.value === 'idx')).toBe(true); - }); - }); -}); diff --git a/src/utils/convertors/json.test.ts b/src/utils/convertors/json.test.ts new file mode 100644 index 000000000..805dd7910 --- /dev/null +++ b/src/utils/convertors/json.test.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { queryResultToJSON } from './json'; +import { makeResult } from './testFixtures'; + +describe('queryResultToJSON', () => { + it('returns an empty string for null', () => { + expect(queryResultToJSON(null)).toBe(''); + }); + + it('serializes all documents pretty-printed', () => { + const result = makeResult({ documents: [{ a: 1 }, { b: 2 }] }); + expect(queryResultToJSON(result)).toBe(JSON.stringify([{ a: 1 }, { b: 2 }], null, 4)); + }); + + it('serializes only the selected documents (by index)', () => { + const result = makeResult({ documents: [{ a: 1 }, { b: 2 }, { c: 3 }] }); + expect(queryResultToJSON(result, [0, 2])).toBe(JSON.stringify([{ a: 1 }, { c: 3 }], null, 4)); + }); + + it('returns "[]" when the selection matches nothing', () => { + const result = makeResult({ documents: [{ a: 1 }] }); + expect(queryResultToJSON(result, [5])).toBe(JSON.stringify([], null, 4)); + }); +}); diff --git a/src/utils/convertors/metrics.test.ts b/src/utils/convertors/metrics.test.ts new file mode 100644 index 000000000..426a33832 --- /dev/null +++ b/src/utils/convertors/metrics.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { indexMetricsToTableItem, queryMetricsToJSON, queryMetricsToTable } from './metrics'; +import { makeMetrics, makeResult } from './testFixtures'; + +describe('queryMetricsToTable', () => { + it('returns [] when there is no result or no metrics', async () => { + expect(await queryMetricsToTable(null)).toEqual([]); + expect(await queryMetricsToTable(makeResult())).toEqual([]); + }); + + it('formats the request charge and includes core metrics', async () => { + const result = makeResult({ requestCharge: 5, queryMetrics: makeMetrics({ retrievedDocumentSize: 128 }) }); + const stats = await queryMetricsToTable(result); + const charge = stats.find((s) => s.metric === 'Request Charge'); + expect(charge?.formattedValue).toBe('5 RUs'); + const size = stats.find((s) => s.metric === 'Retrieved document size'); + expect(size?.formattedValue).toBe('128 bytes'); + }); + + it('appends Round Trips and Activity id only when present', async () => { + const without = await queryMetricsToTable(makeResult({ roundTrips: 0, queryMetrics: makeMetrics() })); + expect(without.some((s) => s.metric === 'Round Trips')).toBe(false); + expect(without.some((s) => s.metric === 'Activity id')).toBe(false); + + const withExtra = await queryMetricsToTable( + makeResult({ roundTrips: 3, activityId: 'abc', queryMetrics: makeMetrics() }), + ); + expect(withExtra.find((s) => s.metric === 'Round Trips')?.value).toBe(3); + expect(withExtra.find((s) => s.metric === 'Activity id')?.value).toBe('abc'); + }); + + it('renders "All" results when countPerPage is -1 and there are no documents', async () => { + const result = makeResult({ metadata: { countPerPage: -1 }, documents: [], queryMetrics: makeMetrics() }); + const stats = await queryMetricsToTable(result); + expect(stats.find((s) => s.metric === 'Showing Results')?.value).toBe('All'); + }); + + it('renders a 0 - N range when countPerPage is -1 and documents exist', async () => { + const result = makeResult({ + metadata: { countPerPage: -1 }, + documents: [{ a: 1 }, { b: 2 }], + queryMetrics: makeMetrics(), + }); + const stats = await queryMetricsToTable(result); + expect(stats.find((s) => s.metric === 'Showing Results')?.value).toBe('0 - 2'); + }); +}); + +describe('indexMetricsToTableItem', () => { + it('trims the raw index metrics string', () => { + const item = indexMetricsToTableItem(makeResult({ indexMetrics: ' some metrics ' })); + expect(item.metric).toBe('Index Metrics'); + expect(item.value).toBe('some metrics'); + expect(item.formattedValue).toBe('some metrics'); + }); +}); + +describe('queryMetricsToJSON', () => { + it('returns an empty string for null', async () => { + expect(await queryMetricsToJSON(null)).toBe(''); + }); + + it('serializes metrics and appends the index metrics item', async () => { + const result = makeResult({ indexMetrics: 'idx', queryMetrics: makeMetrics() }); + const json = await queryMetricsToJSON(result); + const parsed = JSON.parse(json) as Array<{ metric: string; value: unknown }>; + expect(parsed.some((s) => s.metric === 'Index Metrics' && s.value === 'idx')).toBe(true); + }); +}); diff --git a/src/utils/convertors/table.test.ts b/src/utils/convertors/table.test.ts new file mode 100644 index 000000000..f5b02517b --- /dev/null +++ b/src/utils/convertors/table.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type PartitionKeyDefinition } from '@azure/cosmos'; +import { QueryResultMismatchError } from '../queryAnalysis'; +import { queryResultToTable } from './table'; +import { makeResult } from './testFixtures'; + +describe('queryResultToTable', () => { + it('returns empty table for null / empty documents', async () => { + expect(await queryResultToTable(null, undefined)).toEqual({ headers: [], dataset: [] }); + expect(await queryResultToTable(makeResult(), undefined)).toEqual({ headers: [], dataset: [] }); + }); + + it('SELECT VALUE scalar → single _value1 column', async () => { + const result = makeResult({ query: 'SELECT VALUE c.name FROM c', documents: ['Alice', 'Bob'] }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['_value1']); + expect(table.dataset.map((r) => r._value1)).toEqual(['Alice', 'Bob']); + }); + + it('SELECT * with object docs → id first, then fields (partition key first)', async () => { + const result = makeResult({ query: 'SELECT * FROM c', documents: [{ id: '1', name: 'x' }] }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['id', 'name']); + expect(table.dataset[0].id).toBe('1'); + expect(table.dataset[0].name).toBe('x'); + // each row carries an internal __id + expect(typeof table.dataset[0].__id).toBe('string'); + }); + + it('SELECT list → fixed projected columns, ignoring extra document fields', async () => { + const result = makeResult({ + query: 'SELECT c.id, c.name FROM c', + documents: [{ id: '1', name: 'x', extra: 'ignored' }], + }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['id', 'name']); + }); + + it('unnamed projected column (arithmetic) → synthetic _value1 header', async () => { + const result = makeResult({ query: 'SELECT c.a + c.b FROM c', documents: [{ result: 5 }] }); + const table = await queryResultToTable(result, undefined); + expect(table.headers).toEqual(['_value1']); + }); + + it('throws QueryResultMismatchError when an object query returns primitive data', async () => { + const result = makeResult({ query: 'SELECT * FROM c', documents: ['scalar'] }); + await expect(queryResultToTable(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); + }); + + it('returns empty table for unknown query with mixed data', async () => { + const result = makeResult({ query: '', documents: [{ a: 1 }, 'scalar'] }); + expect(await queryResultToTable(result, undefined)).toEqual({ headers: [], dataset: [] }); + }); + + it('injects virtual partition-key column for SELECT * when a partition key is provided', async () => { + const partitionKey: PartitionKeyDefinition = { paths: ['/pk'] } as PartitionKeyDefinition; + const result = makeResult({ query: 'SELECT * FROM c', documents: [{ id: '1', pk: 'tenant-a' }] }); + const table = await queryResultToTable(result, partitionKey); + // partition-key path column is shown (prefixed with /), id first + expect(table.headers).toContain('/pk'); + expect(table.headers[0]).toBe('id'); + expect(table.dataset[0]['pk']).toBe('tenant-a'); + }); +}); diff --git a/src/utils/convertors/testFixtures.ts b/src/utils/convertors/testFixtures.ts new file mode 100644 index 000000000..554db8892 --- /dev/null +++ b/src/utils/convertors/testFixtures.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared test fixtures for the convertors test suite. Not a test file itself (no `*.test.ts` + * suffix) so vitest does not pick it up as a suite. + */ + +import { type SerializedQueryMetrics, type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; + +/** Build a SerializedQueryResult with sensible defaults, overriding only what a test needs. */ +export function makeResult(partial: Partial = {}): SerializedQueryResult { + return { + documents: [], + iteration: 1, + metadata: {}, + indexMetrics: '', + requestCharge: 0, + roundTrips: 0, + hasMoreResults: false, + query: '', + ...partial, + }; +} + +/** Build a SerializedQueryMetrics with all-zero defaults. */ +export function makeMetrics(partial: Partial = {}): SerializedQueryMetrics { + return { + documentLoadTime: 0, + documentWriteTime: 0, + indexHitDocumentCount: 0, + outputDocumentCount: 0, + outputDocumentSize: 0, + indexLookupTime: 0, + retrievedDocumentCount: 0, + retrievedDocumentSize: 0, + vmExecutionTime: 0, + runtimeExecutionTimes: { + queryEngineExecutionTime: 0, + systemFunctionExecutionTime: 0, + userDefinedFunctionExecutionTime: 0, + }, + totalQueryExecutionTime: 0, + ...partial, + }; +} diff --git a/src/utils/convertors/tree.test.ts b/src/utils/convertors/tree.test.ts new file mode 100644 index 000000000..07577e66c --- /dev/null +++ b/src/utils/convertors/tree.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { QueryResultMismatchError } from '../queryAnalysis'; +import { makeResult } from './testFixtures'; +import { queryResultToTree } from './tree'; + +describe('queryResultToTree', () => { + it('returns [] for empty / primitive collections', async () => { + expect(await queryResultToTree(makeResult(), undefined)).toEqual([]); + expect(await queryResultToTree(makeResult({ documents: ['a', 'b'] }), undefined)).toEqual([]); + }); + + it('builds a Document row with nested children for object docs', async () => { + const result = makeResult({ documents: [{ id: 'doc-1', tags: ['a', 'b'] }] }); + const rows = await queryResultToTree(result, undefined); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('Document'); + expect(rows[0].field).toBe('doc-1'); + + const fields = rows[0].children?.map((c) => c.field); + expect(fields).toContain('id'); + expect(fields).toContain('tags'); + + const tagsRow = rows[0].children?.find((c) => c.field === 'tags'); + expect(tagsRow?.type).toBe('Array'); + expect(tagsRow?.children).toHaveLength(2); + }); + + it('throws QueryResultMismatchError when an object query returns mixed data', async () => { + const result = makeResult({ query: 'SELECT * FROM c', documents: [{ a: 1 }, 'scalar'] }); + await expect(queryResultToTree(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); + }); +}); From 9fc32a81917dde5d54e54673a76aa979fe904f15 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:00:49 +0200 Subject: [PATCH 03/12] refactor(csvConverter): split into escape/metrics/table modules; add unit tests --- src/utils/csvConverter/escape.test.ts | 24 ++++++++++ src/utils/csvConverter/escape.ts | 16 +++++++ src/utils/csvConverter/index.ts | 15 ++++++ src/utils/csvConverter/metrics.test.ts | 26 ++++++++++ src/utils/csvConverter/metrics.ts | 22 +++++++++ src/utils/csvConverter/table.test.ts | 47 +++++++++++++++++++ .../table.ts} | 28 ++--------- 7 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 src/utils/csvConverter/escape.test.ts create mode 100644 src/utils/csvConverter/escape.ts create mode 100644 src/utils/csvConverter/index.ts create mode 100644 src/utils/csvConverter/metrics.test.ts create mode 100644 src/utils/csvConverter/metrics.ts create mode 100644 src/utils/csvConverter/table.test.ts rename src/utils/{csvConverter.ts => csvConverter/table.ts} (65%) diff --git a/src/utils/csvConverter/escape.test.ts b/src/utils/csvConverter/escape.test.ts new file mode 100644 index 000000000..b1635f4a5 --- /dev/null +++ b/src/utils/csvConverter/escape.test.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { escapeCsvValue } from './escape'; + +describe('escapeCsvValue', () => { + it('wraps a plain value in double quotes', () => { + expect(escapeCsvValue('abc')).toBe('"abc"'); + }); + + it('doubles embedded double-quotes (RFC 4180)', () => { + expect(escapeCsvValue('a"b')).toBe('"a""b"'); + }); + + it('keeps separators and newlines inside the quoted field', () => { + expect(escapeCsvValue('a,b;c\nd')).toBe('"a,b;c\nd"'); + }); + + it('handles the empty string', () => { + expect(escapeCsvValue('')).toBe('""'); + }); +}); diff --git a/src/utils/csvConverter/escape.ts b/src/utils/csvConverter/escape.ts new file mode 100644 index 000000000..cd46afe7f --- /dev/null +++ b/src/utils/csvConverter/escape.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SettingsService } from '../../services/SettingsService'; + +/** Resolve the user-configured CSV separator, defaulting to ';'. */ +export function getCsvSeparator(): string { + return SettingsService.getSetting('cosmosDB.csvSeparator') ?? ';'; +} + +/** Quote a CSV field and escape embedded double-quotes (RFC 4180). */ +export const escapeCsvValue = (value: string): string => { + return `"${value.replace(/"/g, '""')}"`; +}; diff --git a/src/utils/csvConverter/index.ts b/src/utils/csvConverter/index.ts new file mode 100644 index 000000000..d337f8db0 --- /dev/null +++ b/src/utils/csvConverter/index.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Public API for CSV export. Split into focused modules: + * - escape.ts — value quoting + separator resolution + * - metrics.ts — query metrics → CSV + * - table.ts — query result rows → CSV + */ + +export { escapeCsvValue } from './escape'; +export { queryMetricsToCsv } from './metrics'; +export { queryResultToCsv } from './table'; diff --git a/src/utils/csvConverter/metrics.test.ts b/src/utils/csvConverter/metrics.test.ts new file mode 100644 index 000000000..c033c1ff7 --- /dev/null +++ b/src/utils/csvConverter/metrics.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { makeMetrics, makeResult } from '../convertors/testFixtures'; +import { queryMetricsToCsv } from './metrics'; + +describe('queryMetricsToCsv', () => { + it('returns an empty string for null', async () => { + expect(await queryMetricsToCsv(null)).toBe(''); + }); + + it('emits a sep header, a titles row and a values row', async () => { + const result = makeResult({ requestCharge: 5, indexMetrics: 'idx', queryMetrics: makeMetrics() }); + const csv = await queryMetricsToCsv(result); + const [sepLine, titles, values] = csv.split('\n'); + + expect(sepLine).toBe('sep=,'); + // titles include the metrics produced by queryMetricsToTable plus the appended index metrics + expect(titles).toContain('"Request Charge"'); + expect(titles).toContain('"Index Metrics"'); + // request charge is the first metric; its value column is "5" + expect(values.startsWith('"5"')).toBe(true); + }); +}); diff --git a/src/utils/csvConverter/metrics.ts b/src/utils/csvConverter/metrics.ts new file mode 100644 index 000000000..15a2b9365 --- /dev/null +++ b/src/utils/csvConverter/metrics.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; +import { indexMetricsToTableItem, queryMetricsToTable } from '../convertors'; +import { escapeCsvValue } from './escape'; + +export const queryMetricsToCsv = async (queryResult: SerializedQueryResult | null): Promise => { + if (!queryResult) { + return ''; + } + + const stats = await queryMetricsToTable(queryResult); + + stats.push(indexMetricsToTableItem(queryResult)); + + const titles = stats.map((item) => escapeCsvValue(item.metric)).join(','); + const values = stats.map((item) => escapeCsvValue(item.value.toString())).join(','); + return `sep=,\n${titles}\n${values}`; +}; diff --git a/src/utils/csvConverter/table.test.ts b/src/utils/csvConverter/table.test.ts new file mode 100644 index 000000000..bce736787 --- /dev/null +++ b/src/utils/csvConverter/table.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, vi } from 'vitest'; +import { SettingsService } from '../../services/SettingsService'; +import { makeResult } from '../convertors/testFixtures'; +import { queryResultToCsv } from './table'; + +describe('queryResultToCsv', () => { + beforeEach(() => { + // getCsvSeparator() reads this setting; pin it to ',' for deterministic output. + vi.spyOn(SettingsService, 'getSetting').mockReturnValue(','); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns an empty string for null', async () => { + expect(await queryResultToCsv(null)).toBe(''); + }); + + it('builds a sep header, header row and value rows using the configured separator', async () => { + const result = makeResult({ query: 'SELECT c.id, c.name FROM c', documents: [{ id: '1', name: 'x' }] }); + const [sepLine, headers, firstRow] = (await queryResultToCsv(result)).split('\n'); + + expect(sepLine).toBe('sep=,'); + expect(headers).toBe('"id","name"'); + expect(firstRow).toBe('"1","x"'); + }); + + it('applies the row selection filter', async () => { + const result = makeResult({ query: 'SELECT c.id FROM c', documents: [{ id: '1' }, { id: '2' }, { id: '3' }] }); + const lines = (await queryResultToCsv(result, undefined, [1])).split('\n'); + // sep + header + a single selected data row + expect(lines.slice(2)).toEqual(['"2"']); + }); + + it('JSON-stringifies non-string values before escaping', async () => { + const result = makeResult({ query: 'SELECT c.id, c.meta FROM c', documents: [{ id: '1', meta: { a: 1 } }] }); + const lines = (await queryResultToCsv(result)).split('\n'); + // {a:1} → JSON.stringify → {"a":1} → escaped (inner quotes doubled) + expect(lines[2]).toBe('"1","{""a"":1}"'); + }); +}); diff --git a/src/utils/csvConverter.ts b/src/utils/csvConverter/table.ts similarity index 65% rename from src/utils/csvConverter.ts rename to src/utils/csvConverter/table.ts index 8574b119b..ce2eb777a 100644 --- a/src/utils/csvConverter.ts +++ b/src/utils/csvConverter/table.ts @@ -4,31 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { type PartitionKeyDefinition } from '@azure/cosmos'; -import { type SerializedQueryResult } from '../cosmosdb/types/queryResult'; -import { SettingsService } from '../services/SettingsService'; -import { indexMetricsToTableItem, queryMetricsToTable, queryResultToTable } from './convertors'; - -function getCsvSeparator(): string { - return SettingsService.getSetting('cosmosDB.csvSeparator') ?? ';'; -} - -export const escapeCsvValue = (value: string): string => { - return `"${value.replace(/"/g, '""')}"`; -}; - -export const queryMetricsToCsv = async (queryResult: SerializedQueryResult | null): Promise => { - if (!queryResult) { - return ''; - } - - const stats = await queryMetricsToTable(queryResult); - - stats.push(indexMetricsToTableItem(queryResult)); - - const titles = stats.map((item) => escapeCsvValue(item.metric)).join(','); - const values = stats.map((item) => escapeCsvValue(item.value.toString())).join(','); - return `sep=,\n${titles}\n${values}`; -}; +import { type SerializedQueryResult } from '../../cosmosdb/types/queryResult'; +import { queryResultToTable } from '../convertors'; +import { escapeCsvValue, getCsvSeparator } from './escape'; export const queryResultToCsv = async ( queryResult: SerializedQueryResult | null, From 0d1ed2cdb7211dade31c9b8cc11a94ef05a446e4 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:04:49 +0200 Subject: [PATCH 04/12] test(AzureResourceMetadata): cover documentEndpoint + isServerless getters --- src/cosmosdb/AzureResourceMetadata.test.ts | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/cosmosdb/AzureResourceMetadata.test.ts diff --git a/src/cosmosdb/AzureResourceMetadata.test.ts b/src/cosmosdb/AzureResourceMetadata.test.ts new file mode 100644 index 000000000..fc25a118e --- /dev/null +++ b/src/cosmosdb/AzureResourceMetadata.test.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import { vi } from 'vitest'; +import { AzureResourceMetadata } from './AzureResourceMetadata'; +import { SERVERLESS_CAPABILITY_NAME } from './cosmosdb-shared-constants'; + +// These are only needed so the module under test can be imported; the getters under test never call them. +vi.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandling: vi.fn(), +})); +vi.mock('../utils/azureClients', () => ({ + createCosmosDBManagementClient: vi.fn(), +})); + +/** + * The real constructor is `protected` (instances are meant to be built via the async static + * `create`). A tiny subclass lets us construct instances directly with a controlled + * `databaseAccount` so we can unit-test the pure getters without any network/SDK calls. + */ +class TestableAzureResourceMetadata extends AzureResourceMetadata { + public constructor(databaseAccount: Partial) { + super( + { subscriptionId: 'sub-1' } as unknown as AzureSubscription, + 'account-id', + 'account-name', + 'resource-group', + databaseAccount as DatabaseAccountGetResults, + ); + } +} + +describe('AzureResourceMetadata', () => { + describe('documentEndpoint', () => { + it('returns the document endpoint when present', () => { + const metadata = new TestableAzureResourceMetadata({ + documentEndpoint: 'https://acc.documents.azure.com:443/', + }); + expect(metadata.documentEndpoint).toBe('https://acc.documents.azure.com:443/'); + }); + + it('returns an empty string when the endpoint is missing', () => { + const metadata = new TestableAzureResourceMetadata({}); + expect(metadata.documentEndpoint).toBe(''); + }); + }); + + describe('isServerless', () => { + it('is true when capabilities include the serverless capability', () => { + const metadata = new TestableAzureResourceMetadata({ + capabilities: [{ name: SERVERLESS_CAPABILITY_NAME }], + }); + expect(metadata.isServerless).toBe(true); + }); + + it('is true even when mixed with other capabilities', () => { + const metadata = new TestableAzureResourceMetadata({ + capabilities: [{ name: 'EnableGremlin' }, { name: SERVERLESS_CAPABILITY_NAME }], + }); + expect(metadata.isServerless).toBe(true); + }); + + it('is false when capabilities do not include the serverless capability', () => { + const metadata = new TestableAzureResourceMetadata({ + capabilities: [{ name: 'EnableGremlin' }], + }); + expect(metadata.isServerless).toBe(false); + }); + + it('is false when there are no capabilities', () => { + const metadata = new TestableAzureResourceMetadata({}); + expect(metadata.isServerless).toBe(false); + }); + }); +}); From ff55fa74738f4d51b9188b9e2e2fab564b7b00fe Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:41:03 +0200 Subject: [PATCH 05/12] test(stage2): expand coverage for SettingsService, copilotUtils, vscodeUtils, convertors, CosmosDbOperationsService --- src/chat/CosmosDbOperationsService.test.ts | 115 +++++++++++++++ src/services/SettingsService.test.ts | 158 +++++++++++++++++++++ src/utils/convertors/table.test.ts | 29 ++++ src/utils/convertors/tree.test.ts | 49 +++++++ src/utils/copilotUtils.test.ts | 71 ++++++++- src/utils/vscodeUtils.test.ts | 36 ++++- 6 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 src/services/SettingsService.test.ts diff --git a/src/chat/CosmosDbOperationsService.test.ts b/src/chat/CosmosDbOperationsService.test.ts index 921d098c3..8fc352232 100644 --- a/src/chat/CosmosDbOperationsService.test.ts +++ b/src/chat/CosmosDbOperationsService.test.ts @@ -888,4 +888,119 @@ describe('CosmosDbOperationsService', () => { expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Generating query')); }); }); + + describe('simplifySchemaForLLM (via formatQueryHistoryForLLM)', () => { + it('simplifies nested objects, typed arrays and union types', () => { + const schema = { + type: 'object', + properties: { + tags: { anyOf: [{ type: 'array', items: { type: 'string' } }] }, + mixed: { anyOf: [{ type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }] }, + union: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + nested: { anyOf: [{ type: 'object', properties: { inner: { type: 'boolean' } } }] }, + }, + } as unknown as QueryHistoryContext['executions'][number]['schema']; + + const out = service.formatQueryHistoryForLLM({ + databaseId: 'db', + containerId: 'c', + executions: [{ query: QUERY_SELECT_ALL, documentCount: 1, schema }], + }); + + expect(out).toContain('"tags": "array"'); + expect(out).toContain('"mixed": "array"'); + // union types are emitted as an array of type names + expect(out).toContain('"union"'); + // object entries recurse into their properties + expect(out).toContain('"inner"'); + }); + }); + + describe('executeOperation - explainQuery happy path', () => { + it('returns a formatted analysis containing the LLM explanation', async () => { + const { callWithTelemetryAndErrorHandling } = vi.mocked(await import('@microsoft/vscode-azext-utils')); + callWithTelemetryAndErrorHandling.mockImplementation(async (_name: string, callback: any) => + callback({ errorHandling: {}, telemetry: { properties: {}, measurements: {} } }), + ); + + const { QueryEditorTab } = vi.mocked(await import('../panels/QueryEditorTab')); + (QueryEditorTab.openTabs as any) = new Set(); // no editor → proceed without connection + + const { getSelectedModel } = vi.mocked(await import('../utils/aiUtils')); + getSelectedModel.mockResolvedValue({} as any); + + const { sendChatRequest } = vi.mocked(await import('./chatUtils')); + sendChatRequest.mockResolvedValue({ + text: (async function* () { + yield 'This query returns all documents.'; + })(), + } as any); + + const result = await service.executeOperation('explainQuery', { + currentQuery: QUERY_SELECT_ALL, + userPrompt: 'explain', + }); + + expect(typeof result).toBe('string'); + expect(result as string).toContain('Query Analysis'); + expect(result as string).toContain('This query returns all documents.'); + }); + }); + + describe('executeOperation - editQuery happy path', () => { + it('returns an EditQueryResult with the suggested and previous queries', async () => { + const { callWithTelemetryAndErrorHandling } = vi.mocked(await import('@microsoft/vscode-azext-utils')); + callWithTelemetryAndErrorHandling.mockImplementation(async (_name: string, callback: any) => + callback({ errorHandling: {}, telemetry: { properties: {}, measurements: {} } }), + ); + + const mockEditor = { + getCurrentQueryResults: vi.fn().mockReturnValue({ documents: [{ id: '1' }], requestCharge: 1.5 }), + getCurrentQuery: vi.fn().mockReturnValue(QUERY_SELECT_ID), + getSelectedQuery: vi.fn().mockReturnValue(undefined), + isActive: vi.fn().mockReturnValue(true), + isVisible: vi.fn().mockReturnValue(true), + }; + const connection = { databaseId: 'db1', containerId: 'c1', azureMetadata: { accountId: 'acc1' } }; + + const { QueryEditorTab } = vi.mocked(await import('../panels/QueryEditorTab')); + (QueryEditorTab.openTabs as any) = new Set([mockEditor]); + const { getActiveQueryEditor, getConnectionFromQueryTab, buildChatMessages } = vi.mocked( + await import('./chatUtils'), + ); + getActiveQueryEditor.mockReturnValue(mockEditor as any); + getConnectionFromQueryTab.mockReturnValue(connection as any); + buildChatMessages.mockReturnValue([] as any); + + const { getSelectedModel, extractJsonObject } = vi.mocked(await import('../utils/aiUtils')); + const responseJson = JSON.stringify({ query: QUERY_SELECT_ACTIVE, explanation: QUERY_EXPLANATION_ACTIVE }); + const mockStream = (async function* () { + yield new vscode.LanguageModelTextPart(responseJson); + })(); + getSelectedModel.mockResolvedValue({ + sendRequest: vi.fn().mockResolvedValue({ stream: mockStream }), + countTokens: vi.fn().mockResolvedValue(10), + name: 'm', + family: 'f', + id: 'id', + maxInputTokens: 4096, + } as any); + extractJsonObject.mockReturnValue(responseJson); + + const result = await service.executeOperation('editQuery', { + currentQuery: QUERY_SELECT_ID, + userPrompt: 'only active rows', + }); + + expect(typeof result).toBe('object'); + const edit = result as Exclude; + expect(edit.type).toBe('editQuery'); + expect(edit.currentQuery).toBe(QUERY_SELECT_ID); + expect(edit.suggestedQuery).toContain(QUERY_SELECT_ACTIVE); + expect(edit.suggestedQuery).toContain('Updated from'); + expect(edit.suggestedQuery).toContain('Previous query'); + expect(edit.explanation).toBe(QUERY_EXPLANATION_ACTIVE); + expect(edit.queryContext.databaseId).toBe('db1'); + }); + }); }); diff --git a/src/services/SettingsService.test.ts b/src/services/SettingsService.test.ts new file mode 100644 index 000000000..bc5c04ec9 --- /dev/null +++ b/src/services/SettingsService.test.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { SettingsService, settingsFile, vscodeFolder } from './SettingsService'; + +/** + * Build a fake WorkspaceConfiguration whose get/inspect/update behaviour is driven by the + * supplied maps. Only the members exercised by SettingUtils are implemented. + */ +function fakeConfig(options: { + get?: Record; + inspect?: Record; + onUpdate?: (key: string, value: unknown, target?: vscode.ConfigurationTarget) => void; +}): vscode.WorkspaceConfiguration { + return { + get: vi.fn((key: string) => options.get?.[key]), + inspect: vi.fn((key: string) => options.inspect?.[key]), + update: vi.fn(async (key: string, value: unknown, target?: vscode.ConfigurationTarget) => { + options.onUpdate?.(key, value, target); + }), + has: vi.fn(() => true), + } as unknown as vscode.WorkspaceConfiguration; +} + +describe('SettingUtils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getGlobalSetting', () => { + it('prefers the global value when it is defined', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue( + fakeConfig({ inspect: { key: { globalValue: 'global', defaultValue: 'default' } } }), + ); + expect(SettingsService.getGlobalSetting('key')).toBe('global'); + }); + + it('falls back to the default value when the global value is undefined', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue( + fakeConfig({ inspect: { key: { globalValue: undefined, defaultValue: 'default' } } }), + ); + expect(SettingsService.getGlobalSetting('key')).toBe('default'); + }); + }); + + describe('getSetting', () => { + it('returns the merged configuration value', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue(fakeConfig({ get: { key: 'value' } })); + expect(SettingsService.getSetting('key')).toBe('value'); + }); + }); + + describe('updateGlobalSetting', () => { + it('updates with the Global configuration target', async () => { + const onUpdate = vi.fn(); + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue(fakeConfig({ onUpdate })); + await SettingsService.updateGlobalSetting('key', 'value'); + expect(onUpdate).toHaveBeenCalledWith('key', 'value', vscode.ConfigurationTarget.Global); + }); + }); + + describe('getLowestConfigurationLevel', () => { + it('returns WorkspaceFolder when a folder value exists', () => { + const config = fakeConfig({ inspect: { key: { workspaceFolderValue: 'x' } } }); + expect(SettingsService.getLowestConfigurationLevel(config, 'key')).toBe( + vscode.ConfigurationTarget.WorkspaceFolder, + ); + }); + + it('returns Workspace when only a workspace value exists', () => { + const config = fakeConfig({ inspect: { key: { workspaceValue: 'x' } } }); + expect(SettingsService.getLowestConfigurationLevel(config, 'key')).toBe( + vscode.ConfigurationTarget.Workspace, + ); + }); + + it('returns Global when only a global value exists', () => { + const config = fakeConfig({ inspect: { key: { globalValue: 'x' } } }); + expect(SettingsService.getLowestConfigurationLevel(config, 'key')).toBe(vscode.ConfigurationTarget.Global); + }); + + it('returns undefined when nothing is set', () => { + const config = fakeConfig({ inspect: { key: {} } }); + expect(SettingsService.getLowestConfigurationLevel(config, 'key')).toBeUndefined(); + }); + }); + + describe('getWorkspaceSetting', () => { + it('returns the value when the configuration level meets the target limit', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue( + fakeConfig({ get: { key: 'value' }, inspect: { key: { workspaceValue: 'value' } } }), + ); + expect(SettingsService.getWorkspaceSetting('key')).toBe('value'); + }); + + it('returns undefined when the configuration level is below the target limit', () => { + // Only a global value exists, but the default target limit is Workspace. + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue( + fakeConfig({ get: { key: 'value' }, inspect: { key: { globalValue: 'value' } } }), + ); + expect(SettingsService.getWorkspaceSetting('key')).toBeUndefined(); + }); + }); + + describe('getWorkspaceSettingFromAnyFolder', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns the global setting when there are no workspace folders', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue( + fakeConfig({ inspect: { key: { globalValue: 'global' } } }), + ); + // No workspaceFolders → falls back to getGlobalSetting. + expect(SettingsService.getWorkspaceSettingFromAnyFolder('key')).toBe('global'); + }); + + it('returns the single consistent value across folders', () => { + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + configurable: true, + get: () => [ + { uri: vscode.Uri.file('/a'), name: 'a', index: 0 }, + { uri: vscode.Uri.file('/b'), name: 'b', index: 1 }, + ], + }); + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue(fakeConfig({ get: { key: 'same' } })); + expect(SettingsService.getWorkspaceSettingFromAnyFolder('key')).toBe('same'); + }); + + it('returns undefined when folders disagree', () => { + let call = 0; + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + configurable: true, + get: () => [ + { uri: vscode.Uri.file('/a'), name: 'a', index: 0 }, + { uri: vscode.Uri.file('/b'), name: 'b', index: 1 }, + ], + }); + vi.spyOn(vscode.workspace, 'getConfiguration').mockImplementation(() => + fakeConfig({ get: { key: call++ === 0 ? 'first' : 'second' } }), + ); + expect(SettingsService.getWorkspaceSettingFromAnyFolder('key')).toBeUndefined(); + }); + }); + + describe('getDefaultRootWorkspaceSettingsPath', () => { + it('joins the folder path with .vscode/settings.json', () => { + const folder = { uri: vscode.Uri.file('/root'), name: 'root', index: 0 } as vscode.WorkspaceFolder; + const expected = path.join(folder.uri.fsPath, vscodeFolder, settingsFile); + expect(SettingsService.getDefaultRootWorkspaceSettingsPath(folder)).toBe(expected); + }); + }); +}); diff --git a/src/utils/convertors/table.test.ts b/src/utils/convertors/table.test.ts index f5b02517b..e36243fc8 100644 --- a/src/utils/convertors/table.test.ts +++ b/src/utils/convertors/table.test.ts @@ -65,4 +65,33 @@ describe('queryResultToTable', () => { expect(table.headers[0]).toBe('id'); expect(table.dataset[0]['pk']).toBe('tenant-a'); }); + + it('honours descending sort and trailing service columns for SELECT *', async () => { + const result = makeResult({ + query: 'SELECT * FROM c', + documents: [{ b: 1, a: 2, _ts: 100, _rid: 'x' }], + }); + const table = await queryResultToTable(result, undefined, { + ShowPartitionKey: 'none', + ShowServiceColumns: 'last', + Sorting: 'descending', + TruncateValues: 100, + }); + // id forced first (SELECT *), data columns descending, service columns last (descending) + expect(table.headers).toEqual(['id', 'b', 'a', '_ts', '_rid']); + }); + + it('places service columns last in ascending order for SELECT *', async () => { + const result = makeResult({ + query: 'SELECT * FROM c', + documents: [{ b: 1, a: 2, _ts: 100, _rid: 'x' }], + }); + const table = await queryResultToTable(result, undefined, { + ShowPartitionKey: 'none', + ShowServiceColumns: 'last', + Sorting: 'ascending', + TruncateValues: 100, + }); + expect(table.headers).toEqual(['id', 'a', 'b', '_rid', '_ts']); + }); }); diff --git a/src/utils/convertors/tree.test.ts b/src/utils/convertors/tree.test.ts index 07577e66c..a28e990ce 100644 --- a/src/utils/convertors/tree.test.ts +++ b/src/utils/convertors/tree.test.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type PartitionKeyDefinition } from '@azure/cosmos'; import { QueryResultMismatchError } from '../queryAnalysis'; import { makeResult } from './testFixtures'; import { queryResultToTree } from './tree'; +import { MAX_TREE_LEVEL_LENGTH } from './types'; describe('queryResultToTree', () => { it('returns [] for empty / primitive collections', async () => { @@ -33,4 +35,51 @@ describe('queryResultToTree', () => { const result = makeResult({ query: 'SELECT * FROM c', documents: [{ a: 1 }, 'scalar'] }); await expect(queryResultToTree(result, undefined)).rejects.toBeInstanceOf(QueryResultMismatchError); }); + + it('expands nested object values into sorted child rows', async () => { + const result = makeResult({ documents: [{ id: 'd', meta: { z: 1, a: 2 } }] }); + const rows = await queryResultToTree(result, undefined); + const metaRow = rows[0].children?.find((c) => c.field === 'meta'); + expect(metaRow?.type).toBe('Object'); + expect(metaRow?.value).toBe('{...}'); + // Object keys are rendered in ascending order. + expect(metaRow?.children?.map((c) => c.field)).toEqual(['a', 'z']); + }); + + it('truncates arrays longer than MAX_TREE_LEVEL_LENGTH with a notice row', async () => { + const big = Array.from({ length: MAX_TREE_LEVEL_LENGTH + 50 }, (_, i) => i); + const result = makeResult({ documents: [{ id: 'd', big }] }); + const rows = await queryResultToTree(result, undefined); + const bigRow = rows[0].children?.find((c) => c.field === 'big'); + expect(bigRow?.type).toBe('Array'); + expect(bigRow?.children).toHaveLength(MAX_TREE_LEVEL_LENGTH + 1); + expect(bigRow?.children?.[MAX_TREE_LEVEL_LENGTH].value).toContain('too large'); + }); + + it('truncates objects with more than MAX_TREE_LEVEL_LENGTH keys with a notice row', async () => { + const obj: Record = {}; + for (let i = 0; i < MAX_TREE_LEVEL_LENGTH + 50; i++) { + obj['k' + String(i).padStart(4, '0')] = i; + } + const result = makeResult({ documents: [{ id: 'd', obj }] }); + const rows = await queryResultToTree(result, undefined); + const objRow = rows[0].children?.find((c) => c.field === 'obj'); + expect(objRow?.type).toBe('Object'); + expect(objRow?.children).toHaveLength(MAX_TREE_LEVEL_LENGTH + 1); + expect(objRow?.children?.[MAX_TREE_LEVEL_LENGTH].value).toContain('too large'); + }); + + it('includes partition-key path rows when a partition key is provided', async () => { + const partitionKey: PartitionKeyDefinition = { paths: ['/pk'] } as PartitionKeyDefinition; + const result = makeResult({ documents: [{ id: 'd1', pk: 'tenant-a' }] }); + const rows = await queryResultToTree(result, partitionKey); + const pkRow = rows[0].children?.find((c) => c.field === '/pk'); + expect(pkRow?.value).toBe('tenant-a'); + }); + + it('uses an index-number field when the document id is missing', async () => { + const result = makeResult({ documents: [{ name: 'no-id-here' }] }); + const rows = await queryResultToTree(result, undefined); + expect(rows[0].field).toContain('id is missing'); + }); }); diff --git a/src/utils/copilotUtils.test.ts b/src/utils/copilotUtils.test.ts index dd65cd63e..2eb93b7ae 100644 --- a/src/utils/copilotUtils.test.ts +++ b/src/utils/copilotUtils.test.ts @@ -10,20 +10,22 @@ import { areCopilotModelsAvailable, isAIFeaturesDisabledBySetting, isCopilotChatExtensionInstalled, + onCopilotAvailabilityChanged, } from './copilotUtils'; // Mock the vscode module vi.mock('vscode', () => ({ extensions: { getExtension: vi.fn(), - onDidChange: vi.fn(), + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), }, lm: { selectChatModels: vi.fn(), + onDidChangeChatModels: vi.fn(() => ({ dispose: vi.fn() })), }, workspace: { getConfiguration: vi.fn(), - onDidChangeConfiguration: vi.fn(), + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), }, Disposable: { from: vi.fn((..._disposables) => ({ dispose: vi.fn() })), @@ -145,4 +147,69 @@ describe('copilotUtils', () => { await expect(areAIFeaturesEnabled()).resolves.toBe(false); }); }); + + describe('onCopilotAvailabilityChanged', () => { + it('registers extension, configuration and model listeners and returns a disposable', () => { + (vscode.extensions.getExtension as Mock).mockReturnValue(undefined); + + const disposable = onCopilotAvailabilityChanged(vi.fn()); + + expect(vscode.extensions.onDidChange).toHaveBeenCalled(); + expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalled(); + expect(vscode.lm.onDidChangeChatModels).toHaveBeenCalled(); + expect(vscode.Disposable.from).toHaveBeenCalled(); + expect(typeof disposable.dispose).toBe('function'); + }); + + it('invokes the callback with true when the configuration change makes models available', async () => { + mockConfigGet.mockReturnValue(false); // not disabled + (vscode.lm.selectChatModels as Mock).mockResolvedValue([{ id: 'model1' }]); + + const callback = vi.fn(); + onCopilotAvailabilityChanged(callback); + + // Grab the configuration listener registered inside onCopilotAvailabilityChanged. + const configListener = (vscode.workspace.onDidChangeConfiguration as Mock).mock.calls[0][0] as (e: { + affectsConfiguration: (s: string) => boolean; + }) => void; + configListener({ affectsConfiguration: () => true }); + + // Allow the async availability check to settle. + await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(true)); + }); + + it('ignores configuration changes that do not affect chat.disableAIFeatures', () => { + const callback = vi.fn(); + onCopilotAvailabilityChanged(callback); + + const configListener = (vscode.workspace.onDidChangeConfiguration as Mock).mock.calls[0][0] as (e: { + affectsConfiguration: (s: string) => boolean; + }) => void; + configListener({ affectsConfiguration: () => false }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('retries when enabling but models are not yet available, then succeeds', async () => { + vi.useFakeTimers(); + try { + mockConfigGet.mockReturnValue(false); // not disabled → enabling scenario + // First check: no models; subsequent checks: available. + (vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([]).mockResolvedValue([{ id: 'model1' }]); + + const callback = vi.fn(); + onCopilotAvailabilityChanged(callback); + + const modelListener = (vscode.lm.onDidChangeChatModels as Mock).mock.calls[0][0] as () => void; + modelListener(); + + // Drain the first (failing) availability check, then the scheduled retry. + await vi.runAllTimersAsync(); + + expect(callback).toHaveBeenCalledWith(true); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/src/utils/vscodeUtils.test.ts b/src/utils/vscodeUtils.test.ts index ccda3e92c..598789c78 100644 --- a/src/utils/vscodeUtils.test.ts +++ b/src/utils/vscodeUtils.test.ts @@ -4,7 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { getDocumentTreeItemLabel } from './vscodeUtils'; +import { dispose, getDocumentTreeItemLabel, getNodeEditorLabel, toDisposable } from './vscodeUtils'; + +describe('disposable helpers', () => { + it('dispose() calls dispose on every item and returns an empty array', () => { + const a = { dispose: vi.fn() }; + const b = { dispose: vi.fn() }; + const result = dispose([a, b]); + expect(a.dispose).toHaveBeenCalledOnce(); + expect(b.dispose).toHaveBeenCalledOnce(); + expect(result).toEqual([]); + }); + + it('toDisposable() wraps a callback into an IDisposable', () => { + const cb = vi.fn(); + const disposable = toDisposable(cb); + disposable.dispose(); + expect(cb).toHaveBeenCalledOnce(); + }); +}); + +describe('getNodeEditorLabel', () => { + it('returns the node id', () => { + expect(getNodeEditorLabel({ id: 'account/db/collection' } as never)).toBe('account/db/collection'); + }); +}); describe('Document Label Tests', () => { beforeAll(() => { @@ -35,4 +59,14 @@ describe('Document Label Tests', () => { const doc = { name: null, _id: '12345678901234567890123456789012' }; expect(getDocumentTreeItemLabel(doc)).toEqual(doc._id); }); + + it('skips object-valued label fields and falls back to _id', () => { + const doc = { name: { nested: true }, _id: 'fallback-id' }; + expect(getDocumentTreeItemLabel(doc)).toEqual('fallback-id'); + }); + + it('falls back to id when _id is missing', () => { + const doc = { name: undefined, id: 'doc-id' }; + expect(getDocumentTreeItemLabel(doc)).toEqual('doc-id'); + }); }); From e35eef2dda22faeb38e3c075860b2fcbb9a3ea1a Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:47:45 +0200 Subject: [PATCH 06/12] test(stage2): cover nonNull helpers and pathExists --- src/utils/fs/pathExists.test.ts | 29 +++++++++++++++ src/utils/nonNull.test.ts | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/utils/fs/pathExists.test.ts create mode 100644 src/utils/nonNull.test.ts diff --git a/src/utils/fs/pathExists.test.ts b/src/utils/fs/pathExists.test.ts new file mode 100644 index 000000000..427b1f79b --- /dev/null +++ b/src/utils/fs/pathExists.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fs from 'node:fs/promises'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { pathExists } from './pathExists'; + +vi.mock('node:fs/promises', () => ({ + default: { access: vi.fn() }, +})); + +describe('pathExists', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns true when fs.access resolves (path is accessible)', async () => { + (fs.access as Mock).mockResolvedValue(undefined); + await expect(pathExists('/some/existing/path')).resolves.toBe(true); + expect(fs.access).toHaveBeenCalledWith('/some/existing/path'); + }); + + it('returns false when fs.access rejects (path is missing)', async () => { + (fs.access as Mock).mockRejectedValue(new Error('ENOENT')); + await expect(pathExists('/missing/path')).resolves.toBe(false); + }); +}); diff --git a/src/utils/nonNull.test.ts b/src/utils/nonNull.test.ts new file mode 100644 index 000000000..710271254 --- /dev/null +++ b/src/utils/nonNull.test.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { nonNullOrEmptyValue, nonNullProp, nonNullValue } from './nonNull'; + +describe('nonNullValue', () => { + it('returns the value when it is defined', () => { + expect(nonNullValue(0)).toBe(0); + expect(nonNullValue('')).toBe(''); + expect(nonNullValue(false)).toBe(false); + expect(nonNullValue({ a: 1 })).toEqual({ a: 1 }); + }); + + it('throws when the value is undefined', () => { + expect(() => nonNullValue(undefined)).toThrow('Internal error'); + }); + + it('throws when the value is null', () => { + expect(() => nonNullValue(null)).toThrow('Internal error'); + }); + + it('includes the property name / message in the error', () => { + expect(() => nonNullValue(undefined, 'myProp')).toThrow('myProp'); + }); +}); + +describe('nonNullProp', () => { + it('returns the property value when present', () => { + const source = { name: 'cosmos', count: 0 }; + expect(nonNullProp(source, 'name')).toBe('cosmos'); + expect(nonNullProp(source, 'count')).toBe(0); + }); + + it('throws and reports the property name when the property is undefined', () => { + const source: { name?: string } = {}; + expect(() => nonNullProp(source, 'name')).toThrow('name'); + }); + + it('appends an extra message to the property name in the error', () => { + const source: { name?: string } = {}; + expect(() => nonNullProp(source, 'name', 'must be set')).toThrow('name, must be set'); + }); +}); + +describe('nonNullOrEmptyValue', () => { + it('returns the string when it is non-empty', () => { + expect(nonNullOrEmptyValue('hello')).toBe('hello'); + }); + + it('throws when the value is an empty string', () => { + expect(() => nonNullOrEmptyValue('')).toThrow('Internal error'); + }); + + it('throws when the value is undefined', () => { + expect(() => nonNullOrEmptyValue(undefined)).toThrow('Internal error'); + }); + + it('includes the property name / message in the error', () => { + expect(() => nonNullOrEmptyValue('', 'requiredField')).toThrow('requiredField'); + }); +}); From f31c3c4519de263ec0a90b0fa97b7b51d9ba04a4 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:53:06 +0200 Subject: [PATCH 07/12] test(stage2): cover workspacUtils and queryResult type guards --- src/cosmosdb/types/queryResult.test.ts | 83 ++++++++++++++++++++++++++ src/utils/workspacUtils.test.ts | 61 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/cosmosdb/types/queryResult.test.ts create mode 100644 src/utils/workspacUtils.test.ts diff --git a/src/cosmosdb/types/queryResult.test.ts b/src/cosmosdb/types/queryResult.test.ts new file mode 100644 index 000000000..3ad3a05c4 --- /dev/null +++ b/src/cosmosdb/types/queryResult.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type PartitionKeyDefinition } from '@azure/cosmos'; +import { describe, expect, it } from 'vitest'; +import { isCosmosDBRecord, isCosmosDBRecordIdentifier } from './queryResult'; + +/** A complete Cosmos DB document with all mandatory system-generated fields. */ +function fullRecord(overrides: Record = {}): Record { + return { + id: 'doc-1', + _rid: 'rid-1', + _ts: 1700000000, + _self: 'dbs/x/colls/y/docs/z', + _etag: '"etag"', + _attachments: 'attachments/', + ...overrides, + }; +} + +describe('isCosmosDBRecordIdentifier', () => { + it('returns false for non-objects', () => { + expect(isCosmosDBRecordIdentifier(null)).toBe(false); + expect(isCosmosDBRecordIdentifier(undefined)).toBe(false); + expect(isCosmosDBRecordIdentifier('string')).toBe(false); + expect(isCosmosDBRecordIdentifier(42)).toBe(false); + expect(isCosmosDBRecordIdentifier([])).toBe(false); + }); + + it('returns true when a non-empty _rid is present (no partition key needed)', () => { + expect(isCosmosDBRecordIdentifier({ _rid: 'abc' })).toBe(true); + }); + + it('returns true for a non-empty id when _rid is absent', () => { + expect(isCosmosDBRecordIdentifier({ id: 'doc-1' })).toBe(true); + }); + + it('returns false when neither _rid nor a non-empty id is present', () => { + expect(isCosmosDBRecordIdentifier({})).toBe(false); + expect(isCosmosDBRecordIdentifier({ id: '' })).toBe(false); + expect(isCosmosDBRecordIdentifier({ _rid: '' })).toBe(false); + }); + + it('verifies every partition key path exists when a definition is provided', () => { + const pk: PartitionKeyDefinition = { paths: ['/tenantId'] } as PartitionKeyDefinition; + expect(isCosmosDBRecordIdentifier({ id: 'doc-1', tenantId: 't1' }, pk)).toBe(true); + expect(isCosmosDBRecordIdentifier({ id: 'doc-1' }, pk)).toBe(false); + }); + + it('resolves nested partition key paths', () => { + const pk: PartitionKeyDefinition = { paths: ['/address/zip'] } as PartitionKeyDefinition; + expect(isCosmosDBRecordIdentifier({ id: 'doc-1', address: { zip: '12345' } }, pk)).toBe(true); + expect(isCosmosDBRecordIdentifier({ id: 'doc-1', address: {} }, pk)).toBe(false); + }); + + it('skips the partition key check entirely when _rid is present', () => { + const pk: PartitionKeyDefinition = { paths: ['/missing'] } as PartitionKeyDefinition; + expect(isCosmosDBRecordIdentifier({ _rid: 'abc' }, pk)).toBe(true); + }); +}); + +describe('isCosmosDBRecord', () => { + it('returns true for a full record with all system fields', () => { + expect(isCosmosDBRecord(fullRecord())).toBe(true); + }); + + it('returns false for non-objects', () => { + expect(isCosmosDBRecord(null)).toBe(false); + expect(isCosmosDBRecord('x')).toBe(false); + }); + + it('returns false when a mandatory system field is missing', () => { + const { _etag, ...withoutEtag } = fullRecord(); + void _etag; + expect(isCosmosDBRecord(withoutEtag)).toBe(false); + }); + + it('returns false when a system field has the wrong type', () => { + expect(isCosmosDBRecord(fullRecord({ _ts: 'not-a-number' }))).toBe(false); + }); +}); diff --git a/src/utils/workspacUtils.test.ts b/src/utils/workspacUtils.test.ts new file mode 100644 index 000000000..dac9f4c66 --- /dev/null +++ b/src/utils/workspacUtils.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { getBatchSizeSetting, getRootPath } from './workspacUtils'; + +vi.mock('../extensionVariables', () => ({ + ext: { settingsKeys: { batchSize: 'cosmosDB.batchSize' } }, +})); + +function setWorkspaceFolders(folders: { fsPath: string }[] | undefined): void { + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + configurable: true, + get: () => folders?.map((f, index) => ({ uri: vscode.Uri.file(f.fsPath), name: f.fsPath, index })), + }); +} + +describe('getRootPath', () => { + afterEach(() => { + vi.restoreAllMocks(); + setWorkspaceFolders(undefined); + }); + + it('returns the single folder path in a single-root workspace', () => { + setWorkspaceFolders([{ fsPath: '/root' }]); + expect(getRootPath()).toBe(vscode.Uri.file('/root').fsPath); + }); + + it('returns undefined in a multi-root workspace', () => { + setWorkspaceFolders([{ fsPath: '/a' }, { fsPath: '/b' }]); + expect(getRootPath()).toBeUndefined(); + }); + + it('returns undefined when there are no workspace folders', () => { + setWorkspaceFolders(undefined); + expect(getRootPath()).toBeUndefined(); + }); +}); + +describe('getBatchSizeSetting', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the configured batch size', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: vi.fn(() => 50), + } as unknown as vscode.WorkspaceConfiguration); + expect(getBatchSizeSetting()).toBe(50); + }); + + it('throws when the batch size setting is missing', () => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: vi.fn(() => undefined), + } as unknown as vscode.WorkspaceConfiguration); + expect(() => getBatchSizeSetting()).toThrow('batchSize'); + }); +}); From 05230da2df50287ea00a87d33056874955decb4f Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:58:14 +0200 Subject: [PATCH 08/12] test(schema-analyzer): cover valueToDisplayString for all BSON types --- .../src/bson/ValueFormatters.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/schema-analyzer/src/bson/ValueFormatters.test.ts diff --git a/packages/schema-analyzer/src/bson/ValueFormatters.test.ts b/packages/schema-analyzer/src/bson/ValueFormatters.test.ts new file mode 100644 index 000000000..893690c5d --- /dev/null +++ b/packages/schema-analyzer/src/bson/ValueFormatters.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Binary, BSONRegExp, ObjectId, Timestamp } from 'mongodb'; +import { describe, expect, it } from 'vitest'; +import { valueToDisplayString } from './ValueFormatters.js'; + +describe('valueToDisplayString', () => { + it('returns strings unchanged', () => { + expect(valueToDisplayString('hello', 'string')).toBe('hello'); + }); + + it('stringifies numeric types', () => { + expect(valueToDisplayString(42, 'number')).toBe('42'); + expect(valueToDisplayString(7, 'int32')).toBe('7'); + expect(valueToDisplayString(3.14, 'double')).toBe('3.14'); + expect(valueToDisplayString(100, 'decimal128')).toBe('100'); + expect(valueToDisplayString(9000, 'long')).toBe('9000'); + }); + + it('stringifies booleans', () => { + expect(valueToDisplayString(true, 'boolean')).toBe('true'); + expect(valueToDisplayString(false, 'boolean')).toBe('false'); + }); + + it('formats dates as ISO strings', () => { + const date = new Date('2024-01-02T03:04:05.000Z'); + expect(valueToDisplayString(date, 'date')).toBe('2024-01-02T03:04:05.000Z'); + }); + + it('formats ObjectId as a hex string', () => { + const oid = new ObjectId('507f1f77bcf86cd799439011'); + expect(valueToDisplayString(oid, 'objectid')).toBe('507f1f77bcf86cd799439011'); + }); + + it('returns "null" for null', () => { + expect(valueToDisplayString(null, 'null')).toBe('null'); + }); + + it('formats a BSON regexp as "pattern options"', () => { + expect(valueToDisplayString(new BSONRegExp('^abc$', 'i'), 'regexp')).toBe('^abc$ i'); + }); + + it('formats binary values with their length', () => { + const binary = new Binary(Buffer.from([1, 2, 3, 4])); + expect(valueToDisplayString(binary, 'binary')).toBe('Binary[4]'); + }); + + it('stringifies symbols', () => { + expect(valueToDisplayString(Symbol('s'), 'symbol')).toBe('Symbol(s)'); + }); + + it('stringifies timestamps via toString', () => { + const ts = new Timestamp({ t: 1, i: 2 }); + expect(valueToDisplayString(ts, 'timestamp')).toBe(ts.toString()); + }); + + it('returns sentinel strings for MinKey / MaxKey', () => { + expect(valueToDisplayString({}, 'minkey')).toBe('MinKey'); + expect(valueToDisplayString({}, 'maxkey')).toBe('MaxKey'); + }); + + it('JSON-stringifies code and codewithscope', () => { + expect(valueToDisplayString({ code: 'x=1' }, 'code')).toBe('{"code":"x=1"}'); + expect(valueToDisplayString({ code: 'y=2' }, 'codewithscope')).toBe('{"code":"y=2"}'); + }); + + it('JSON-stringifies arrays, objects and other fallthrough types', () => { + expect(valueToDisplayString([1, 2], 'array')).toBe('[1,2]'); + expect(valueToDisplayString({ a: 1 }, 'object')).toBe('{"a":1}'); + expect(valueToDisplayString({ k: 'v' }, 'map')).toBe('{"k":"v"}'); + expect(valueToDisplayString({ $ref: 'c' }, 'dbref')).toBe('{"$ref":"c"}'); + expect(valueToDisplayString({ x: 1 }, '_unknown_')).toBe('{"x":1}'); + }); +}); From d95f5bd6f779c0d4a4298919f9397593129b3069 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:03:16 +0200 Subject: [PATCH 09/12] chore(tests): silence two oxlint warnings in existing test files with justified disables --- .../src/test-fixtures/integration.test.ts | 5 +++-- src/utils/survey.initSurvey.test.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nosql-language-service/src/test-fixtures/integration.test.ts b/packages/nosql-language-service/src/test-fixtures/integration.test.ts index d471935e2..bf88760f1 100644 --- a/packages/nosql-language-service/src/test-fixtures/integration.test.ts +++ b/packages/nosql-language-service/src/test-fixtures/integration.test.ts @@ -135,8 +135,9 @@ if (!endpoint) { for (const f of negativeIntegrationFixtures) { it(`${f.id}: ${f.description}`, async () => { if (f.expectError) { - // Expect the SDK to throw (e.g. UDF not registered) - // oxlint-disable-next-line vitest/no-conditional-expect + // Expect the SDK to throw (e.g. UDF not registered). + // require-to-throw-message: negative fixtures only assert that *some* error is thrown; the SDK message is not stable. + // oxlint-disable-next-line vitest/no-conditional-expect, vitest/require-to-throw-message await expect(runQuery(f.container, f.query)).rejects.toThrow(); } else { const items = await runQuery(f.container, f.query); diff --git a/src/utils/survey.initSurvey.test.ts b/src/utils/survey.initSurvey.test.ts index e81cf3cdf..2367c90da 100644 --- a/src/utils/survey.initSurvey.test.ts +++ b/src/utils/survey.initSurvey.test.ts @@ -513,6 +513,7 @@ describe('Survey Initialization', () => { surveyStateRef.isCandidate = undefined; // Check if this machine ID would be selected + // oxlint-disable-next-line no-await-in-loop -- each iteration mutates shared machineId/survey state and must run sequentially if (await getIsSurveyCandidate()) { candidateCount++; } From ccfb7c05c5445b0636ea8fb157660c4ab9d3eb4d Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:21:58 +0200 Subject: [PATCH 10/12] test(nosql-providers): cover streamParser, diagnosticsProvider & multiQueryDecorator (vscode + monaco) --- .../providers/codemirror/streamParser.test.ts | 119 +++++++++++++ .../monaco/diagnosticsProvider.test.ts | 133 ++++++++++++++ .../monaco/multiQueryDecorator.test.ts | 155 ++++++++++++++++ .../vscode/diagnosticsProvider.test.ts | 158 +++++++++++++++++ .../vscode/multiQueryDecorator.test.ts | 165 ++++++++++++++++++ 5 files changed, 730 insertions(+) create mode 100644 packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts create mode 100644 packages/nosql-language-service/src/providers/monaco/diagnosticsProvider.test.ts create mode 100644 packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.test.ts create mode 100644 packages/nosql-language-service/src/providers/vscode/diagnosticsProvider.test.ts create mode 100644 packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.test.ts diff --git a/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts b/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts new file mode 100644 index 000000000..4e2260b2f --- /dev/null +++ b/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { StringStream } from '@codemirror/language'; +import { describe, expect, it } from 'vitest'; +import { cosmosDbSqlStreamParser } from './streamParser.js'; + +interface Token { + text: string; + type: string | null; +} + +/** + * Tokenize a single line, threading the parser state. Returns the tokens and + * the resulting state so multi-line contexts (strings, block comments) can be + * tested across lines. + */ +function tokenizeLine(line: string, state = cosmosDbSqlStreamParser.startState!()) { + const stream = new StringStream(line, 4, 4); + const tokens: Token[] = []; + let guard = 0; + while (!stream.eol() && guard++ < 1000) { + const start = stream.pos; + const type = cosmosDbSqlStreamParser.token(stream, state); + if (stream.pos === start) { + // Defensive: avoid an infinite loop if the parser failed to advance. + stream.next(); + } + tokens.push({ text: line.slice(start, stream.pos), type }); + } + return { tokens, state }; +} + +/** Convenience: tokenize a single-token line and return its type. */ +function typeOf(line: string): string | null { + const { tokens } = tokenizeLine(line); + // Filter out whitespace tokens (null type from eatSpace). + const nonNull = tokens.filter((t) => t.type !== null); + return nonNull.length === 1 ? nonNull[0].type : nonNull.map((t) => t.type).join(','); +} + +describe('cosmosDbSqlStreamParser', () => { + it('starts in the top context', () => { + expect(cosmosDbSqlStreamParser.startState!()).toEqual({ context: 'top' }); + }); + + it('classifies keywords, operator keywords, builtins and identifiers', () => { + expect(typeOf('SELECT')).toBe('keyword'); + expect(typeOf('AND')).toBe('operatorKeyword'); + expect(typeOf('COUNT')).toBe('function(definition)'); + expect(typeOf('myField')).toBe('variableName'); + }); + + it('classifies numbers (int, hex, float, exponent)', () => { + expect(typeOf('123')).toBe('number'); + expect(typeOf('0xFF')).toBe('number'); + expect(typeOf('3.14')).toBe('number'); + expect(typeOf('1e5')).toBe('number'); + }); + + it('classifies operators, parens and punctuation', () => { + expect(typeOf('>=')).toBe('operator'); + expect(typeOf('||')).toBe('operator'); + expect(typeOf('+')).toBe('operator'); + expect(typeOf('(')).toBe('paren'); + expect(typeOf(',')).toBe('punctuation'); + }); + + it('classifies line comments', () => { + expect(typeOf('-- a comment')).toBe('lineComment'); + }); + + it('classifies a single-line block comment and returns to top', () => { + const { tokens, state } = tokenizeLine('/* hi */'); + expect(tokens[0].type).toBe('blockComment'); + expect(state.context).toBe('top'); + }); + + it('continues a block comment across lines', () => { + const first = tokenizeLine('/* start'); + expect(first.state.context).toBe('blockComment'); + const second = tokenizeLine('end */', first.state); + // The comment terminator is consumed and the parser returns to the top context. + expect(second.tokens[0].type).toBe('blockComment'); + expect(second.state.context).toBe('top'); + }); + + it('classifies single-quoted strings, including escaped quotes', () => { + expect(typeOf("'abc'")).toBe('string'); + expect(typeOf("'a''b'")).toBe('string'); + }); + + it('continues an unterminated single-quoted string across lines', () => { + const first = tokenizeLine("'abc"); + expect(first.state.context).toBe('singleString'); + const second = tokenizeLine("def'", first.state); + expect(second.tokens[0].type).toBe('string'); + expect(second.state.context).toBe('top'); + }); + + it('classifies double-quoted identifiers as string.special', () => { + expect(typeOf('"ident"')).toBe('string.special'); + }); + + it('continues an unterminated quoted identifier across lines', () => { + const first = tokenizeLine('"abc'); + expect(first.state.context).toBe('quotedIdentifier'); + const second = tokenizeLine('def"', first.state); + expect(second.tokens[0].type).toBe('string.special'); + expect(second.state.context).toBe('top'); + }); + + it('returns null for whitespace-only input', () => { + const { tokens } = tokenizeLine(' '); + expect(tokens.every((t) => t.type === null)).toBe(true); + }); +}); diff --git a/packages/nosql-language-service/src/providers/monaco/diagnosticsProvider.test.ts b/packages/nosql-language-service/src/providers/monaco/diagnosticsProvider.test.ts new file mode 100644 index 000000000..8c5b62a1c --- /dev/null +++ b/packages/nosql-language-service/src/providers/monaco/diagnosticsProvider.test.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SqlLanguageService } from '../../services/SqlLanguageService.js'; +import { MonacoDiagnosticsProvider } from './diagnosticsProvider.js'; +import { type MonacoNamespace } from './types.js'; + +function makeModel(text: string, languageId = 'cosmosdb-sql') { + const contentListeners: (() => void)[] = []; + const disposeListeners: (() => void)[] = []; + return { + getValue: () => text, + getLanguageId: () => languageId, + onDidChangeContent: vi.fn((cb: () => void) => { + contentListeners.push(cb); + return { dispose: vi.fn() }; + }), + onWillDispose: vi.fn((cb: () => void) => { + disposeListeners.push(cb); + return { dispose: vi.fn() }; + }), + fireChange: () => contentListeners.forEach((c) => c()), + fireDispose: () => disposeListeners.forEach((c) => c()), + }; +} + +function createMonacoMock(models: ReturnType[]) { + const listeners: { + createModel?: (m: unknown) => void; + changeLang?: (e: unknown) => void; + } = {}; + const monaco = { + listeners, + editor: { + getModels: () => models, + setModelMarkers: vi.fn(), + onDidCreateModel: vi.fn((cb: (m: unknown) => void) => { + listeners.createModel = cb; + return { dispose: vi.fn() }; + }), + onDidChangeModelLanguage: vi.fn((cb: (e: unknown) => void) => { + listeners.changeLang = cb; + return { dispose: vi.fn() }; + }), + }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }, + }; + return monaco as unknown as MonacoNamespace & typeof monaco; +} + +const INVALID_QUERY = 'SELECT * FORM c'; +const VALID_QUERY = 'SELECT * FROM c'; + +describe('MonacoDiagnosticsProvider', () => { + let service: SqlLanguageService; + + beforeEach(() => { + service = new SqlLanguageService(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('registers model lifecycle listeners', () => { + const monaco = createMonacoMock([]); + new MonacoDiagnosticsProvider(monaco, service); + expect(monaco.editor.onDidCreateModel).toHaveBeenCalled(); + expect(monaco.editor.onDidChangeModelLanguage).toHaveBeenCalled(); + }); + + it('publishes markers for existing matching models on construction', () => { + const model = makeModel(INVALID_QUERY); + const monaco = createMonacoMock([model as unknown as ReturnType]); + new MonacoDiagnosticsProvider(monaco, service); + expect(monaco.editor.setModelMarkers).toHaveBeenCalledTimes(1); + const [, owner, markers] = monaco.editor.setModelMarkers.mock.calls[0]; + expect(owner).toBe('cosmosdb-sql'); + expect((markers as unknown[]).length).toBeGreaterThan(0); + // severity is mapped to a Monaco MarkerSeverity value + expect((markers as { severity: number }[])[0].severity).toBe(monaco.MarkerSeverity.Error); + }); + + it('ignores models with a non-matching language', () => { + const model = makeModel(INVALID_QUERY, 'plaintext'); + const monaco = createMonacoMock([model]); + new MonacoDiagnosticsProvider(monaco, service); + expect(monaco.editor.setModelMarkers).not.toHaveBeenCalled(); + }); + + it('observes models created after construction', () => { + const monaco = createMonacoMock([]); + new MonacoDiagnosticsProvider(monaco, service); + const model = makeModel(VALID_QUERY); + monaco.listeners.createModel?.(model); + expect(model.onDidChangeContent).toHaveBeenCalled(); + expect(monaco.editor.setModelMarkers).toHaveBeenCalled(); + }); + + it('debounces marker updates on content change', () => { + vi.useFakeTimers(); + const model = makeModel(VALID_QUERY); + const monaco = createMonacoMock([model]); + new MonacoDiagnosticsProvider(monaco, service, { diagnosticDelay: 100 }); + monaco.editor.setModelMarkers.mockClear(); + + model.fireChange(); + expect(monaco.editor.setModelMarkers).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(monaco.editor.setModelMarkers).toHaveBeenCalled(); + }); + + it('clears markers when a model is disposed', () => { + const model = makeModel(VALID_QUERY); + const monaco = createMonacoMock([model]); + new MonacoDiagnosticsProvider(monaco, service); + monaco.editor.setModelMarkers.mockClear(); + + model.fireDispose(); + const lastCall = monaco.editor.setModelMarkers.mock.calls.at(-1); + expect(lastCall?.[2]).toHaveLength(0); + }); + + it('dispose unobserves all models', () => { + const model = makeModel(VALID_QUERY); + const monaco = createMonacoMock([model]); + const provider = new MonacoDiagnosticsProvider(monaco, service); + expect(() => provider.dispose()).not.toThrow(); + }); +}); diff --git a/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.test.ts b/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.test.ts new file mode 100644 index 000000000..74d1ddf74 --- /dev/null +++ b/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.test.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SqlLanguageService } from '../../services/SqlLanguageService.js'; +import { MonacoMultiQueryDecorator } from './multiQueryDecorator.js'; +import { type MonacoNamespace } from './types.js'; + +function makeModel(text: string, languageId = 'cosmosdb-sql') { + const lines = text.split('\n'); + return { + getValue: () => text, + getLanguageId: () => languageId, + getOffsetAt: (pos: { lineNumber: number; column: number }) => { + let off = 0; + for (let i = 0; i < pos.lineNumber - 1; i++) off += lines[i].length + 1; + return off + pos.column - 1; + }, + getPositionAt: (off: number) => { + let rem = off; + for (let i = 0; i < lines.length; i++) { + if (rem <= lines[i].length) return { lineNumber: i + 1, column: rem + 1 }; + rem -= lines[i].length + 1; + } + return { lineNumber: lines.length, column: lines[lines.length - 1].length + 1 }; + }, + getLineMaxColumn: (lineNumber: number) => lines[lineNumber - 1].length + 1, + onDidChangeContent: vi.fn(() => ({ dispose: vi.fn() })), + }; +} + +function makeEditor(model: ReturnType | null) { + const decoCollections: { set: ReturnType; clear: ReturnType }[] = []; + const addZone = vi.fn(() => `zone-${decoCollections.length}`); + const removeZone = vi.fn(); + return { + decoCollections, + addZone, + removeZone, + getModel: () => model, + createDecorationsCollection: vi.fn(() => { + const c = { set: vi.fn(), clear: vi.fn() }; + decoCollections.push(c); + return c; + }), + updateOptions: vi.fn(), + getContainerDomNode: vi.fn(() => ({ style: { setProperty: vi.fn() } })), + getLayoutInfo: vi.fn(() => ({ decorationsLeft: 10, decorationsWidth: 20 })), + onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeCursorPosition: vi.fn(() => ({ dispose: vi.fn() })), + getPosition: vi.fn(() => ({ lineNumber: 2, column: 1 })), + changeViewZones: vi.fn((cb: (accessor: { addZone: typeof addZone; removeZone: typeof removeZone }) => void) => + cb({ addZone, removeZone }), + ), + }; +} + +function createMonacoMock(editors: ReturnType[]) { + const monaco = { + editor: { + getEditors: () => editors, + onDidCreateEditor: vi.fn(() => ({ dispose: vi.fn() })), + onDidCreateModel: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeModelLanguage: vi.fn(() => ({ dispose: vi.fn() })), + }, + }; + return monaco as unknown as MonacoNamespace & typeof monaco; +} + +const MULTI_QUERY = 'SELECT 1;\nSELECT 2;'; +const SINGLE_QUERY = 'SELECT * FROM c'; + +describe('MonacoMultiQueryDecorator', () => { + let service: SqlLanguageService; + + beforeEach(() => { + service = new SqlLanguageService(); + }); + + it('registers editor/model lifecycle listeners', () => { + const monaco = createMonacoMock([]); + new MonacoMultiQueryDecorator(monaco, service); + expect(monaco.editor.onDidCreateEditor).toHaveBeenCalled(); + expect(monaco.editor.onDidCreateModel).toHaveBeenCalled(); + expect(monaco.editor.onDidChangeModelLanguage).toHaveBeenCalled(); + }); + + it('attaches to an existing matching editor and reserves gutter width', () => { + const editor = makeEditor(makeModel(MULTI_QUERY)); + const monaco = createMonacoMock([editor]); + new MonacoMultiQueryDecorator(monaco, service); + + // Two decoration collections: separators + active block. + expect(editor.createDecorationsCollection).toHaveBeenCalledTimes(2); + expect(editor.updateOptions).toHaveBeenCalledWith(expect.objectContaining({ lineDecorationsWidth: 11 })); + }); + + it('sets separator decorations and view zones for a multi-query model', () => { + const editor = makeEditor(makeModel(MULTI_QUERY)); + const monaco = createMonacoMock([editor]); + new MonacoMultiQueryDecorator(monaco, service); + + // decoCollections[0] is the separator collection. + expect(editor.decoCollections[0].set).toHaveBeenCalled(); + const decos = editor.decoCollections[0].set.mock.calls.at(-1)?.[0] as unknown[]; + expect(decos.length).toBeGreaterThan(0); + // A view zone is added per separator line. + expect(editor.changeViewZones).toHaveBeenCalled(); + expect(editor.addZone).toHaveBeenCalled(); + }); + + it('highlights the active block when the cursor is inside a multi-query region', () => { + const editor = makeEditor(makeModel(MULTI_QUERY)); + const monaco = createMonacoMock([editor]); + new MonacoMultiQueryDecorator(monaco, service); + + // decoCollections[1] is the active-block collection. + expect(editor.decoCollections[1].set).toHaveBeenCalled(); + }); + + it('clears the active block for a single-query model', () => { + const editor = makeEditor(makeModel(SINGLE_QUERY)); + const monaco = createMonacoMock([editor]); + new MonacoMultiQueryDecorator(monaco, service); + + expect(editor.decoCollections[1].clear).toHaveBeenCalled(); + expect(editor.decoCollections[1].set).not.toHaveBeenCalled(); + }); + + it('does not attach when the model language does not match', () => { + const editor = makeEditor(makeModel(MULTI_QUERY, 'plaintext')); + const monaco = createMonacoMock([editor]); + new MonacoMultiQueryDecorator(monaco, service); + expect(editor.createDecorationsCollection).not.toHaveBeenCalled(); + }); + + it('does not highlight the active block when the option is disabled', () => { + const editor = makeEditor(makeModel(MULTI_QUERY)); + const monaco = createMonacoMock([editor]); + new MonacoMultiQueryDecorator(monaco, service, { highlightActiveBlock: false }); + expect(editor.onDidChangeCursorPosition).not.toHaveBeenCalled(); + }); + + it('dispose clears decorations and removes view zones', () => { + const editor = makeEditor(makeModel(MULTI_QUERY)); + const monaco = createMonacoMock([editor]); + const decorator = new MonacoMultiQueryDecorator(monaco, service); + editor.removeZone.mockClear(); + decorator.dispose(); + expect(editor.decoCollections[0].clear).toHaveBeenCalled(); + expect(editor.removeZone).toHaveBeenCalled(); + }); +}); diff --git a/packages/nosql-language-service/src/providers/vscode/diagnosticsProvider.test.ts b/packages/nosql-language-service/src/providers/vscode/diagnosticsProvider.test.ts new file mode 100644 index 000000000..ed49d6a5b --- /dev/null +++ b/packages/nosql-language-service/src/providers/vscode/diagnosticsProvider.test.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as vscodeApi from 'vscode'; +import { SqlLanguageService } from '../../services/SqlLanguageService.js'; +import { VSCodeDiagnosticsProvider } from './diagnosticsProvider.js'; +import { type VSCodeNamespace } from './types.js'; + +interface Listeners { + change?: (e: vscodeApi.TextDocumentChangeEvent) => void; + open?: (d: vscodeApi.TextDocument) => void; + close?: (d: vscodeApi.TextDocument) => void; +} + +function createVSCodeMock() { + const listeners: Listeners = {}; + const collection = { + set: vi.fn(), + delete: vi.fn(), + dispose: vi.fn(), + }; + const textDocuments: vscodeApi.TextDocument[] = []; + + const vscode = { + collection, + listeners, + textDocuments, + languages: { + createDiagnosticCollection: vi.fn(() => collection), + }, + workspace: { + get textDocuments() { + return textDocuments; + }, + onDidChangeTextDocument: vi.fn((cb: Listeners['change']) => { + listeners.change = cb; + return { dispose: vi.fn() }; + }), + onDidOpenTextDocument: vi.fn((cb: Listeners['open']) => { + listeners.open = cb; + return { dispose: vi.fn() }; + }), + onDidCloseTextDocument: vi.fn((cb: Listeners['close']) => { + listeners.close = cb; + return { dispose: vi.fn() }; + }), + }, + Range: class { + constructor( + public start: unknown, + public end: unknown, + ) {} + }, + Position: class { + constructor( + public line: number, + public character: number, + ) {} + }, + Diagnostic: class { + code: unknown; + source: string | undefined; + constructor( + public range: unknown, + public message: string, + public severity: number, + ) {} + }, + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, + }; + return vscode as unknown as VSCodeNamespace & typeof vscode; +} + +function createDoc(text: string, languageId = 'cosmosdb-sql', uri = 'file:///t.sql'): vscodeApi.TextDocument { + return { getText: () => text, languageId, uri } as unknown as vscodeApi.TextDocument; +} + +const INVALID_QUERY = 'SELECT * FORM c'; +const VALID_QUERY = 'SELECT * FROM c'; + +describe('VSCodeDiagnosticsProvider', () => { + let vscode: ReturnType; + let service: SqlLanguageService; + + beforeEach(() => { + vscode = createVSCodeMock(); + service = new SqlLanguageService(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a diagnostic collection and registers listeners', () => { + new VSCodeDiagnosticsProvider(vscode, service); + expect(vscode.languages.createDiagnosticCollection).toHaveBeenCalled(); + expect(vscode.workspace.onDidChangeTextDocument).toHaveBeenCalled(); + expect(vscode.workspace.onDidOpenTextDocument).toHaveBeenCalled(); + expect(vscode.workspace.onDidCloseTextDocument).toHaveBeenCalled(); + }); + + it('publishes diagnostics for already-open documents on construction', () => { + vscode.textDocuments.push(createDoc(INVALID_QUERY)); + new VSCodeDiagnosticsProvider(vscode, service); + expect(vscode.collection.set).toHaveBeenCalledTimes(1); + const [, diags] = vscode.collection.set.mock.calls[0]; + expect((diags as unknown[]).length).toBeGreaterThan(0); + // diagnostic.code and source are populated from the language service result + const first = (diags as { source?: string }[])[0]; + expect(first.source).toBe('cosmosdb-sql'); + }); + + it('ignores documents whose languageId does not match', () => { + vscode.textDocuments.push(createDoc(INVALID_QUERY, 'plaintext')); + new VSCodeDiagnosticsProvider(vscode, service); + expect(vscode.collection.set).not.toHaveBeenCalled(); + }); + + it('pushes diagnostics when a matching document is opened', () => { + new VSCodeDiagnosticsProvider(vscode, service); + vscode.listeners.open?.(createDoc(VALID_QUERY)); + // valid query → empty diagnostics array, but still set on the collection + expect(vscode.collection.set).toHaveBeenCalledTimes(1); + const [, diags] = vscode.collection.set.mock.calls[0]; + expect(diags).toHaveLength(0); + }); + + it('debounces diagnostics on document change', () => { + vi.useFakeTimers(); + new VSCodeDiagnosticsProvider(vscode, service, { diagnosticDelay: 100 }); + const doc = createDoc(INVALID_QUERY); + vscode.listeners.change?.({ document: doc } as vscodeApi.TextDocumentChangeEvent); + expect(vscode.collection.set).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(vscode.collection.set).toHaveBeenCalledTimes(1); + }); + + it('clears diagnostics when a document is closed', () => { + new VSCodeDiagnosticsProvider(vscode, service); + const doc = createDoc(VALID_QUERY); + vscode.listeners.close?.(doc); + expect(vscode.collection.delete).toHaveBeenCalledWith(doc.uri); + }); + + it('dispose clears timers and disposes the collection', () => { + vi.useFakeTimers(); + const provider = new VSCodeDiagnosticsProvider(vscode, service, { diagnosticDelay: 100 }); + vscode.listeners.change?.({ document: createDoc(INVALID_QUERY) } as vscodeApi.TextDocumentChangeEvent); + provider.dispose(); + // After dispose the pending timer must not fire. + vi.advanceTimersByTime(200); + expect(vscode.collection.set).not.toHaveBeenCalled(); + expect(vscode.collection.dispose).toHaveBeenCalled(); + }); +}); diff --git a/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.test.ts b/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.test.ts new file mode 100644 index 000000000..9b8107e57 --- /dev/null +++ b/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.test.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SqlLanguageService } from '../../services/SqlLanguageService.js'; +import { VSCodeMultiQueryDecorator } from './multiQueryDecorator.js'; +import { type VSCodeNamespace } from './types.js'; + +function makeEditor(text: string, cursorOffset = 0, languageId = 'cosmosdb-sql') { + const lines = text.split('\n'); + const positionAt = (offset: number) => { + let rem = offset; + for (let i = 0; i < lines.length; i++) { + if (rem <= lines[i].length) return { line: i, character: rem }; + rem -= lines[i].length + 1; + } + return { line: lines.length - 1, character: lines[lines.length - 1].length }; + }; + const offsetAt = (pos: { line: number; character: number }) => { + let off = 0; + for (let i = 0; i < pos.line; i++) off += lines[i].length + 1; + return off + pos.character; + }; + return { + document: { + languageId, + getText: () => text, + positionAt, + offsetAt, + lineCount: lines.length, + lineAt: (line: number) => ({ + range: { start: { line, character: 0 }, end: { line, character: lines[line].length } }, + }), + }, + selection: { active: positionAt(cursorOffset) }, + setDecorations: vi.fn(), + }; +} + +function createVSCodeMock(activeEditor: ReturnType | undefined) { + const decorationTypes: { dispose: ReturnType }[] = []; + const listeners: { + activeEditor?: (e: unknown) => void; + selection?: (e: unknown) => void; + changeDoc?: (e: unknown) => void; + } = {}; + + const vscode = { + decorationTypes, + listeners, + window: { + activeTextEditor: activeEditor, + createTextEditorDecorationType: vi.fn(() => { + const t = { dispose: vi.fn() }; + decorationTypes.push(t); + return t; + }), + onDidChangeActiveTextEditor: vi.fn((cb: (e: unknown) => void) => { + listeners.activeEditor = cb; + return { dispose: vi.fn() }; + }), + onDidChangeTextEditorSelection: vi.fn((cb: (e: unknown) => void) => { + listeners.selection = cb; + return { dispose: vi.fn() }; + }), + }, + workspace: { + onDidChangeTextDocument: vi.fn((cb: (e: unknown) => void) => { + listeners.changeDoc = cb; + return { dispose: vi.fn() }; + }), + activeTextEditor: activeEditor, + }, + Range: class { + args: unknown[]; + constructor(...args: unknown[]) { + this.args = args; + } + }, + }; + return vscode as unknown as VSCodeNamespace & typeof vscode; +} + +const MULTI_QUERY = 'SELECT 1;\nSELECT 2;'; +const SINGLE_QUERY = 'SELECT * FROM c'; + +describe('VSCodeMultiQueryDecorator', () => { + let service: SqlLanguageService; + + beforeEach(() => { + service = new SqlLanguageService(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates two decoration types and registers listeners', () => { + const vscode = createVSCodeMock(undefined); + new VSCodeMultiQueryDecorator(vscode, service); + expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledTimes(2); + expect(vscode.window.onDidChangeActiveTextEditor).toHaveBeenCalled(); + expect(vscode.workspace.onDidChangeTextDocument).toHaveBeenCalled(); + expect(vscode.window.onDidChangeTextEditorSelection).toHaveBeenCalled(); + }); + + it('decorates separators and the active block for a multi-query editor', () => { + // Cursor inside the second query (offset 12 ≈ "SELECT 2"). + const editor = makeEditor(MULTI_QUERY, 12); + const vscode = createVSCodeMock(editor); + new VSCodeMultiQueryDecorator(vscode, service); + + // setDecorations is called for both the separator type and the active-block type. + expect(editor.setDecorations).toHaveBeenCalled(); + const separatorCall = editor.setDecorations.mock.calls.find((c) => c[0] === vscode.decorationTypes[0]); + const activeCall = editor.setDecorations.mock.calls.find((c) => c[0] === vscode.decorationTypes[1]); + expect(separatorCall).toBeDefined(); + expect(activeCall).toBeDefined(); + // One separator (the first ";") → one decorated range. + expect((separatorCall![1] as unknown[]).length).toBeGreaterThan(0); + // Active-block decorations cover every line. + expect((activeCall![1] as unknown[]).length).toBe(MULTI_QUERY.split('\n').length); + }); + + it('clears active-block decorations for a single-query editor', () => { + const editor = makeEditor(SINGLE_QUERY, 0); + const vscode = createVSCodeMock(editor); + new VSCodeMultiQueryDecorator(vscode, service); + + const activeCall = editor.setDecorations.mock.calls.find((c) => c[0] === vscode.decorationTypes[1]); + expect(activeCall).toBeDefined(); + expect(activeCall![1]).toHaveLength(0); + }); + + it('does not highlight the active block when the option is disabled', () => { + const editor = makeEditor(MULTI_QUERY, 12); + const vscode = createVSCodeMock(editor); + new VSCodeMultiQueryDecorator(vscode, service, { highlightActiveBlock: false }); + expect(vscode.window.onDidChangeTextEditorSelection).not.toHaveBeenCalled(); + }); + + it('debounces redraws on document change', () => { + vi.useFakeTimers(); + const editor = makeEditor(MULTI_QUERY, 12); + const vscode = createVSCodeMock(editor); + new VSCodeMultiQueryDecorator(vscode, service, { decorationDelay: 100 }); + editor.setDecorations.mockClear(); + + vscode.listeners.changeDoc?.({ document: editor.document }); + expect(editor.setDecorations).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(editor.setDecorations).toHaveBeenCalled(); + }); + + it('dispose tears down decoration types', () => { + const editor = makeEditor(MULTI_QUERY, 12); + const vscode = createVSCodeMock(editor); + const decorator = new VSCodeMultiQueryDecorator(vscode, service); + decorator.dispose(); + expect(vscode.decorationTypes[0].dispose).toHaveBeenCalled(); + expect(vscode.decorationTypes[1].dispose).toHaveBeenCalled(); + }); +}); From b6cde6e69c9d37da5a5dcbd5c267ae792e336bb4 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:34:43 +0200 Subject: [PATCH 11/12] refactor(nosql-providers): extract getActiveBlockOffsets into SqlLanguageService Move the active-query-block resolution (non-empty region filter + active region lookup + leading/trailing whitespace trim) out of the vscode and monaco decorators into a single editor-agnostic SqlLanguageService.getActiveBlockOffsets() method. Both decorators now just convert the returned offsets to native positions, removing duplicated string logic. Adds 7 unit tests for the new method. --- .../providers/monaco/multiQueryDecorator.ts | 34 ++--------- .../providers/vscode/multiQueryDecorator.ts | 32 +++-------- .../src/services/SqlLanguageService.test.ts | 57 +++++++++++++++++++ .../src/services/SqlLanguageService.ts | 40 +++++++++++++ .../src/services/types.ts | 16 ++++++ 5 files changed, 124 insertions(+), 55 deletions(-) diff --git a/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.ts b/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.ts index c506794ee..af43b92ca 100644 --- a/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.ts +++ b/packages/nosql-language-service/src/providers/monaco/multiQueryDecorator.ts @@ -198,40 +198,14 @@ export class MonacoMultiQueryDecorator implements Disposable { if (!this.activeBlockDecorations) return; const text = model.getValue(); - const doc = this.service.parseDocument(text); - - // Only highlight when there is more than one non-empty region - const nonEmpty = doc.regions.filter((r) => r.text.trim().length > 0); - if (nonEmpty.length <= 1) { - this.activeBlockDecorations.clear(); - return; - } - - const region = this.service.getActiveRegion(text, cursorOffset); - if (!region || region.text.trim().length === 0) { - this.activeBlockDecorations.clear(); - return; - } - - // Skip leading/trailing whitespace inside the region. The region's - // startOffset sits immediately after the previous `;`, which lives on - // the same line as the previous query — without this trim, the - // highlight would extend onto that line and visually mark *both* - // queries as active. - const regionText = region.text; - let leading = 0; - while (leading < regionText.length && /\s/.test(regionText[leading])) leading++; - let trailing = regionText.length; - while (trailing > leading && /\s/.test(regionText[trailing - 1])) trailing--; - const contentStart = region.startOffset + leading; - const contentEnd = region.startOffset + trailing; - if (contentEnd <= contentStart) { + const block = this.service.getActiveBlockOffsets(text, cursorOffset); + if (!block) { this.activeBlockDecorations.clear(); return; } - const startPos = model.getPositionAt(contentStart); - const endPos = model.getPositionAt(contentEnd - 1); + const startPos = model.getPositionAt(block.startOffset); + const endPos = model.getPositionAt(block.endOffset - 1); this.activeBlockDecorations.set([ { diff --git a/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.ts b/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.ts index 2af335243..06d406fc7 100644 --- a/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.ts +++ b/packages/nosql-language-service/src/providers/vscode/multiQueryDecorator.ts @@ -119,36 +119,18 @@ export class VSCodeMultiQueryDecorator implements Disposable { private updateActiveBlockDecoration(editor: vscodeApi.TextEditor): void { const text = editor.document.getText(); - const doc = this.service.parseDocument(text); + const cursorOffset = editor.document.offsetAt(editor.selection.active); + const block = this.service.getActiveBlockOffsets(text, cursorOffset); - // Single-query documents stay untouched — no reserved gutter slot. - const nonEmpty = doc.regions.filter((r) => r.text.trim().length > 0); - if (nonEmpty.length <= 1) { + // No active block to highlight (single-query doc, cursor outside any + // region, or whitespace-only region) — clear the reserved gutter slot. + if (!block) { editor.setDecorations(this.activeBlockDecorationType, []); return; } - // Resolve the active block's line range, if any. - let activeStartLine = -1; - let activeEndLine = -1; - const cursorOffset = editor.document.offsetAt(editor.selection.active); - const region = this.service.getActiveRegion(text, cursorOffset); - if (region && region.text.trim().length > 0) { - // Trim whitespace: a region's startOffset sits right after the - // previous `;`, so without trimming the bar would extend back onto - // the previous query's line. - const regionText = region.text; - let leading = 0; - while (leading < regionText.length && /\s/.test(regionText[leading])) leading++; - let trailing = regionText.length; - while (trailing > leading && /\s/.test(regionText[trailing - 1])) trailing--; - const contentStart = region.startOffset + leading; - const contentEnd = region.startOffset + trailing; - if (contentEnd > contentStart) { - activeStartLine = editor.document.positionAt(contentStart).line; - activeEndLine = editor.document.positionAt(contentEnd - 1).line; - } - } + const activeStartLine = editor.document.positionAt(block.startOffset).line; + const activeEndLine = editor.document.positionAt(block.endOffset - 1).line; // Decorate every line to keep the reserved slot stable; only active // lines override `backgroundColor` to paint the visible bar. diff --git a/packages/nosql-language-service/src/services/SqlLanguageService.test.ts b/packages/nosql-language-service/src/services/SqlLanguageService.test.ts index ec02e0350..3d46307f4 100644 --- a/packages/nosql-language-service/src/services/SqlLanguageService.test.ts +++ b/packages/nosql-language-service/src/services/SqlLanguageService.test.ts @@ -94,3 +94,60 @@ describe('SqlLanguageService.getSeparatorPositions', () => { expect(text[seps[1].semicolonOffset]).toBe(';'); }); }); + +describe('SqlLanguageService.getActiveBlockOffsets', () => { + let service: SqlLanguageService; + + beforeEach(() => { + service = new SqlLanguageService(); + }); + + it('returns null for a single query', () => { + expect(service.getActiveBlockOffsets('SELECT * FROM c', 3)).toBeNull(); + }); + + it('returns null for a single query with trailing semicolon (one non-empty region)', () => { + expect(service.getActiveBlockOffsets('SELECT * FROM c;', 3)).toBeNull(); + }); + + it('resolves the active block under the cursor', () => { + const text = 'SELECT 1;\nSELECT 2;'; + const cursor = text.indexOf('SELECT 2'); + const block = service.getActiveBlockOffsets(text, cursor); + expect(block).not.toBeNull(); + expect(text.substring(block!.startOffset, block!.endOffset)).toBe('SELECT 2'); + }); + + it('strips leading whitespace from the active block', () => { + const text = 'SELECT 1;\n\n SELECT 2;'; + const cursor = text.indexOf('SELECT 2'); + const block = service.getActiveBlockOffsets(text, cursor); + expect(block).not.toBeNull(); + // startOffset must point at 'S', not the preceding whitespace/newlines. + expect(text[block!.startOffset]).toBe('S'); + expect(text.substring(block!.startOffset, block!.endOffset)).toBe('SELECT 2'); + }); + + it('strips trailing whitespace from the active block', () => { + const text = 'SELECT 1;\nSELECT 2 ;'; + const cursor = text.indexOf('SELECT 2'); + const block = service.getActiveBlockOffsets(text, cursor); + expect(block).not.toBeNull(); + const content = text.substring(block!.startOffset, block!.endOffset); + expect(content).toBe(content.trim()); + expect(content).toBe('SELECT 2'); + }); + + it('returns null when the cursor sits in a whitespace-only region', () => { + const text = 'SELECT 1; ;SELECT 2;'; + const cursor = text.indexOf(' ;') + 1; // inside the blank region between ;; + expect(service.getActiveBlockOffsets(text, cursor)).toBeNull(); + }); + + it('endOffset is exclusive (last highlighted char is endOffset - 1)', () => { + const text = 'SELECT 1;\nSELECT 2;'; + const cursor = text.indexOf('SELECT 2'); + const block = service.getActiveBlockOffsets(text, cursor)!; + expect(text[block.endOffset - 1]).toBe('2'); + }); +}); diff --git a/packages/nosql-language-service/src/services/SqlLanguageService.ts b/packages/nosql-language-service/src/services/SqlLanguageService.ts index 44459b979..0eaffe39f 100644 --- a/packages/nosql-language-service/src/services/SqlLanguageService.ts +++ b/packages/nosql-language-service/src/services/SqlLanguageService.ts @@ -24,6 +24,7 @@ import { getFunctionMeta } from './functionSignatures.js'; import { parseMultiQueryDocument, type MultiQueryDocument, type QueryRegion } from './MultiQueryDocument.js'; import { DiagnosticSeverity, + type ActiveBlockRange, type Diagnostic, type FoldableRegion, type HoverInfo, @@ -148,6 +149,45 @@ export class SqlLanguageService { return result; } + /** + * Resolve the content range of the active query block for the given + * cursor offset, with leading/trailing whitespace stripped. + * + * Editors use this to highlight the block under the cursor. The + * whitespace trim matters because a region's `startOffset` sits + * immediately after the previous `;` (on the previous query's line) — + * without trimming, the highlight would bleed onto that line and mark + * two queries as active. + * + * Returns `null` when: + * - the document has one or zero non-empty regions (nothing to + * distinguish), or + * - the cursor is not inside a non-empty region, or + * - the active region is whitespace-only. + */ + getActiveBlockOffsets(query: string, cursorOffset: number): ActiveBlockRange | null { + const doc = parseMultiQueryDocument(query); + + // Only highlight when there is more than one non-empty region. + const nonEmpty = doc.regions.filter((r) => r.text.trim().length > 0); + if (nonEmpty.length <= 1) return null; + + const region = doc.regionAtOffset(cursorOffset); + if (!region || region.text.trim().length === 0) return null; + + const regionText = region.text; + let leading = 0; + while (leading < regionText.length && /\s/.test(regionText[leading])) leading++; + let trailing = regionText.length; + while (trailing > leading && /\s/.test(regionText[trailing - 1])) trailing--; + + const startOffset = region.startOffset + leading; + const endOffset = region.startOffset + trailing; + if (endOffset <= startOffset) return null; + + return { startOffset, endOffset }; + } + // ─── Diagnostics ──────────────────────────────────────── /** diff --git a/packages/nosql-language-service/src/services/types.ts b/packages/nosql-language-service/src/services/types.ts index 67d68ba08..6c0efee06 100644 --- a/packages/nosql-language-service/src/services/types.ts +++ b/packages/nosql-language-service/src/services/types.ts @@ -140,6 +140,22 @@ export interface SeparatorPosition { readonly semicolonOffset: number; } +/** + * Content range of the active query block in a multi-query document, + * described by document-level byte offsets with leading/trailing + * whitespace stripped. + * + * Editors convert these offsets to native positions to highlight the + * block under the cursor. `endOffset` is exclusive — the last + * highlighted character is at `endOffset - 1`. + */ +export interface ActiveBlockRange { + /** Offset of the first non-whitespace character of the active block. */ + readonly startOffset: number; + /** Offset just past the last non-whitespace character of the active block. */ + readonly endOffset: number; +} + // ========================== Language service host ============================= /** From 525bf605ef73a01dfb42ac0fd150af39b1dc17d9 Mon Sep 17 00:00:00 2001 From: Dmitrii Shilov <6812525+bk201-@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:39:05 +0200 Subject: [PATCH 12/12] fix(test): pass indentUnit arg to streamParser startState in tests tsc requires the StreamParser.startState(indentUnit) argument; vitest's esbuild transform skips type-checking so it only surfaced in the CI build. Pass 4 to satisfy the signature. --- .../src/providers/codemirror/streamParser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts b/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts index 4e2260b2f..bae13175d 100644 --- a/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts +++ b/packages/nosql-language-service/src/providers/codemirror/streamParser.test.ts @@ -17,7 +17,7 @@ interface Token { * the resulting state so multi-line contexts (strings, block comments) can be * tested across lines. */ -function tokenizeLine(line: string, state = cosmosDbSqlStreamParser.startState!()) { +function tokenizeLine(line: string, state = cosmosDbSqlStreamParser.startState!(4)) { const stream = new StringStream(line, 4, 4); const tokens: Token[] = []; let guard = 0; @@ -43,7 +43,7 @@ function typeOf(line: string): string | null { describe('cosmosDbSqlStreamParser', () => { it('starts in the top context', () => { - expect(cosmosDbSqlStreamParser.startState!()).toEqual({ context: 'top' }); + expect(cosmosDbSqlStreamParser.startState!(4)).toEqual({ context: 'top' }); }); it('classifies keywords, operator keywords, builtins and identifiers', () => {