diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index ffe5b2e65d..99ddb24743 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -1,11 +1,11 @@ /** * Parse Server Configuration Builder - * + * * This module builds the definitions file (src/Options/Definitions.js) * from the src/Options/index.js options interfaces. * The Definitions.js module is responsible for the default values as well * as the mappings for the CLI. - * + * * To rebuild the definitions file, run * `$ node resources/buildConfigDefinitions.js` */ diff --git a/spec/MongoSchemaCollectionAdapter.spec.js b/spec/MongoSchemaCollectionAdapter.spec.js index ba22c1fab3..f5f75442f6 100644 --- a/spec/MongoSchemaCollectionAdapter.spec.js +++ b/spec/MongoSchemaCollectionAdapter.spec.js @@ -21,6 +21,9 @@ describe('MongoSchemaCollection', () => { "create":{"*":true}, "delete":{"*":true}, "addField":{"*":true}, + }, + "indexes": { + "name1":{"deviceToken":1} } }, "installationId":"string", @@ -66,7 +69,10 @@ describe('MongoSchemaCollection', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, - } + }, + indexes: { + name1: {deviceToken: 1} + }, }); done(); }); diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index 86a68b5694..deebaa3b39 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -474,7 +474,7 @@ describe('ParseLiveQueryServer', function() { // Trigger disconnect event parseWebSocket.emit('disconnect'); expect(spy).toHaveBeenCalled(); - // call for ws_connect, another for ws_disconnect + // call for ws_connect, another for ws_disconnect expect(spy.calls.count()).toBe(2); }); diff --git a/spec/ParseQuery.FullTextSearch.spec.js b/spec/ParseQuery.FullTextSearch.spec.js index acb67c73f2..2563781d6e 100644 --- a/spec/ParseQuery.FullTextSearch.spec.js +++ b/spec/ParseQuery.FullTextSearch.spec.js @@ -31,7 +31,8 @@ const fullTextHelper = () => { const request = { method: "POST", body: { - subject: subjects[i] + subject: subjects[i], + comment: subjects[i], }, path: "/1/classes/TestObject" }; @@ -280,42 +281,83 @@ describe('Parse.Query Full Text Search testing', () => { }); describe_only_db('mongo')('Parse.Query Full Text Search testing', () => { - it('fullTextSearch: $search, only one text index', (done) => { - return reconfigureServer({ - appId: 'test', - restAPIKey: 'test', - publicServerURL: 'http://localhost:8378/1', - databaseAdapter: new MongoStorageAdapter({ uri: mongoURI }) + it('fullTextSearch: does not create text index if compound index exist', (done) => { + fullTextHelper().then(() => { + return databaseAdapter.dropAllIndexes('TestObject'); }).then(() => { - return rp.post({ - url: 'http://localhost:8378/1/batch', - body: { - requests: [ - { - method: "POST", - body: { - subject: "coffee is java" - }, - path: "/1/classes/TestObject" - }, - { - method: "POST", - body: { - subject: "java is coffee" - }, - path: "/1/classes/TestObject" + 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' } - ] - }, - json: 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).toEqual(3); + return databaseAdapter.getIndexes('TestObject'); + }).then((indexes) => { + expect(indexes.length).toEqual(2); + rp.get({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }, (error, response, body) => { + 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 schema compound index exist', (done) => { + fullTextHelper().then(() => { + return databaseAdapter.dropAllIndexes('TestObject'); }).then(() => { - return databaseAdapter.createIndex('TestObject', {random: 'text'}); + return databaseAdapter.getIndexes('TestObject'); + }).then((indexes) => { + expect(indexes.length).toEqual(1); + return rp.put({ + url: 'http://localhost:8378/1/schemas/TestObject', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'X-Parse-Master-Key': 'test', + }, + body: { + indexes: { + text_test: { subject: 'text', comment: 'text'}, + }, + }, + }); }).then(() => { + return databaseAdapter.getIndexes('TestObject'); + }).then((indexes) => { + expect(indexes.length).toEqual(2); const where = { subject: { $text: { @@ -334,12 +376,26 @@ describe_only_db('mongo')('Parse.Query Full Text Search testing', () => { } }); }).then((resp) => { - fail(`Should not be more than one text index: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INTERNAL_SERVER_ERROR); - done(); - }); + expect(resp.results.length).toEqual(3); + return databaseAdapter.getIndexes('TestObject'); + }).then((indexes) => { + expect(indexes.length).toEqual(2); + rp.get({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }, (error, response, body) => { + expect(body.indexes._id_).toBeDefined(); + expect(body.indexes._id_._id).toEqual(1); + expect(body.indexes.text_test).toBeDefined(); + expect(body.indexes.text_test.subject).toEqual('text'); + expect(body.indexes.text_test.comment).toEqual('text'); + done(); + }); + }).catch(done.fail); }); it('fullTextSearch: $diacriticSensitive - false', (done) => { diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index eef17e9751..986740650b 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -274,7 +274,7 @@ describe('SchemaController', () => { fooSixteen: {type: 'String'}, fooEighteen: {type: 'String'}, fooNineteen: {type: 'String'}, - }, levelPermissions, config.database)) + }, levelPermissions, {}, config.database)) .then(actualSchema => { const expectedSchema = { className: 'NewClass', @@ -304,6 +304,9 @@ describe('SchemaController', () => { fooNineteen: {type: 'String'}, }, classLevelPermissions: { ...levelPermissions }, + indexes: { + _id_: { _id: 1 } + } }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 6219130500..8c7481d9bb 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -771,7 +771,7 @@ describe('schemas', () => { }); }); - it_exclude_dbs(['postgres'])('lets you delete multiple fields and add fields', done => { + it('lets you delete multiple fields and add fields', done => { var obj1 = hasAllPODobject(); obj1.save() .then(() => { @@ -1756,4 +1756,605 @@ describe('schemas', () => { done(); }); }); + + it('cannot create index if field does not exist', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { aString: 1}, + } + } + }, (error, response, body) => { + expect(body.code).toBe(Parse.Error.INVALID_QUERY); + expect(body.error).toBe('Field aString does not exist, cannot add index.'); + done(); + }); + }) + }); + + it('cannot create compound index if field does not exist', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'} + }, + indexes: { + name1: { aString: 1, bString: 1}, + } + } + }, (error, response, body) => { + expect(body.code).toBe(Parse.Error.INVALID_QUERY); + expect(body.error).toBe('Field bString does not exist, cannot add index.'); + done(); + }); + }) + }); + + it('allows add index when you create a class', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: "NewClass", + fields: { + aString: {type: 'String'} + }, + indexes: { + name1: { aString: 1}, + }, + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'} + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + name1: { aString: 1}, + }, + }); + config.database.adapter.getIndexes('NewClass').then((indexes) => { + expect(indexes.length).toBe(2); + done(); + }); + }); + }); + + it('empty index returns nothing', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: "NewClass", + fields: { + aString: {type: 'String'} + }, + indexes: {}, + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'} + }, + classLevelPermissions: defaultClassLevelPermissions, + }); + done(); + }); + }); + + it('lets you add indexes', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'} + }, + indexes: { + name1: { aString: 1}, + }, + } + }, (error, response, body) => { + expect(dd(body, { + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'} + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + } + })).toEqual(undefined); + request.get({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'} + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + } + }); + config.database.adapter.getIndexes('NewClass').then((indexes) => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }) + }); + + it('lets you add multiple indexes', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + dString: {type: 'String'}, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + } + } + }, (error, response, body) => { + expect(dd(body, { + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + dString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + } + })).toEqual(undefined); + request.get({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + dString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then((indexes) => { + expect(indexes.length).toEqual(4); + done(); + }); + }); + }); + }) + }); + + it('lets you delete indexes', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'}, + }, + indexes: { + name1: { aString: 1 }, + } + } + }, (error, response, body) => { + expect(dd(body, { + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + } + })).toEqual(undefined); + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' } + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + } + }); + config.database.adapter.getIndexes('NewClass').then((indexes) => { + expect(indexes.length).toEqual(1); + done(); + }); + }); + }); + }) + }); + + it('lets you delete multiple indexes', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + } + } + }, (error, response, body) => { + expect(dd(body, { + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + } + })).toEqual(undefined); + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + name2: { __op: 'Delete' }, + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + } + }); + config.database.adapter.getIndexes('NewClass').then((indexes) => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }) + }); + + it('lets you add and delete indexes', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + dString: {type: 'String'}, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + } + } + }, (error, response, body) => { + expect(dd(body, { + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + dString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + } + })).toEqual(undefined); + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + name2: { __op: 'Delete' }, + name4: { dString: 1 }, + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + aString: {type: 'String'}, + bString: {type: 'String'}, + cString: {type: 'String'}, + dString: {type: 'String'}, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + name4: { dString: 1 }, + } + }); + config.database.adapter.getIndexes('NewClass').then((indexes) => { + expect(indexes.length).toEqual(3); + done(); + }); + }); + }); + }) + }); + + it('cannot delete index that does not exist', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + unknownIndex: { __op: 'Delete' } + } + } + }, (error, response, body) => { + expect(body.code).toBe(Parse.Error.INVALID_QUERY); + expect(body.error).toBe('Index unknownIndex does not exist, cannot delete.'); + done(); + }); + }) + }); + + it('cannot update index that exist', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'}, + }, + indexes: { + name1: { aString: 1 } + } + } + }, () => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { field2: 1 } + } + } + }, (error, response, body) => { + expect(body.code).toBe(Parse.Error.INVALID_QUERY); + expect(body.error).toBe('Index name1 exists, cannot update.'); + done(); + }); + }); + }) + }); + + it_exclude_dbs(['postgres'])('get indexes on startup', (done) => { + const obj = new Parse.Object('TestObject'); + obj.save().then(() => { + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + }).then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(body.indexes._id_).toBeDefined(); + done(); + }); + }); + }); + + it_exclude_dbs(['postgres'])('get compound indexes on startup', (done) => { + const obj = new Parse.Object('TestObject'); + obj.set('subject', 'subject'); + obj.set('comment', 'comment'); + obj.save().then(() => { + return config.database.adapter.createIndex('TestObject', {subject: 'text', comment: 'text'}); + }).then(() => { + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + }).then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + 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(); + }); + }); + }); }); diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index 051bac65cf..7de184250d 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -63,13 +63,20 @@ const defaultCLPS = Object.freeze({ function mongoSchemaToParseSchema(mongoSchema) { let clps = defaultCLPS; - if (mongoSchema._metadata && mongoSchema._metadata.class_permissions) { - clps = {...emptyCLPS, ...mongoSchema._metadata.class_permissions}; + let indexes = {} + if (mongoSchema._metadata) { + if (mongoSchema._metadata.class_permissions) { + clps = {...emptyCLPS, ...mongoSchema._metadata.class_permissions}; + } + if (mongoSchema._metadata.indexes) { + indexes = {...mongoSchema._metadata.indexes}; + } } return { className: mongoSchema._id, fields: mongoSchemaFieldsToParseSchemaFields(mongoSchema), classLevelPermissions: clps, + indexes: indexes, }; } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index c3efb7e642..72d72a5391 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -53,7 +53,7 @@ const convertParseSchemaToMongoSchema = ({...schema}) => { // Returns { code, error } if invalid, or { result }, an object // suitable for inserting into _SCHEMA collection, otherwise. -const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPermissions) => { +const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPermissions, indexes) => { const mongoObject = { _id: className, objectId: 'string', @@ -74,6 +74,11 @@ const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPe } } + if (indexes && typeof indexes === 'object' && Object.keys(indexes).length > 0) { + mongoObject._metadata = mongoObject._metadata || {}; + mongoObject._metadata.indexes = indexes; + } + return mongoObject; } @@ -165,11 +170,81 @@ export class MongoStorageAdapter { })); } + setIndexesWithSchemaFormat(className, submittedIndexes, existingIndexes = {}, fields) { + if (submittedIndexes === undefined) { + return Promise.resolve(); + } + if (Object.keys(existingIndexes).length === 0) { + existingIndexes = { _id_: { _id: 1} }; + } + const deletePromises = []; + const insertedIndexes = []; + Object.keys(submittedIndexes).forEach(name => { + const field = submittedIndexes[name]; + if (existingIndexes[name] && field.__op !== 'Delete') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`); + } + if (!existingIndexes[name] && field.__op === 'Delete') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} does not exist, cannot delete.`); + } + if (field.__op === 'Delete') { + const promise = this.dropIndex(className, name); + deletePromises.push(promise); + delete existingIndexes[name]; + } else { + Object.keys(field).forEach(key => { + if (!fields.hasOwnProperty(key)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Field ${key} does not exist, cannot add index.`); + } + }); + existingIndexes[name] = field; + insertedIndexes.push({ + key: field, + name, + }); + } + }); + let insertPromise = Promise.resolve(); + if (insertedIndexes.length > 0) { + insertPromise = this.createIndexes(className, insertedIndexes); + } + return Promise.all(deletePromises) + .then(() => insertPromise) + .then(() => this._schemaCollection()) + .then(schemaCollection => schemaCollection.updateSchema(className, { + $set: { _metadata: { indexes: existingIndexes } } + })); + } + + setIndexesFromMongo(className) { + return this.getIndexes(className).then((indexes) => { + indexes = indexes.reduce((obj, index) => { + if (index.key._fts) { + delete index.key._fts; + delete index.key._ftsx; + for (const field in index.weights) { + index.key[field] = 'text'; + } + } + obj[index.name] = index.key; + return obj; + }, {}); + return this._schemaCollection() + .then(schemaCollection => schemaCollection.updateSchema(className, { + $set: { _metadata: { indexes: indexes } } + })); + }).catch(() => { + // Ignore if collection not found + return Promise.resolve(); + }); + } + createClass(className, schema) { schema = convertParseSchemaToMongoSchema(schema); - const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(schema.fields, className, schema.classLevelPermissions); + const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(schema.fields, className, schema.classLevelPermissions, schema.indexes); mongoObject._id = className; - return this._schemaCollection() + return this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields) + .then(() => this._schemaCollection()) .then(schemaCollection => schemaCollection._collection.insertOne(mongoObject)) .then(result => MongoSchemaCollection._TESTmongoSchemaToParseSchema(result.ops[0])) .catch(error => { @@ -353,7 +428,7 @@ export class MongoStorageAdapter { }, {}); readPreference = this._parseReadPreference(readPreference); - return this.createTextIndexesIfNeeded(className, query) + return this.createTextIndexesIfNeeded(className, query, schema) .then(() => this._adaptiveCollection(className)) .then(collection => collection.find(mongoWhere, { skip, @@ -461,6 +536,11 @@ export class MongoStorageAdapter { .then(collection => collection._mongoCollection.createIndex(index)); } + createIndexes(className, indexes) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.createIndexes(indexes)); + } + createIndexesIfNeeded(className, fieldName, type) { if (type && type.type === 'Polygon') { const index = { @@ -471,20 +551,26 @@ export class MongoStorageAdapter { return Promise.resolve(); } - createTextIndexesIfNeeded(className, query) { + createTextIndexesIfNeeded(className, query, schema) { for(const fieldName in query) { if (!query[fieldName] || !query[fieldName].$text) { continue; } - const index = { - [fieldName]: 'text' + const existingIndexes = schema.indexes; + for (const key in existingIndexes) { + const index = existingIndexes[key]; + if (index.hasOwnProperty(fieldName)) { + return Promise.resolve(); + } + } + const indexName = `${fieldName}_text`; + const textIndex = { + [indexName]: { [fieldName]: 'text' } }; - return this.createIndex(className, index) + return this.setIndexesWithSchemaFormat(className, textIndex, existingIndexes, schema.fields) .catch((error) => { - if (error.code === 85) { - throw new Parse.Error( - Parse.Error.INTERNAL_SERVER_ERROR, - 'Only one text index is supported, please delete all text indexes to use new field.'); + if (error.code === 85) { // Index exist with different options + return this.setIndexesFromMongo(className); } throw error; }); @@ -496,6 +582,26 @@ export class MongoStorageAdapter { return this._adaptiveCollection(className) .then(collection => collection._mongoCollection.indexes()); } + + dropIndex(className, index) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.dropIndex(index)); + } + + dropAllIndexes(className) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.dropIndexes()); + } + + updateSchemaWithIndexes() { + return this.getAllClasses() + .then((classes) => { + const promises = classes.map((schema) => { + return this.setIndexesFromMongo(schema.className); + }); + return Promise.all(promises); + }); + } } export default MongoStorageAdapter; diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 4560ab9b2e..971b19ac7e 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -98,10 +98,15 @@ const toParseSchema = (schema) => { if (schema.classLevelPermissions) { clps = {...emptyCLPS, ...schema.classLevelPermissions}; } + let indexes = {}; + if (schema.indexes) { + indexes = {...schema.indexes}; + } return { className: schema.className, fields: schema.fields, classLevelPermissions: clps, + indexes, }; } @@ -601,12 +606,64 @@ export class PostgresStorageAdapter { }); } + setIndexesWithSchemaFormat(className, submittedIndexes, existingIndexes = {}, fields, conn) { + conn = conn || this._client; + if (submittedIndexes === undefined) { + return Promise.resolve(); + } + if (Object.keys(existingIndexes).length === 0) { + existingIndexes = { _id_: { _id: 1} }; + } + const deletedIndexes = []; + const insertedIndexes = []; + Object.keys(submittedIndexes).forEach(name => { + const field = submittedIndexes[name]; + if (existingIndexes[name] && field.__op !== 'Delete') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`); + } + if (!existingIndexes[name] && field.__op === 'Delete') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} does not exist, cannot delete.`); + } + if (field.__op === 'Delete') { + deletedIndexes.push(name); + delete existingIndexes[name]; + } else { + Object.keys(field).forEach(key => { + if (!fields.hasOwnProperty(key)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Field ${key} does not exist, cannot add index.`); + } + }); + existingIndexes[name] = field; + insertedIndexes.push({ + key: field, + name, + }); + } + }); + let insertPromise = Promise.resolve(); + if (insertedIndexes.length > 0) { + insertPromise = this.createIndexes(className, insertedIndexes, conn); + } + let deletePromise = Promise.resolve(); + if (deletedIndexes.length > 0) { + deletePromise = this.dropIndexes(className, deletedIndexes, conn); + } + return deletePromise + .then(() => insertPromise) + .then(() => this._ensureSchemaCollectionExists()) + .then(() => { + const values = [className, 'schema', 'indexes', JSON.stringify(existingIndexes)] + return conn.none(`UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className"=$1 `, values); + }); + } + createClass(className, schema) { return this._client.tx(t => { const q1 = this.createTable(className, schema, t); const q2 = t.none('INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', { className, schema }); + const q3 = this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields, t); - return t.batch([q1, q2]); + return t.batch([q1, q2, q3]); }) .then(() => { return toParseSchema(schema) @@ -1535,6 +1592,25 @@ export class PostgresStorageAdapter { console.error(error); }); } + + createIndexes(className, indexes, conn) { + return (conn || this._client).tx(t => t.batch(indexes.map(i => { + return t.none('CREATE INDEX $1:name ON $2:name ($3:name)', [i.name, className, i.key]); + }))); + } + + dropIndexes(className, indexes, conn) { + return (conn || this._client).tx(t => t.batch(indexes.map(i => t.none('DROP INDEX $1:name', i)))); + } + + getIndexes(className) { + const qs = 'SELECT * FROM pg_indexes WHERE tablename = ${className}'; + return this._client.any(qs, {className}); + } + + updateSchemaWithIndexes() { + return Promise.resolve(); + } } function convertPolygonToSQL(polygon) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index f49cd2c360..d211e30afe 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1029,9 +1029,11 @@ DatabaseController.prototype.performInitialization = function() { throw error; }); + const indexPromise = this.adapter.updateSchemaWithIndexes(); + // Create tables for volatile classes const adapterInit = this.adapter.performInitialization({ VolatileClassesSchemas: SchemaController.VolatileClassesSchemas }); - return Promise.all([usernameUniqueness, emailUniqueness, roleUniqueness, adapterInit]); + return Promise.all([usernameUniqueness, emailUniqueness, roleUniqueness, adapterInit, indexPromise]); } function joinTableName(className, key) { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 88de10d4c3..ab579b6aa2 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -287,18 +287,28 @@ const convertAdapterSchemaToParseSchema = ({...schema}) => { schema.fields.password = { type: 'String' }; } + if (schema.indexes && Object.keys(schema.indexes).length === 0) { + delete schema.indexes; + } + return schema; } -const injectDefaultSchema = ({className, fields, classLevelPermissions}) => ({ - className, - fields: { - ...defaultColumns._Default, - ...(defaultColumns[className] || {}), - ...fields, - }, - classLevelPermissions, -}); +const injectDefaultSchema = ({className, fields, classLevelPermissions, indexes}) => { + const defaultSchema = { + className, + fields: { + ...defaultColumns._Default, + ...(defaultColumns[className] || {}), + ...fields, + }, + classLevelPermissions, + }; + if (indexes && Object.keys(indexes).length !== 0) { + defaultSchema.indexes = indexes; + } + return defaultSchema; +}; const _HooksSchema = {className: "_Hooks", fields: defaultColumns._Hooks}; const _GlobalConfigSchema = { className: "_GlobalConfig", fields: defaultColumns._GlobalConfig } @@ -344,6 +354,7 @@ export default class SchemaController { _dbAdapter; data; perms; + indexes; constructor(databaseAdapter, schemaCache) { this._dbAdapter = databaseAdapter; @@ -352,6 +363,8 @@ export default class SchemaController { this.data = {}; // this.perms[className][operation] tells you the acl-style permissions this.perms = {}; + // this.indexes[className][operation] tells you the indexes + this.indexes = {}; } reloadData(options = {clearCache: false}) { @@ -370,9 +383,11 @@ export default class SchemaController { .then(allSchemas => { const data = {}; const perms = {}; + const indexes = {}; allSchemas.forEach(schema => { data[schema.className] = injectDefaultSchema(schema).fields; perms[schema.className] = schema.classLevelPermissions; + indexes[schema.className] = schema.indexes; }); // Inject the in-memory classes @@ -380,13 +395,16 @@ export default class SchemaController { const schema = injectDefaultSchema({ className }); data[className] = schema.fields; perms[className] = schema.classLevelPermissions; + indexes[className] = schema.indexes; }); this.data = data; this.perms = perms; + this.indexes = indexes; delete this.reloadDataPromise; }, (err) => { this.data = {}; this.perms = {}; + this.indexes = {}; delete this.reloadDataPromise; throw err; }); @@ -424,7 +442,8 @@ export default class SchemaController { return Promise.resolve({ className, fields: this.data[className], - classLevelPermissions: this.perms[className] + classLevelPermissions: this.perms[className], + indexes: this.indexes[className] }); } return this._cache.getOneSchema(className).then((cached) => { @@ -449,13 +468,13 @@ export default class SchemaController { // on success, and rejects with an error on fail. Ensure you // have authorization (master key, or client class creation // enabled) before calling this function. - addClassIfNotExists(className, fields = {}, classLevelPermissions) { + addClassIfNotExists(className, fields = {}, classLevelPermissions, indexes = {}) { var validationError = this.validateNewClass(className, fields, classLevelPermissions); if (validationError) { return Promise.reject(validationError); } - return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className })) + return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, indexes, className })) .then(convertAdapterSchemaToParseSchema) .then((res) => { return this._cache.clear().then(() => { @@ -471,7 +490,7 @@ export default class SchemaController { }); } - updateClass(className, submittedFields, classLevelPermissions, database) { + updateClass(className, submittedFields, classLevelPermissions, indexes, database) { return this.getOneSchema(className) .then(schema => { const existingFields = schema.fields; @@ -509,7 +528,6 @@ export default class SchemaController { if (deletedFields.length > 0) { deletePromise = this.deleteFields(deletedFields, className, database); } - return deletePromise // Delete Everything .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values .then(() => { @@ -520,12 +538,20 @@ export default class SchemaController { return Promise.all(promises); }) .then(() => this.setPermissions(className, classLevelPermissions, newSchema)) + .then(() => this._dbAdapter.setIndexesWithSchemaFormat(className, indexes, schema.indexes, newSchema)) + .then(() => this.reloadData({ clearCache: true })) //TODO: Move this logic into the database adapter - .then(() => ({ - className: className, - fields: this.data[className], - classLevelPermissions: this.perms[className] - })); + .then(() => { + const reloadedSchema = { + className: className, + fields: this.data[className], + classLevelPermissions: this.perms[className], + }; + if (this.indexes[className] && Object.keys(this.indexes[className]).length !== 0) { + reloadedSchema.indexes = this.indexes[className]; + } + return reloadedSchema; + }); }) .catch(error => { if (error === undefined) { @@ -620,8 +646,7 @@ export default class SchemaController { return Promise.resolve(); } validateCLP(perms, newSchema); - return this._dbAdapter.setClassLevelPermissions(className, perms) - .then(() => this.reloadData({ clearCache: true })); + return this._dbAdapter.setClassLevelPermissions(className, perms); } // Returns a promise that resolves successfully to the new schema diff --git a/src/Options/index.js b/src/Options/index.js index d21e715270..5d5f4ecc8b 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -116,7 +116,7 @@ export interface ParseServerOptions { enableSingleSchemaCache: ?boolean; // = false /* Sets the number of characters in generated object id's, default 10 */ objectIdSize: ?number; // = 10 - /* The port to run the ParseServer. defaults to 1337. + /* The port to run the ParseServer. defaults to 1337. :ENV: PORT */ port: ?number; // = 1337 /* The host to serve ParseServer on. defaults to 0.0.0.0 */ diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 0e572c88a5..f21c14c217 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -49,7 +49,7 @@ function createSchema(req) { } return req.config.database.loadSchema({ clearCache: true}) - .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) + .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions, req.body.indexes)) .then(schema => ({ response: schema })); } @@ -65,7 +65,7 @@ function modifySchema(req) { const className = req.params.className; return req.config.database.loadSchema({ clearCache: true}) - .then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.config.database)) + .then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.body.indexes, req.config.database)) .then(result => ({response: result})); }