diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index b238e24e63..48300892c7 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -4,7 +4,10 @@ // Some new tests are added. 'use strict'; +const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); +const databaseURI = 'mongodb://localhost:27017/test'; const Parse = require('parse/node'); +const rp = require('request-promise'); describe('Parse.Query testing', () => { it("basic query", function(done) { @@ -2816,4 +2819,235 @@ describe('Parse.Query testing', () => { done(); }, done.fail); }); -}); + + const fullTextHelper = () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + const subjects = [ + 'coffee', + 'Coffee Shopping', + 'Baking a cake', + 'baking', + 'Café Con Leche', + 'Сырники', + 'coffee and cream', + 'Cafe con Leche', + ]; + const requests = []; + for (const i in subjects) { + const request = { + method: "POST", + body: { + subject: subjects[i] + }, + path: "/1/classes/TestObject" + }; + requests.push(request); + } + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + databaseAdapter: adapter + }).then(() => { + return adapter.createIndex('TestObject', {subject:'text'}); + }).then(() => { + return rp.post({ + url: 'http://localhost:8378/1/batch', + body: { + requests + }, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }); + } + + it_exclude_dbs(['postgres'])('fullTextSearch: $search', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'coffee' + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + expect(resp.results.length).toBe(3); + done(); + }, done.fail); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $language', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'leche', + $language: 'es' + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + expect(resp.results.length).toBe(2); + done(); + }, done.fail); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $caseSensitive', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'Coffee', + $caseSensitive: true + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + expect(resp.results.length).toBe(1); + done(); + }, done.fail); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $diacriticSensitive', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'CAFÉ', + $diacriticSensitive: true + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + expect(resp.results.length).toBe(1); + done(); + }, done.fail); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $search, invalid input', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: true + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }).catch((err) => { + expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $language, invalid input', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'leche', + $language: true + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }).catch((err) => { + expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $caseSensitive, invalid input', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'Coffee', + $caseSensitive: 'string' + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }).catch((err) => { + expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('fullTextSearch: $diacriticSensitive, invalid input', (done) => { + fullTextHelper().then(() => { + const where = { + $text: { + $search: 'CAFÉ', + $diacriticSensitive: 'string' + } + }; + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test' + } + }); + }).then((resp) => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }).catch((err) => { + expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); +}); \ No newline at end of file diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index bf21df5dac..80774aa32d 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -14,6 +14,10 @@ export default class MongoCollection { // This could be improved a lot but it's not clear if that's a good // idea. Or even if this behavior is a good idea. find(query, { skip, limit, sort, keys, maxTimeMS } = {}) { + if(keys && keys['$textScore']){ + delete keys['$textScore']; + keys['textScore'] = {'$meta': 'textScore'}; + } return this._rawFind(query, { skip, limit, sort, keys, maxTimeMS }) .catch(error => { // Check for "no geoindex" error diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 267e9f7a59..07217a58db 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -393,7 +393,12 @@ export class MongoStorageAdapter { performInitialization() { return Promise.resolve(); } + + createIndex(className, index) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.createIndex(index)); + } } export default MongoStorageAdapter; -module.exports = MongoStorageAdapter; // Required for tests +module.exports = MongoStorageAdapter; // Required for tests \ No newline at end of file diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index a9bdda3d40..7333b4ff52 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -576,6 +576,61 @@ function transformConstraint(constraint, inArray) { answer[key] = constraint[key]; break; + case '$search': { + const s = constraint[key]; + if (typeof s !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: ${s}, should be string` + ); + } + answer[key] = s; + break; + } + case '$language': { + const s = constraint[key]; + if (typeof s !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: ${s}, should be string` + ); + } + answer[key] = s; + break; + } + case '$caseSensitive': { + const s = constraint[key]; + if (typeof s !== 'boolean') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: ${s}, should be boolean` + ); + } + answer[key] = s; + break; + } + case '$diacriticSensitive': { + const s = constraint[key]; + if (typeof s !== 'boolean') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: ${s}, should be boolean` + ); + } + answer[key] = s; + break; + } + case '$meta': { + const s = constraint[key]; + if (s !== 'textScore') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $meta: ${s}, should be textScore` + ); + } + answer[key] = s; + break; + } case '$nearSphere': var point = constraint[key]; answer[key] = [point.longitude, point.latitude]; @@ -1026,4 +1081,4 @@ module.exports = { transformUpdate, transformWhere, mongoObjectToParseObject, -}; +}; \ No newline at end of file diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 40cbeddf0b..6e5003ce80 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -42,7 +42,7 @@ const transformObjectACL = ({ ACL, ...result }) => { return result; } -const specialQuerykeys = ['$and', '$or', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count']; +const specialQuerykeys = ['$and', '$or', '$text', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count']; const isSpecialQueryKey = key => { return specialQuerykeys.indexOf(key) >= 0; @@ -989,4 +989,4 @@ function joinTableName(className, key) { // Expose validateQuery for tests DatabaseController._validateQuery = validateQuery; -module.exports = DatabaseController; +module.exports = DatabaseController; \ No newline at end of file diff --git a/src/RestQuery.js b/src/RestQuery.js index 37cbd518d6..40f12eada5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -94,7 +94,9 @@ function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, cl var fields = restOptions.order.split(','); this.findOptions.sort = fields.reduce((sortMap, field) => { field = field.trim(); - if (field[0] == '-') { + if (field == '$textScore') { + sortMap['textScore'] = {'$meta': 'textScore'}; + } else if (field[0] == '-') { sortMap[field.slice(1)] = -1; } else { sortMap[field] = 1;