From 8ed94421e69b1d4fde4ec3625a1f4cfbd6d39c2b Mon Sep 17 00:00:00 2001 From: dblythy Date: Thu, 16 Sep 2021 00:15:08 +1000 Subject: [PATCH] fix: add support for descending sorting of full text search (#7496) --- CHANGELOG.md | 1 + spec/ParseQuery.FullTextSearch.spec.js | 513 +++++++------------------ src/RestQuery.js | 2 +- 3 files changed, 133 insertions(+), 383 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1d355b7b..1920116337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,7 @@ ___ - Refactor: uniform issue templates across repos (Manuel Trezza) [#7528](https://github.com/parse-community/parse-server/pull/7528) - ci: bump ci environment (Manuel Trezza) [#7539](https://github.com/parse-community/parse-server/pull/7539) - CI now pushes docker images to Docker Hub (Corey Baker) [#7548](https://github.com/parse-community/parse-server/pull/7548) +- Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) - Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) - docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) diff --git a/spec/ParseQuery.FullTextSearch.spec.js b/spec/ParseQuery.FullTextSearch.spec.js index 99118d106c..e6614e5d81 100644 --- a/spec/ParseQuery.FullTextSearch.spec.js +++ b/spec/ParseQuery.FullTextSearch.spec.js @@ -5,10 +5,9 @@ const Parse = require('parse/node'); const request = require('../lib/request'); let databaseAdapter; -const fullTextHelper = () => { +const fullTextHelper = async () => { const config = Config.get('test'); databaseAdapter = config.database.adapter; - const subjects = [ 'coffee', 'Coffee Shopping', @@ -19,212 +18,84 @@ const fullTextHelper = () => { 'coffee and cream', 'Cafe con Leche', ]; - const requests = []; - for (const i in subjects) { - const request = { - method: 'POST', - body: { - subject: subjects[i], - comment: subjects[i], - }, - path: '/1/classes/TestObject', - }; - requests.push(request); - } - return reconfigureServer({ + await reconfigureServer({ appId: 'test', restAPIKey: 'test', publicServerURL: 'http://localhost:8378/1', databaseAdapter, - }).then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/batch', - body: { - requests, - }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); }); + await Parse.Object.saveAll( + subjects.map(subject => new Parse.Object('TestObject').set({ subject, comment: subject })) + ); }; describe('Parse.Query Full Text Search testing', () => { - it('fullTextSearch: $search', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'coffee', - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then( - resp => { - expect(resp.data.results.length).toBe(3); - done(); - }, - e => done.fail(e) - ); + it('fullTextSearch: $search', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + const results = await query.find(); + expect(results.length).toBe(3); }); - it('fullTextSearch: $search, sort', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'coffee', - }, - }, - }, - }; - const order = '$score'; - const keys = '$score'; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, order, keys, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(response => { - const resp = response.data; - expect(resp.results.length).toBe(3); - expect(resp.results[0].score); - expect(resp.results[1].score); - expect(resp.results[2].score); - done(); - }, done.fail); + it('fullTextSearch: $search, sort', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + query.select('$score'); + query.ascending('$score'); + const results = await query.find(); + expect(results.length).toBe(3); + expect(results[0].get('score')); + expect(results[1].get('score')); + expect(results[2].get('score')); }); - it('fullTextSearch: $language', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'leche', - $language: 'spanish', - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); + it('fulltext descending by $score', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + query.descending('$score'); + query.select('$score'); + const [first, second, third] = await query.find(); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(third).toBeDefined(); + expect(first.get('score')); + expect(second.get('score')); + expect(third.get('score')); + expect(first.get('score') >= second.get('score')).toBeTrue(); + expect(second.get('score') >= third.get('score')).toBeTrue(); }); - it('fullTextSearch: $diacriticSensitive', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: true, - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(1); - done(); - }, done.fail); + it('fullTextSearch: $language', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { language: 'spanish' }); + const resp = await query.find(); + expect(resp.length).toBe(2); }); - it('fullTextSearch: $search, invalid input', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: true, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); + it('fullTextSearch: $diacriticSensitive', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'CAFÉ', { diacriticSensitive: true }); + const resp = await query.find(); + expect(resp.length).toBe(1); }); - it('fullTextSearch: $language, invalid input', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'leche', - $language: true, - }, - }, + it('fullTextSearch: $search, invalid input', async () => { + await fullTextHelper(); + const invalidQuery = async () => { + const where = { + subject: { + $text: { + $search: true, }, - }; - return request({ + }, + }; + try { + await request({ method: 'POST', url: 'http://localhost:8378/1/classes/TestObject', body: { where, _method: 'GET' }, @@ -234,151 +105,73 @@ describe('Parse.Query Full Text Search testing', () => { 'Content-Type': 'application/json', }, }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); + } catch (e) { + throw new Parse.Error(e.data.code, e.data.error); + } + }; + await expectAsync(invalidQuery()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $search, should be object') + ); }); - it('fullTextSearch: $caseSensitive, invalid input', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'Coffee', - $caseSensitive: 'string', - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); + it('fullTextSearch: $language, invalid input', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { language: true }); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $language, should be string') + ); }); - it('fullTextSearch: $diacriticSensitive, invalid input', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: 'string', - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }) - .catch(err => { - expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); + it('fullTextSearch: $caseSensitive, invalid input', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { caseSensitive: 'string' }); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $caseSensitive, should be boolean') + ); + }); + + it('fullTextSearch: $diacriticSensitive, invalid input', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { diacriticSensitive: 'string' }); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $diacriticSensitive, should be boolean') + ); }); }); describe_only_db('mongo')('[mongodb] Parse.Query Full Text Search testing', () => { - it('fullTextSearch: does not create text index if compound index exist', done => { - fullTextHelper() - .then(() => { - return databaseAdapter.dropAllIndexes('TestObject'); - }) - .then(() => { - return databaseAdapter.getIndexes('TestObject'); - }) - .then(indexes => { - expect(indexes.length).toEqual(1); - return databaseAdapter.createIndex('TestObject', { - subject: 'text', - comment: 'text', - }); - }) - .then(() => { - return databaseAdapter.getIndexes('TestObject'); - }) - .then(indexes => { - expect(indexes.length).toEqual(2); - const where = { - subject: { - $text: { - $search: { - $term: 'coffee', - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toEqual(3); - return databaseAdapter.getIndexes('TestObject'); - }) - .then(indexes => { - expect(indexes.length).toEqual(2); - request({ - url: 'http://localhost:8378/1/schemas/TestObject', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - 'Content-Type': 'application/json', - }, - }).then(response => { - const body = response.data; - expect(body.indexes._id_).toBeDefined(); - expect(body.indexes._id_._id).toEqual(1); - expect(body.indexes.subject_text_comment_text).toBeDefined(); - expect(body.indexes.subject_text_comment_text.subject).toEqual('text'); - expect(body.indexes.subject_text_comment_text.comment).toEqual('text'); - done(); - }); - }) - .catch(done.fail); + it('fullTextSearch: does not create text index if compound index exist', async () => { + await fullTextHelper(); + await databaseAdapter.dropAllIndexes('TestObject'); + let indexes = await databaseAdapter.getIndexes('TestObject'); + expect(indexes.length).toEqual(1); + await databaseAdapter.createIndex('TestObject', { + subject: 'text', + comment: 'text', + }); + indexes = await databaseAdapter.getIndexes('TestObject'); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + query.select('$score'); + query.ascending('$score'); + const results = await query.find(); + expect(results.length).toBe(3); + expect(results[0].get('score')); + expect(results[1].get('score')); + expect(results[2].get('score')); + + indexes = await databaseAdapter.getIndexes('TestObject'); + expect(indexes.length).toEqual(2); + + const schemas = await new Parse.Schema('TestObject').get(); + expect(schemas.indexes._id_).toBeDefined(); + expect(schemas.indexes._id_._id).toEqual(1); + expect(schemas.indexes.subject_text_comment_text).toBeDefined(); + expect(schemas.indexes.subject_text_comment_text.subject).toEqual('text'); + expect(schemas.indexes.subject_text_comment_text.comment).toEqual('text'); }); it('fullTextSearch: does not create text index if schema compound index exist', done => { @@ -458,64 +251,20 @@ describe_only_db('mongo')('[mongodb] Parse.Query Full Text Search testing', () = .catch(done.fail); }); - it('fullTextSearch: $diacriticSensitive - false', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: false, - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(2); - done(); - }, done.fail); + it('fullTextSearch: $diacriticSensitive - false', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'CAFÉ', { diacriticSensitive: false }); + const resp = await query.find(); + expect(resp.length).toBe(2); }); - it('fullTextSearch: $caseSensitive', done => { - fullTextHelper() - .then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'Coffee', - $caseSensitive: true, - }, - }, - }, - }; - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where, _method: 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then(resp => { - expect(resp.data.results.length).toBe(1); - done(); - }, done.fail); + it('fullTextSearch: $caseSensitive', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'Coffee', { caseSensitive: true }); + const results = await query.find(); + expect(results.length).toBe(1); }); }); diff --git a/src/RestQuery.js b/src/RestQuery.js index a919d5091f..be96683451 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -143,7 +143,7 @@ function RestQuery( var fields = restOptions.order.split(','); this.findOptions.sort = fields.reduce((sortMap, field) => { field = field.trim(); - if (field === '$score') { + if (field === '$score' || field === '-$score') { sortMap.score = { $meta: 'textScore' }; } else if (field[0] == '-') { sortMap[field.slice(1)] = -1;