Skip to content

Full Text Search Support, with textScore #3905

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

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 235 additions & 1 deletion spec/ParseQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
});
});
});
4 changes: 4 additions & 0 deletions src/Adapters/Storage/Mongo/MongoCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/Adapters/Storage/Mongo/MongoStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 56 additions & 1 deletion src/Adapters/Storage/Mongo/MongoTransform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -1026,4 +1081,4 @@ module.exports = {
transformUpdate,
transformWhere,
mongoObjectToParseObject,
};
};
4 changes: 2 additions & 2 deletions src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -989,4 +989,4 @@ function joinTableName(className, key) {

// Expose validateQuery for tests
DatabaseController._validateQuery = validateQuery;
module.exports = DatabaseController;
module.exports = DatabaseController;
4 changes: 3 additions & 1 deletion src/RestQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down