From 10542c8c19c50f488d8ab6971273948f93798437 Mon Sep 17 00:00:00 2001 From: Mike Noseworthy Date: Sun, 24 Mar 2024 19:12:21 -0230 Subject: [PATCH] feat(NODE-6044): add detailed types for `SearchIndexDescription` --- src/collection.ts | 3 +- src/index.ts | 59 +++ src/operations/search_indexes/create.ts | 5 +- src/operations/search_indexes/definition.ts | 480 ++++++++++++++++++ src/operations/search_indexes/types.ts | 0 src/operations/search_indexes/update.ts | 5 +- .../crud/abstract_operation.test.ts | 6 +- .../types/search_index_descriptions.test-d.ts | 96 ++++ test/types/search_indexes_test-d.ts | 25 + 9 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 src/operations/search_indexes/definition.ts create mode 100644 src/operations/search_indexes/types.ts create mode 100644 test/types/search_index_descriptions.test-d.ts create mode 100644 test/types/search_indexes_test-d.ts diff --git a/src/collection.ts b/src/collection.ts index b6581675ca..409aafabd0 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -73,6 +73,7 @@ import { CreateSearchIndexesOperation, type SearchIndexDescription } from './operations/search_indexes/create'; +import type { SearchIndexDefinition } from './operations/search_indexes/definition'; import { DropSearchIndexOperation } from './operations/search_indexes/drop'; import { UpdateSearchIndexOperation } from './operations/search_indexes/update'; import { @@ -1133,7 +1134,7 @@ export class Collection { * * @remarks Only available when used against a 7.0+ Atlas cluster. */ - async updateSearchIndex(name: string, definition: Document): Promise { + async updateSearchIndex(name: string, definition: SearchIndexDefinition): Promise { return executeOperation( this.client, new UpdateSearchIndexOperation(this as TODO_NODE_3286, name, definition) diff --git a/src/index.ts b/src/index.ts index 9cd58ec0ac..920b3fe3f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -483,6 +483,65 @@ export type { RemoveUserOptions } from './operations/remove_user'; export type { RenameOptions } from './operations/rename'; export type { RunCommandOptions } from './operations/run_command'; export type { SearchIndexDescription } from './operations/search_indexes/create'; +export type { + AsciiFoldingTokenFilter, + AutocompleteFieldMapping, + BooleanFieldMapping, + CharacterFilter, + CustomAnalyzer, + DaitchMokotoffSoundexTokenFilter, + DateFacetFieldMapping, + DateFieldMapping, + DocumentFieldMapping, + EdgeGramTokenFilter, + EdgeGramTokenizer, + EmbeddedDocumentFieldMapping, + EnglishPossessiveTokenFilter, + FieldMapping, + FlattenGraphTokenFilter, + GeoFieldMapping, + HtmlStripCharFilter, + IcuFoldingTokenFilter, + IcuNormalizeCharFilter, + IcuNormalizerTokenFilter, + KeywordTokenizer, + KnnVectorFieldMapping, + KStemmingTokenFilter, + LengthTokenFilter, + LowercaseTokenFilter, + MappingCharFilter, + NgramTokenFilter, + NGramTokenizer, + NumberFacetFieldMapping, + NumberFieldMapping, + ObjectIdFieldMapping, + PersianCharFilter, + PorterStemmingTokenFilter, + RegexCaptureGroupTokenizer, + RegexSplitTokenizer, + RegexTokenFilter, + ReverseTokenFilter, + SearchIndexDefinition, + ShingleTokenFilter, + SnowballStemmingTokenFilter, + SpanishPluralStemming, + StandardTokenizer, + StempelTokenFilter, + StopwordTokenFilter, + StoredSourceDefinition, + StoredSourceExcludeDefinition, + StoredSourceIncludeDefinition, + StringFacetFieldMapping, + StringFieldMapping, + SynonymMappingDefinition, + TokenFieldMapping, + TokenFilter, + Tokenizer, + TrimTokenFilter, + UaxUrlEmailTokenizer, + WhitespaceTokenizer, + WordDelimiterGraphTokenFilter +} from './operations/search_indexes/definition'; export type { SetProfilingLevelOptions } from './operations/set_profiling_level'; export type { DbStatsOptions } from './operations/stats'; export type { diff --git a/src/operations/search_indexes/create.ts b/src/operations/search_indexes/create.ts index 054ba02629..bc7f75bcf2 100644 --- a/src/operations/search_indexes/create.ts +++ b/src/operations/search_indexes/create.ts @@ -1,9 +1,8 @@ -import type { Document } from 'bson'; - import type { Collection } from '../../collection'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; import { AbstractOperation } from '../operation'; +import type { SearchIndexDefinition } from './definition'; /** * @public @@ -13,7 +12,7 @@ export interface SearchIndexDescription { name?: string; /** The index definition. */ - definition: Document; + definition: SearchIndexDefinition; } /** @internal */ diff --git a/src/operations/search_indexes/definition.ts b/src/operations/search_indexes/definition.ts new file mode 100644 index 0000000000..56c5da4a5a --- /dev/null +++ b/src/operations/search_indexes/definition.ts @@ -0,0 +1,480 @@ +import type { Document } from 'bson'; + +/* Token Filters */ + +/** @public */ +export type AsciiFoldingTokenFilter = { + type: 'asciiFolding'; + originalTokens?: 'include' | 'omit'; +}; + +/** @public */ +export type DaitchMokotoffSoundexTokenFilter = { + type: 'daitchMokotoffSoundex'; + originalTokens?: 'include' | 'omit'; +}; + +/** @public */ +export type EdgeGramTokenFilter = { + type: 'edgeGram'; + minGram: number; + maxGram: number; + termNotInBounds?: 'include' | 'omit'; +}; + +/** @public */ +export type EnglishPossessiveTokenFilter = { + type: 'englishPossessive'; +}; + +/** @public */ +export type FlattenGraphTokenFilter = { + type: 'flattenGraph'; +}; + +/** @public */ +export type IcuFoldingTokenFilter = { + type: 'icuFolding'; +}; + +/** @public */ +export type IcuNormalizerTokenFilter = { + type: 'icuNormalizer'; + normalizationForm?: 'nfd' | 'nfc' | 'nfkd' | 'nfkc'; +}; + +/** @public */ +export type KStemmingTokenFilter = { + type: 'kStemming'; +}; + +/** @public */ +export type LengthTokenFilter = { + type: 'length'; + min?: number; + max?: number; +}; + +/** @public */ +export type LowercaseTokenFilter = { + type: 'lowercase'; +}; + +/** @public */ +export type NgramTokenFilter = { + type: 'nGram'; + minGram: number; + maxGram: number; + termNotInBounds?: 'include' | 'omit'; +}; + +/** @public */ +export type PorterStemmingTokenFilter = { + type: 'porterStemming'; +}; + +/** @public */ +export type RegexTokenFilter = { + type: 'regex'; + pattern: string; + replacement: string; + matches: 'all' | 'first'; +}; + +/** @public */ +export type ReverseTokenFilter = { + type: 'reverse'; +}; + +/** @public */ +export type ShingleTokenFilter = { + type: 'shingle'; + minShingleSize: number; + maxShingleSize: number; +}; + +/** @public */ +export type SnowballStemmingTokenFilter = { + type: 'snowballStemming'; + stemmerName: + | 'arabic' + | 'armenian' + | 'basque' + | 'catalan' + | 'danish' + | 'dutch' + | 'english' + | 'estonian' + | 'finnish' + | 'french' + | 'german' + | 'german2' + | 'hungarian' + | 'irish' + | 'italian' + | 'kp' + | 'lithuanian' + | 'lovins' + | 'norwegian' + | 'porter' + | 'portuguese' + | 'romanian' + | 'russian' + | 'spanish' + | 'swedish' + | 'turkish'; +}; + +/** @public */ +export type SpanishPluralStemming = { + type: 'spanishPluralStemming'; +}; + +/** @public */ +export type StempelTokenFilter = { + type: 'stempel'; +}; + +/** @public */ +export type StopwordTokenFilter = { + type: 'stopword'; + tokens: string[]; + ignoreCase?: boolean; +}; + +/** @public */ +export type TrimTokenFilter = { + type: 'trim'; +}; + +/** @public */ +export type WordDelimiterGraphTokenFilter = { + type: 'wordDelimiterGraph'; + delimiterOptions?: { + generateWordParts?: boolean; + generateNumberParts?: boolean; + concatenateWords?: boolean; + concatenateNumbers?: boolean; + concatenateAll?: boolean; + preserveOriginal?: boolean; + splitOnCaseChange?: boolean; + splitOnNumerics?: boolean; + stemEnglishPossessive?: boolean; + ignoreKeywords?: boolean; + protectedWords?: { + words: string[]; + ignoreCase?: boolean; + }; + }; +}; + +/** @public */ +export type TokenFilter = + | AsciiFoldingTokenFilter + | DaitchMokotoffSoundexTokenFilter + | EdgeGramTokenFilter + | EnglishPossessiveTokenFilter + | FlattenGraphTokenFilter + | IcuFoldingTokenFilter + | IcuNormalizerTokenFilter + | KStemmingTokenFilter + | LengthTokenFilter + | LowercaseTokenFilter + | NgramTokenFilter + | PorterStemmingTokenFilter + | RegexTokenFilter + | ReverseTokenFilter + | ShingleTokenFilter + | SnowballStemmingTokenFilter + | SpanishPluralStemming + | StempelTokenFilter + | StopwordTokenFilter + | TrimTokenFilter + | WordDelimiterGraphTokenFilter; + +/* Tokenizers */ + +/** @public */ +export type EdgeGramTokenizer = { + type: 'edgeGram'; + minGram: number; + maxGram: number; +}; + +/** @public */ +export type KeywordTokenizer = { + type: 'keyword'; +}; + +/** @public */ +export type NGramTokenizer = { + type: 'nGram'; + minGram: number; + maxGram: number; +}; + +/** @public */ +export type RegexCaptureGroupTokenizer = { + type: 'regexCaptureGroup'; + pattern: string; + group: number; +}; + +/** @public */ +export type RegexSplitTokenizer = { + type: 'regexSplit'; + pattern: string; +}; + +/** @public */ +export type StandardTokenizer = { + type: 'standard'; + maxTokenLength?: number; +}; + +/** @public */ +export type UaxUrlEmailTokenizer = { + type: 'uaxUrlEmail'; + maxTokenLength?: number; +}; + +/** @public */ +export type WhitespaceTokenizer = { + type: 'whitespace'; + maxTokenLength?: number; +}; + +/** @public */ +export type Tokenizer = + | EdgeGramTokenizer + | KeywordTokenizer + | NGramTokenizer + | RegexCaptureGroupTokenizer + | RegexSplitTokenizer + | StandardTokenizer + | UaxUrlEmailTokenizer + | WhitespaceTokenizer; + +/* Character filters */ + +/** @public */ +export type HtmlStripCharFilter = { + type: 'htmlStrip'; + ignoredTags?: string[]; +}; + +/** @public */ +export type IcuNormalizeCharFilter = { + type: 'icuNormalize'; +}; + +/** @public */ +export type MappingCharFilter = { + type: 'mapping'; + mappings: Record; +}; + +/** @public */ +export type PersianCharFilter = { + type: 'persian'; +}; + +/** @public */ +export type CharacterFilter = + | HtmlStripCharFilter + | IcuNormalizeCharFilter + | MappingCharFilter + | PersianCharFilter; + +/* Custom analyzers */ + +/** @public */ +export type CustomAnalyzer = { + name: string; + charFilters?: CharacterFilter[]; + tokenizer: Tokenizer; + tokenFilters?: TokenFilter[]; +}; + +/* Field mappings */ + +/** @public */ +export type AutocompleteFieldMapping = { + type: 'autocomplete'; + analyzer?: string; + maxGrams?: number; + minGrams?: number; + tokenization?: 'edgeGram' | 'rightEdgeGram' | 'nGram'; + foldDiacritics?: boolean; +}; + +/** @public */ +export type BooleanFieldMapping = { + type: 'boolean'; +}; + +/** @public */ +export type DateFieldMapping = { + type: 'date'; +}; + +/** @public */ +export type DateFacetFieldMapping = { + type: 'dateFacet'; +}; + +/** @public */ +export type DocumentFieldMapping = { + type: 'document'; + dynamic?: boolean; + fields: Record; +}; + +/** @public */ +export type EmbeddedDocumentFieldMapping = { + type: 'embeddedDocuments'; + dynamic?: boolean; + fields: Record; +}; + +/** @public */ +export type GeoFieldMapping = { + type: 'geo'; + indexShapes?: boolean; +}; + +/** @public */ +export type KnnVectorFieldMapping = { + type: 'knnVector'; + dimensions: number; + similarity: 'euclidean' | 'cosine' | 'dotProduct'; +}; + +/** @public */ +export type NumberFieldMapping = { + type: 'number'; + representation?: 'int64' | 'double'; + indexIntegers?: boolean; + indexDoubles?: boolean; +}; + +/** @public */ +export type NumberFacetFieldMapping = { + type: 'numberFacet'; + representation?: 'int64' | 'double'; + indexIntegers?: boolean; + indexDoubles?: boolean; +}; + +/** @public */ +export type ObjectIdFieldMapping = { + type: 'objectId'; +}; + +/** @public */ +export type StringFieldMapping = { + type: 'string'; + analyzer?: string; + searchAnalyzer?: string; + indexOptions?: 'docs' | 'freqs' | 'positions' | 'offsets'; + store?: boolean; + ignoreAbove?: number; + multi?: string; + norms?: 'include' | 'omit'; +}; + +/** @public */ +export type StringFacetFieldMapping = { + type: 'stringFacet'; +}; + +/** @public */ +export type TokenFieldMapping = { + type: 'token'; + normalizer?: 'lowercase' | 'none'; +}; + +/** @public */ +export type FieldMapping = + | AutocompleteFieldMapping + | BooleanFieldMapping + | DateFieldMapping + | DateFacetFieldMapping + | DocumentFieldMapping + | EmbeddedDocumentFieldMapping + | GeoFieldMapping + | KnnVectorFieldMapping + | NumberFieldMapping + | NumberFacetFieldMapping + | ObjectIdFieldMapping + | StringFieldMapping + | StringFacetFieldMapping + | TokenFieldMapping; + +/* stored sources */ + +/** @public */ +export type StoredSourceIncludeDefinition = { + include: string[]; +}; + +/** @public */ +export type StoredSourceExcludeDefinition = { + exclude: string[]; +}; + +/** @public */ +export type StoredSourceDefinition = StoredSourceIncludeDefinition | StoredSourceExcludeDefinition; + +/* synonym mapping */ + +/** @public */ +export type SynonymMappingDefinition = { + analyzer: string; + name: string; + source: { + collection: string; + }; +}; + +/** @public + * Definition of a search index. + * + * @remarks Only available when used against a 7.0+ Atlas cluster. + * @see https://www.mongodb.com/docs/atlas/atlas-search/index-definitions/#std-label-ref-index-definitions + * */ +export interface SearchIndexDefinition extends Document { + /** + * Specifies the analyzer to apply to string fields when indexing. + * If you omit this field, the index uses the standard analyzer. + * */ + analyzer?: string; + + /** Specifies the Custom Analyzers to use in this index. */ + analyzers?: CustomAnalyzer[]; + + /** Specifies how to index fields at different paths for this index */ + mappings: { + /** Enables or disables dynamic mapping of fields for this index. */ + dynamic: boolean; + + /** + * Required only if dynamic mapping is disabled. + * Specifies the fields that you would like to index. + */ + fields?: Record; + }; + + /** + * Specifies the analyzer to apply to query text before the text is searched. + * If you omit this field, the index uses the same analyzer specified in the analyzer field. + * If you omit both the searchAnalyzer and the analyzer fields, the index uses the standard analyzer. + * */ + searchAnalyzer?: string; + + /** Specifies fields in the documents to store for query-time look-ups using the returnedStoredSource option. */ + storedSource?: boolean | StoredSourceDefinition; + + /** Synonym mappings to use in your index. */ + synonyms?: SynonymMappingDefinition[]; +} diff --git a/src/operations/search_indexes/types.ts b/src/operations/search_indexes/types.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/operations/search_indexes/update.ts b/src/operations/search_indexes/update.ts index aad7f93536..1126eafc01 100644 --- a/src/operations/search_indexes/update.ts +++ b/src/operations/search_indexes/update.ts @@ -1,16 +1,15 @@ -import type { Document } from 'bson'; - import type { Collection } from '../../collection'; import type { Server } from '../../sdam/server'; import type { ClientSession } from '../../sessions'; import { AbstractOperation } from '../operation'; +import type { SearchIndexDefinition } from './definition'; /** @internal */ export class UpdateSearchIndexOperation extends AbstractOperation { constructor( private readonly collection: Collection, private readonly name: string, - private readonly definition: Document + private readonly definition: SearchIndexDefinition ) { super(); } diff --git a/test/integration/crud/abstract_operation.test.ts b/test/integration/crud/abstract_operation.test.ts index fcac3e6ffe..50c4309f69 100644 --- a/test/integration/crud/abstract_operation.test.ts +++ b/test/integration/crud/abstract_operation.test.ts @@ -231,7 +231,9 @@ describe('abstract operation', function () { }, { subclassCreator: () => - new mongodb.CreateSearchIndexesOperation(collection, [{ definition: { a: 1 } }]), + new mongodb.CreateSearchIndexesOperation(collection, [ + { definition: { mappings: { dynamic: true } } } + ]), subclassType: mongodb.CreateSearchIndexesOperation, correctCommandName: 'createSearchIndexes' }, @@ -243,7 +245,7 @@ describe('abstract operation', function () { { subclassCreator: () => new mongodb.UpdateSearchIndexOperation(collection, 'dummyName', { - a: 1 + mappings: { dynamic: true } }), subclassType: mongodb.UpdateSearchIndexOperation, correctCommandName: 'updateSearchIndex' diff --git a/test/types/search_index_descriptions.test-d.ts b/test/types/search_index_descriptions.test-d.ts new file mode 100644 index 0000000000..6bc5290df4 --- /dev/null +++ b/test/types/search_index_descriptions.test-d.ts @@ -0,0 +1,96 @@ +import { expectAssignable, expectNotAssignable } from 'tsd'; + +import type { SearchIndexDescription } from '../../src'; + +// Test to ensure valid configurations of SearchIndexDescription are allowed +expectAssignable({ + name: 'mySearchIndex', + definition: { + mappings: { + dynamic: true + } + } +}); + +expectAssignable({ + definition: { + analyzer: 'standard', + searchAnalyzer: 'standard', + mappings: { + dynamic: false, + fields: { + title: { type: 'string' } + } + }, + storedSource: true + } +}); + +expectAssignable({ + definition: { + analyzer: 'custom_analyzer', + analyzers: [ + { + name: 'custom_analyzer', + tokenizer: { type: 'standard' } + } + ], + mappings: { + dynamic: false, + fields: { + description: { type: 'string', analyzer: 'custom_analyzer' } + } + }, + storedSource: { + include: ['title', 'description'] + }, + synonyms: [ + { + analyzer: 'standard', + name: 'synonym_mapping', + source: { + collection: 'synonyms' + } + } + ] + } +}); + +// Test to ensure configurations missing required `definition` are invalid +expectNotAssignable({}); +expectNotAssignable({ + name: 'incompleteDefinition' +}); + +// Test configurations that should not be assignable to SearchIndexDescription due to invalid `definition` structure +expectNotAssignable({ + name: 'invalidDefinition', + definition: { + mappings: { + dynamic: 'yes' // dynamic should be a boolean + } + } +}); + +// Test configurations with incorrect field types in `definition` +expectNotAssignable({ + definition: { + analyzer: 'standard', + mappings: { + dynamic: false, + fields: { + createdAt: { type: 'date', wrongOption: true } // 'wrongOption' is not a valid property for DateFieldMapping + } + } + } +}); + +// Ensure that `name` as other than string is caught +expectNotAssignable({ + name: 123, // name should be a string + definition: { + mappings: { + dynamic: true + } + } +}); diff --git a/test/types/search_indexes_test-d.ts b/test/types/search_indexes_test-d.ts new file mode 100644 index 0000000000..4fc3586f44 --- /dev/null +++ b/test/types/search_indexes_test-d.ts @@ -0,0 +1,25 @@ +import { expectType } from 'tsd'; + +import { MongoClient } from '../../src'; + +const client = new MongoClient(''); +const db = client.db('test'); +const collection = db.collection('test.find'); + +// Promise variant testing +expectType>( + collection.createSearchIndex({ + name: 'test-index', + definition: { mappings: { dynamic: false, fields: { description: { type: 'string' } } } } + }) +); + +// Explicit check for iterable result +for (const indexName of await collection.createSearchIndexes([ + { + name: 'test-index', + definition: { mappings: { dynamic: false, fields: { description: { type: 'string' } } } } + } +])) { + expectType(indexName); +}