Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for MongoDB $search operator #2526

Closed
wants to merge 14 commits into from
47 changes: 47 additions & 0 deletions src/ParseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ interface FullTextQueryOptions {
diacriticSensitive?: boolean;
}

interface SearchOptions {
index?: string;
}

export interface QueryJSON {
where: WhereClause;
watch?: string;
Expand Down Expand Up @@ -1585,6 +1589,49 @@ class ParseQuery<T extends ParseObject = ParseObject> {
return this._addCondition(key, '$text', { $search: fullOptions });
}

/*
* Triggers a MongoDb Atlas Text Search
*
* @param {string} value The string to search
* @param {string[]} path The fields to search
* @param {object} options (Optional)
* @param {string} options.index The index to search
* @returns {Promise} Returns a promise that will be resolved with the results
* of the search
*
*/

async search(value: string, path: string[], options?: SearchOptions): Promise<T[]> {
Copy link
Member

@dplewis dplewis Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency we should rename value to query and it should allow for string | string[]. This should be good enough for a simple search. We can add regex, allowAnalyzedField, and score options later or now if you prefer. For more advanced searching like compound the developer would have to use query.aggregate.

Edit: There is a empty line above that should be removed

if (!value) {
throw new Error('A search term is required.');
}

if (typeof value !== 'string') {
throw new Error('The value being searched for must be a string.');
}

const controller = CoreManager.getQueryController();
const params = {
$search: {
index: options?.index || 'default',
text: {
path,
query: value,
},
},
};

const searchOptions: { sessionToken?: string; useMasterKey: boolean } = {
useMasterKey: true,
};
const results = await controller.aggregate(this.className, params, searchOptions);
return (
results.results?.map(result =>
ParseObject.fromJSON({ className: this.className, ...result })
) || []
);
}

/**
* Method to sort the full text search by text score
*
Expand Down
56 changes: 56 additions & 0 deletions src/__tests__/ParseQuery-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2709,6 +2709,62 @@ describe('ParseQuery', () => {
});
});

it('can issue a search query', async () => {
CoreManager.setQueryController({
find() {},
aggregate(className, params, options) {
expect(className).toBe('Item');
expect(params).toEqual({
$search: {
index: 'searchIndex',
text: {
path: ['name'],
query: 'searchTerm',
},
},
});
expect(options.useMasterKey).toEqual(true);
return Promise.resolve({
results: [
{
objectId: 'I55',
foo: 'bar',
},
],
});
},
});
const q = new ParseQuery('Item');
const obj = await q.search('searchTerm', ['name'], { index: 'searchIndex' });
expect(obj[0].id).toBe('I55');
expect(obj[0].get('foo')).toBe('bar');
});

it('search term is required', async () => {
const q = new ParseQuery('Item');
await expect(q.search()).rejects.toThrow('A search term is required.');
});

it('search term must be a string', async () => {
const q = new ParseQuery('Item');
await expect(q.search(123)).rejects.toThrow('The value being searched for must be a string.');
});

it('search can return an empty array if no results', async () => {
CoreManager.setQueryController({
find() {},
aggregate() {
return Promise.resolve({
results: null,
});
},
});

const q = new ParseQuery('Item');
const results = await q.search('searchTerm', ['name'], { index: 'searchIndex' });
expect(results).toEqual([]);
});

it('aggregate query array pipeline with equalTo', done => {
const pipeline = [{ group: { objectId: '$name' } }];
MockRESTController.request.mockImplementationOnce(() => {
Expand Down
4 changes: 4 additions & 0 deletions types/ParseQuery.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ interface FullTextQueryOptions {
caseSensitive?: boolean;
diacriticSensitive?: boolean;
}
interface SearchOptions {
index?: string;
}
export interface QueryJSON {
where: WhereClause;
watch?: string;
Expand Down Expand Up @@ -634,6 +637,7 @@ declare class ParseQuery<T extends ParseObject = ParseObject> {
* @returns {Parse.Query} Returns the query, so you can chain this call.
*/
fullText<K extends keyof T['attributes'] | keyof BaseAttributes>(key: K, value: string, options?: FullTextQueryOptions): this;
search(value: string, path: string[], options?: SearchOptions): Promise<T[]>;
/**
* Method to sort the full text search by text score
*
Expand Down