diff --git a/.travis.yml b/.travis.yml index 2b5c1e1ef2..bdf037e583 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,13 +2,24 @@ language: node_js node_js: - '4.3' - '6.1' +services: + - postgresql +addons: + postgresql: '9.4' +before_script: + - psql -c 'create database parse_server_postgres_adapter_test_database;' -U postgres env: global: - COVERAGE_OPTION='./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**' matrix: + - PARSE_SERVER_TEST_DB=postgres - MONGODB_VERSION=2.6.11 - MONGODB_VERSION=3.0.8 - MONGODB_VERSION=3.2.6 +matrix: + fast_finish: true, + allow_failures: + - env: PARSE_SERVER_TEST_DB=postgres branches: only: - master diff --git a/package.json b/package.json index 8fb67faf8b..44130aa77e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "parse-server-push-adapter": "^1.0.0", "parse-server-s3-adapter": "^1.0.1", "parse-server-simple-mailgun-adapter": "^1.0.0", + "pg-promise": "^4.4.0", "redis": "^2.5.0-1", "request": "^2.65.0", "request-promise": "^3.0.0", diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 069e0166bf..2ead5c1ebc 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -9,7 +9,7 @@ const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDataba describe('MongoStorageAdapter', () => { beforeEach(done => { new MongoStorageAdapter({ uri: databaseURI }) - .deleteAllSchemas() + .deleteAllClasses() .then(done, fail); }); @@ -49,7 +49,7 @@ describe('MongoStorageAdapter', () => { it('stores objectId in _id', done => { let adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('Foo', { objectId: 'abcde' }, { fields: { objectId: 'String' } }) + adapter.createObject('Foo', {}, { objectId: 'abcde' }) .then(() => adapter._rawFind('Foo', {})) .then(results => { expect(results.length).toEqual(1); @@ -70,10 +70,10 @@ describe('MongoStorageAdapter', () => { } }; let adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('APointerDarkly', obj, { fields: { + adapter.createObject('APointerDarkly', { fields: { objectId: { type: 'String' }, aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, - }}) + }}, obj) .then(() => adapter._rawFind('APointerDarkly', {})) .then(results => { expect(results.length).toEqual(1); @@ -90,7 +90,7 @@ describe('MongoStorageAdapter', () => { let adapter = new MongoStorageAdapter({ uri: databaseURI }); let schema = { fields : { subdoc: { type: 'Object' } } }; let obj = { subdoc: {foo: 'bar', wu: 'tan'} }; - adapter.createObject('MyClass', obj, schema) + adapter.createObject('MyClass', schema, obj) .then(() => adapter._rawFind('MyClass', {})) .then(results => { expect(results.length).toEqual(1); @@ -99,7 +99,7 @@ describe('MongoStorageAdapter', () => { expect(mob.subdoc.foo).toBe('bar'); expect(mob.subdoc.wu).toBe('tan'); let obj = { 'subdoc.wu': 'clan' }; - return adapter.findOneAndUpdate('MyClass', {}, schema, obj); + return adapter.findOneAndUpdate('MyClass', schema, {}, obj); }) .then(() => adapter._rawFind('MyClass', {})) .then(results => { @@ -127,7 +127,7 @@ describe('MongoStorageAdapter', () => { object: { type: 'Object' }, date: { type: 'Date' }, } }; - adapter.createObject('MyClass', obj, schema) + adapter.createObject('MyClass', schema, obj) .then(() => adapter._rawFind('MyClass', {})) .then(results => { expect(results.length).toEqual(1); @@ -135,7 +135,7 @@ describe('MongoStorageAdapter', () => { expect(mob.array instanceof Array).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date instanceof Date).toBe(true); - return adapter.find('MyClass', {}, schema, {}); + return adapter.find('MyClass', schema, {}, {}); }) .then(results => { expect(results.length).toEqual(1); diff --git a/spec/OAuth.spec.js b/spec/OAuth.spec.js index ee505151ab..a8da7ed08b 100644 --- a/spec/OAuth.spec.js +++ b/spec/OAuth.spec.js @@ -284,9 +284,9 @@ describe('OAuth', function() { "Expiration should be cleared"); // make sure the auth data is properly deleted var config = new Config(Parse.applicationId); - config.database.adapter.find('_User', { objectId: model.id }, { + config.database.adapter.find('_User', { fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), - }, {}) + }, { objectId: model.id }, {}) .then(res => { expect(res.length).toBe(1); expect(res[0]._auth_data_myoauth).toBeUndefined(); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 6696568110..a1fb544024 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -20,7 +20,10 @@ describe('miscellaneous', function() { expect(typeof obj.id).toBe('string'); expect(typeof obj.createdAt.toGMTString()).toBe('string'); done(); - }, function(err) { console.log(err); }); + }, error => { + fail(JSON.stringify(error)); + done(); + }); }); it('get a TestObject', function(done) { @@ -122,81 +125,63 @@ describe('miscellaneous', function() { }); it('ensure that if people already have duplicate users, they can still sign up new users', done => { - reconfigureServer({}) + let config = new Config('test'); // Remove existing data to clear out unique index - .then(TestUtils.destroyAllDataPermanently) + TestUtils.destroyAllDataPermanently() + .then(() => config.database.adapter.createObject('_User', requiredUserFields, { objectId: 'x', username: 'u' })) + .then(() => config.database.adapter.createObject('_User', requiredUserFields, { objectId: 'y', username: 'u' })) + // Create a new server to try to recreate the unique indexes + .then(reconfigureServer) + .catch(() => { + let user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + // Sign up with new email still works + return user.signUp().catch(fail); + }) .then(() => { - let adapter = new MongoStorageAdapter({ - uri: 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', - collectionPrefix: 'test_', - }); - adapter.createObject('_User', { objectId: 'x', username: 'u' }, requiredUserFields) - .then(() => adapter.createObject('_User', { objectId: 'y', username: 'u' }, requiredUserFields)) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('zxcv'); - return user.signUp(); - }) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('u'); - user.signUp() - .catch(error => { - expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); - done(); - }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); - }, () => { - fail('destroyAllDataPermanently failed') + let user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('u'); + // sign up with duplicate username doens't + return user.signUp() + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); done(); - }); + }) }); it('ensure that if people already have duplicate emails, they can still sign up new users', done => { - reconfigureServer({}) - // Wipe out existing database with unique index so we can create a duplicate user - .then(TestUtils.destroyAllDataPermanently) + let config = new Config('test'); + // Remove existing data to clear out unique index + TestUtils.destroyAllDataPermanently() + .then(() => config.database.adapter.createObject('_User', requiredUserFields, { objectId: 'x', email: 'a@b.c' })) + .then(() => config.database.adapter.createObject('_User', requiredUserFields, { objectId: 'y', email: 'a@b.c' })) + .then(reconfigureServer) + .catch(() => { + let user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('qqq'); + user.setEmail('unique@unique.unique'); + return user.signUp().catch(fail); + }) .then(() => { - let adapter = new MongoStorageAdapter({ - uri: 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', - collectionPrefix: 'test_', - }); - adapter.createObject('_User', { objectId: 'x', email: 'a@b.c' }, requiredUserFields) - .then(() => adapter.createObject('_User', { objectId: 'y', email: 'a@b.c' }, requiredUserFields)) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('qqq'); - user.setEmail('unique@unique.unique'); - return user.signUp(); - }) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('www'); - user.setEmail('a@b.c'); - user.signUp() - .catch(error => { - expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); - done(); - }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); + let user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('www'); + user.setEmail('a@b.c'); + return user.signUp() + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); + done(); }); }); it('ensure that if you try to sign up a user with a unique username and email, but duplicates in some other field that has a uniqueness constraint, you get a regular duplicate value error', done => { let config = new Config('test'); - config.database.adapter.ensureUniqueness('_User', ['randomField'], requiredUserFields) + config.database.adapter.ensureUniqueness('_User', requiredUserFields, ['randomField']) .then(() => { let user = new Parse.User(); user.setPassword('asdf'); @@ -228,8 +213,7 @@ describe('miscellaneous', function() { expect(typeof user.id).toEqual('string'); expect(user.get('password')).toBeUndefined(); expect(user.getSessionToken()).not.toBeUndefined(); - Parse.User.logOut(); - done(); + Parse.User.logOut().then(done); }, error: function(error) { fail(error); } @@ -366,7 +350,7 @@ describe('miscellaneous', function() { return obj.save(); }).then(() => { let config = new Config(appId); - return config.database.adapter.find('TestObject', {}, { fields: {} }, {}); + return config.database.adapter.find('TestObject', { fields: {} }, {}, {}); }).then((results) => { expect(results.length).toEqual(1); expect(results[0]['foo']).toEqual('bar'); diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 534527a5bf..504e26990d 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -7,9 +7,12 @@ let Config = require('../src/Config'); describe('a GlobalConfig', () => { beforeEach(done => { let config = new Config('test'); - config.database.adapter.adaptiveCollection('_GlobalConfig') - .then(coll => coll.upsertOne({ '_id': 1 }, { $set: { params: { companies: ['US', 'DK'] } } })) - .then(() => { done(); }); + config.database.adapter.upsertOneObject( + '_GlobalConfig', + { fields: {} }, + { objectId: 1 }, + { params: { companies: ['US', 'DK'] } } + ).then(done); }); it('can be retrieved', (done) => { @@ -90,22 +93,23 @@ describe('a GlobalConfig', () => { it('failed getting config when it is missing', (done) => { let config = new Config('test'); - config.database.adapter.adaptiveCollection('_GlobalConfig') - .then(coll => coll.deleteOne({ '_id': 1 })) - .then(() => { - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body.params).toEqual({}); - done(); - }); + config.database.adapter.deleteObjectsByQuery( + '_GlobalConfig', + { fields: { params: { __type: 'String' } } }, + { objectId: 1 } + ).then(() => { + request.get({ + url : 'http://localhost:8378/1/config', + json : true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key' : 'test' + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(body.params).toEqual({}); + done(); }); + }); }); - }); diff --git a/spec/ParseHooks.spec.js b/spec/ParseHooks.spec.js index 1a1349dae2..75b5857ea7 100644 --- a/spec/ParseHooks.spec.js +++ b/spec/ParseHooks.spec.js @@ -10,11 +10,11 @@ Parse.Hooks = require("../src/cloud-code/Parse.Hooks"); var port = 12345; var hookServerURL = "http://localhost:"+port; +let AppCache = require('../src/cache').AppCache; var app = express(); app.use(bodyParser.json({ 'type': '*/*' })) app.listen(12345); -let AppCache = require('../src/cache').AppCache; describe('Hooks', () => { diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 2bc191dacf..3d1e45d67b 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -24,7 +24,7 @@ describe('Installations', () => { 'deviceType': device }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => config.database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -42,7 +42,7 @@ describe('Installations', () => { 'deviceType': device }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => config.database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -60,7 +60,7 @@ describe('Installations', () => { 'deviceType': device }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => config.database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -79,7 +79,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -102,7 +102,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -200,7 +200,7 @@ describe('Installations', () => { 'custom': 'allowed' }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -224,7 +224,7 @@ describe('Installations', () => { var firstObject; var secondObject; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); firstObject = results[0]; @@ -233,7 +233,7 @@ describe('Installations', () => { input['foo'] = 'bar'; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); secondObject = results[0]; @@ -263,13 +263,13 @@ describe('Installations', () => { var firstObject; var secondObject; rest.create(config, auth.nobody(config), '_Installation', input1) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); firstObject = results[0]; return rest.create(config, auth.nobody(config), '_Installation', input2); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(2); if (results[0]['_id'] == firstObject._id) { @@ -279,7 +279,7 @@ describe('Installations', () => { } return rest.create(config, auth.nobody(config), '_Installation', input3); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0]['_id']).toEqual(secondObject._id); @@ -325,7 +325,7 @@ describe('Installations', () => { channels: ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var id = results[0].objectId; @@ -334,7 +334,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', id, update); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].channels.length).toEqual(1); @@ -356,7 +356,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { 'installationId': installId2 }; @@ -379,7 +379,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { 'deviceToken': b }; @@ -403,7 +403,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -413,7 +413,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].deviceToken).toEqual(u); @@ -429,7 +429,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -453,7 +453,7 @@ describe('Installations', () => { 'channels': ['foo', 'bar'] }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -461,7 +461,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0]['custom']).toEqual('allowed'); @@ -488,11 +488,11 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', {installationId: installId1}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId1}, {})) .then(results => { firstObject = results[0]; expect(results.length).toEqual(1); - return database.adapter.find('_Installation', {installationId: installId2}, installationSchema, {}); + return database.adapter.find('_Installation', installationSchema, {installationId: installId2}, {}); }).then(results => { expect(results.length).toEqual(1); secondObject = results[0]; @@ -503,7 +503,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', secondObject.objectId, input); }) - .then(() => database.adapter.find('_Installation', {objectId: firstObject.objectId}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {objectId: firstObject.objectId}, {})) .then(results => { // The first object should have been deleted expect(results.length).toEqual(0); @@ -530,11 +530,11 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', {installationId: installId1}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId1}, {})) .then((results) => { expect(results.length).toEqual(1); firstObject = results[0]; - return database.adapter.find('_Installation', {installationId: installId2}, installationSchema, {}); + return database.adapter.find('_Installation', installationSchema, {installationId: installId2}, {}); }) .then(results => { expect(results.length).toEqual(1); @@ -546,7 +546,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', secondObject.objectId, input); }) - .then(() => database.adapter.find('_Installation', {objectId: firstObject.objectId}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {objectId: firstObject.objectId}, {})) .then(results => { // The first object should have been deleted expect(results.length).toEqual(0); @@ -570,7 +570,7 @@ describe('Installations', () => { input.appIdentifier = 'bar'; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { // The first object should have been deleted during merge expect(results.length).toEqual(1); @@ -587,7 +587,7 @@ describe('Installations', () => { 'deviceType': 'ios' }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -596,7 +596,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); @@ -621,7 +621,7 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', { deviceToken: t }, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -631,7 +631,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); @@ -656,7 +656,7 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', { deviceToken: t }, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -670,7 +670,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); @@ -691,7 +691,7 @@ describe('Installations', () => { var installObj; var tokenObj; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); installObj = results[0]; @@ -701,7 +701,7 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', { deviceToken: t }, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) .then(results => { expect(results.length).toEqual(1); tokenObj = results[0]; @@ -712,7 +712,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', installObj.objectId, input); }) - .then(() => database.adapter.find('_Installation', { objectId: tokenObj.objectId }, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, { objectId: tokenObj.objectId }, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); @@ -731,7 +731,7 @@ describe('Installations', () => { var installObj; var tokenObj; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); installObj = results[0]; @@ -741,7 +741,7 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', { deviceToken: t }, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) .then(results => { expect(results.length).toEqual(1); tokenObj = results[0]; @@ -756,7 +756,7 @@ describe('Installations', () => { }; return rest.update(config, auth.nobody(config), '_Installation', installObj.objectId, input); }) - .then(() => database.adapter.find('_Installation', { objectId: tokenObj.objectId }, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, { objectId: tokenObj.objectId }, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); @@ -784,7 +784,7 @@ describe('Installations', () => { 'deviceType': 'ios' }; rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); input = { @@ -794,7 +794,7 @@ describe('Installations', () => { }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', {}, installationSchema, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); expect(results[0].deviceToken).toEqual(t); diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index c187ceeea2..55822cb406 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -13,7 +13,7 @@ let database = config.database; describe('rest create', () => { it('handles _id', done => { rest.create(config, auth.nobody(config), 'Foo', {}) - .then(() => database.adapter.find('Foo', {}, { fields: {} }, {})) + .then(() => database.adapter.find('Foo', { fields: {} }, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; @@ -31,11 +31,11 @@ describe('rest create', () => { date: Parse._encode(now), }; rest.create(config, auth.nobody(config), 'MyClass', obj) - .then(() => database.adapter.find('MyClass', {}, { fields: { + .then(() => database.adapter.find('MyClass', { fields: { array: { type: 'Array' }, object: { type: 'Object' }, date: { type: 'Date' }, - } }, {})) + } }, {}, {})) .then(results => { expect(results.length).toEqual(1); var mob = results[0]; @@ -50,7 +50,7 @@ describe('rest create', () => { it('handles object and subdocument', done => { let obj = { subdoc: {foo: 'bar', wu: 'tan'} }; rest.create(config, auth.nobody(config), 'MyClass', obj) - .then(() => database.adapter.find('MyClass', {}, { fields: {} }, {})) + .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) .then(results => { expect(results.length).toEqual(1); let mob = results[0]; @@ -61,7 +61,7 @@ describe('rest create', () => { let obj = { 'subdoc.wu': 'clan' }; return rest.update(config, auth.nobody(config), 'MyClass', mob.objectId, obj) }) - .then(() => database.adapter.find('MyClass', {}, { fields: {} }, {})) + .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) .then(results => { expect(results.length).toEqual(1); let mob = results[0]; @@ -254,10 +254,10 @@ describe('rest create', () => { } }; rest.create(config, auth.nobody(config), 'APointerDarkly', obj) - .then(() => database.adapter.find('APointerDarkly', {}, { fields: { + .then(() => database.adapter.find('APointerDarkly', { fields: { foo: { type: 'String' }, aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, - }}, {})) + }}, {}, {})) .then(results => { expect(results.length).toEqual(1); let output = results[0]; diff --git a/spec/Uniqueness.spec.js b/spec/Uniqueness.spec.js index fe7e1b88b5..129ec07599 100644 --- a/spec/Uniqueness.spec.js +++ b/spec/Uniqueness.spec.js @@ -11,7 +11,7 @@ describe('Uniqueness', function() { obj.save().then(() => { expect(obj.id).not.toBeUndefined(); let config = new Config('test'); - return config.database.adapter.ensureUniqueness('UniqueField', ['unique'], { fields: { unique: { __type: 'String' } } }) + return config.database.adapter.ensureUniqueness('UniqueField', { fields: { unique: { __type: 'String' } } }, ['unique']) }) .then(() => { let obj = new Parse.Object('UniqueField'); @@ -32,10 +32,10 @@ describe('Uniqueness', function() { .then(() => obj.save({ ptr: obj })) .then(() => { let config = new Config('test'); - return config.database.adapter.ensureUniqueness('UniquePointer', ['ptr'], { fields: { + return config.database.adapter.ensureUniqueness('UniquePointer', { fields: { string: { __type: 'String' }, ptr: { __type: 'Pointer', targetClass: 'UniquePointer' } - } }); + } }, ['ptr']); }) .then(() => { let newObj = new Parse.Object('UniquePointer') @@ -60,7 +60,7 @@ describe('Uniqueness', function() { Parse.Object.saveAll([o1, o2]) .then(() => { let config = new Config('test'); - return config.database.adapter.ensureUniqueness('UniqueFail', ['key'], { fields: { key: { __type: 'String' } } }); + return config.database.adapter.ensureUniqueness('UniqueFail', { fields: { key: { __type: 'String' } } }, ['key']); }) .catch(error => { expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); @@ -70,7 +70,7 @@ describe('Uniqueness', function() { it('can do compound uniqueness', done => { let config = new Config('test'); - config.database.adapter.ensureUniqueness('CompoundUnique', ['k1', 'k2'], { fields: { k1: { __type: 'String' }, k2: { __type: 'String' } } }) + config.database.adapter.ensureUniqueness('CompoundUnique', { fields: { k1: { __type: 'String' }, k2: { __type: 'String' } } }, ['k1', 'k2']) .then(() => { let o1 = new Parse.Object('CompoundUnique'); o1.set('k1', 'v1'); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 782a3921dc..f529dcd0a2 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -265,6 +265,10 @@ describe("Email Verification", () => { expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') done(); }); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); }); }); @@ -561,9 +565,8 @@ describe("Password Reset", () => { Parse.User.logIn("zxcv", "hello").then(function(user){ let config = new Config('test'); - config.database.adapter.adaptiveCollection('_User') - .then(coll => coll.find({ 'username': 'zxcv' }, { limit: 1 })) - .then((results) => { + config.database.adapter.find('_User', { fields: {} }, { 'username': 'zxcv' }, { limit: 1 }) + .then(results => { // _perishable_token should be unset after reset password expect(results.length).toEqual(1); expect(results[0]['_perishable_token']).toEqual(undefined); diff --git a/spec/helper.js b/spec/helper.js index b5d000ef00..472eea983e 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -11,24 +11,35 @@ var ParseServer = require('../src/index').ParseServer; var path = require('path'); var TestUtils = require('../src/index').TestUtils; var MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); + const GridStoreAdapter = require('../src/Adapters/Files/GridStoreAdapter').GridStoreAdapter; +const PostgresStorageAdapter = require('../src/Adapters/Storage/Postgres/PostgresStorageAdapter'); + +const mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +let databaseAdapter; +if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { + var postgresURI = 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; + databaseAdapter = new PostgresStorageAdapter({ + uri: postgresURI, + collectionPrefix: 'test_', + }); +} else { + databaseAdapter = new MongoStorageAdapter({ + uri: mongoURI, + collectionPrefix: 'test_', + }) +} var port = 8378; -let mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; -let mongoAdapter = new MongoStorageAdapter({ - uri: mongoURI, - collectionPrefix: 'test_', -}) - let gridStoreAdapter = new GridStoreAdapter(mongoURI); // Default server configuration for tests. var defaultConfiguration = { - databaseAdapter: mongoAdapter, filesAdapter: gridStoreAdapter, serverURL: 'http://localhost:' + port + '/1', + databaseAdapter, appId: 'test', javascriptKey: 'test', dotNetKey: 'windows', @@ -132,7 +143,7 @@ beforeEach(done => { afterEach(function(done) { Parse.Cloud._removeAllHooks(); - mongoAdapter.getAllSchemas() + databaseAdapter.getAllClasses() .then(allSchemas => { allSchemas.forEach((schema) => { var className = schema.className; diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 5f25817dd6..ffecf3c265 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -327,9 +327,9 @@ describe('schemas', () => { }); it('responds with all fields when getting incomplete schema', done => { - config.database.schemaCollection().then((schema) => { - return schema.addSchema('_User'); - }).then(() => { + config.database.loadSchema() + .then(schemaController => schemaController.addClassIfNotExists('_User', {}, defaultClassLevelPermissions)) + .then(() => { request.get({ url: 'http://localhost:8378/1/schemas/_User', headers: masterKeyHeaders, diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index 8a0c2c12b6..0b7394ac34 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -1,6 +1,5 @@ import MongoCollection from './MongoCollection'; -import * as transform from './MongoTransform'; function mongoFieldToParseSchemaField(type) { if (type[0] === '*') { @@ -154,20 +153,12 @@ class MongoSchemaCollection { } // Atomically find and delete an object based on query. - // The result is the promise with an object that was in the database before deleting. - // Postgres Note: Translates directly to `DELETE * FROM ... RETURNING *`, which will return data after delete is done. findAndDeleteSchema(name: string) { - // arguments: query, sort - return this._collection._mongoCollection.findAndRemove(_mongoSchemaQueryFromNameQuery(name), []).then(document => { - // Value is the object where mongo returns multiple fields. - return document.value; - }); + return this._collection._mongoCollection.findAndRemove(_mongoSchemaQueryFromNameQuery(name), []); } - // Add a collection. Currently the input is in mongo format, but that will change to Parse format in a - // later PR. Returns a promise that is expected to resolve with the newly created schema, in Parse format. - // If the class already exists, returns a promise that rejects with undefined as the reason. If the collection - // can't be added for a reason other than it already existing, requirements for rejection reason are TBD. + // Returns a promise that is expected to resolve with the newly created schema, in Parse format. + // If the class already exists, returns a promise that rejects with DUPLICATE_VALUE as the reason. addSchema(name: string, fields, classLevelPermissions) { let mongoSchema = mongoSchemaFromFieldsAndClassNameAndCLP(fields, name, classLevelPermissions); let mongoObject = _mongoSchemaObjectFromNameFields(name, mongoSchema); @@ -175,9 +166,10 @@ class MongoSchemaCollection { .then(result => mongoSchemaToParseSchema(result.ops[0])) .catch(error => { if (error.code === 11000) { //Mongo's duplicate key error - throw undefined; + throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Class already exists.'); + } else { + throw error; } - throw error; }); } @@ -192,8 +184,8 @@ class MongoSchemaCollection { // Add a field to the schema. If database does not support the field // type (e.g. mongo doesn't support more than one GeoPoint in a class) reject with an "Incorrect Type" // Parse error with a desciptive message. If the field already exists, this function must - // not modify the schema, and must reject with an error. Exact error format is TBD. If this function - // is called for a class that doesn't exist, this function must create that class. + // not modify the schema, and must reject with DUPLICATE_VALUE error. + // If this is called for a class that doesn't exist, this function must create that class. // TODO: throw an error if an unsupported field type is passed. Deciding whether a type is supported // should be the job of the adapter. Some adapters may not support GeoPoint at all. Others may @@ -229,10 +221,6 @@ class MongoSchemaCollection { ); }); } - - get transform() { - return transform; - } } // Exported for testing reasons and because we haven't moved all mongo schema format diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 622baf6593..55710aaa1f 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -69,25 +69,19 @@ export class MongoStorageAdapter { return this.connectionPromise; } - collection(name: string) { - return this.connect().then(() => { - return this.database.collection(name); - }); - } - - adaptiveCollection(name: string) { + _adaptiveCollection(name: string) { return this.connect() .then(() => this.database.collection(this._collectionPrefix + name)) .then(rawCollection => new MongoCollection(rawCollection)); } - schemaCollection() { + _schemaCollection() { return this.connect() - .then(() => this.adaptiveCollection(MongoSchemaCollectionName)) + .then(() => this._adaptiveCollection(MongoSchemaCollectionName)) .then(collection => new MongoSchemaCollection(collection)); } - collectionExists(name: string) { + classExists(name) { return this.connect().then(() => { return this.database.listCollections({ name: this._collectionPrefix + name }).toArray(); }).then(collections => { @@ -95,22 +89,42 @@ export class MongoStorageAdapter { }); } - // Deletes a schema. Resolve if successful. If the schema doesn't - // exist, resolve with undefined. If schema exists, but can't be deleted for some other reason, - // reject with INTERNAL_SERVER_ERROR. - deleteOneSchema(className: string) { - return this.collection(this._collectionPrefix + className).then(collection => collection.drop()) + setClassLevelPermissions(className, CLPs) { + return this._schemaCollection() + .then(schemaCollection => schemaCollection.updateSchema(className, { + $set: { _metadata: { class_permissions: CLPs } } + })); + } + + createClass(className, schema) { + return this._schemaCollection() + .then(schemaCollection => schemaCollection.addSchema(className, schema.fields, schema.classLevelPermissions)); + } + + addFieldIfNotExists(className, fieldName, type) { + return this._schemaCollection() + .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)); + } + + // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) + // and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible. + deleteClass(className) { + return this._adaptiveCollection(className) + .then(collection => collection.drop()) .catch(error => { // 'ns not found' means collection was already gone. Ignore deletion attempt. if (error.message == 'ns not found') { return; } throw error; - }); + }) + // We've dropped the collection, now remove the _SCHEMA document + .then(() => this._schemaCollection()) + .then(schemaCollection => schemaCollection.findAndDeleteSchema(className)) } // Delete all data known to this adatper. Used for testing. - deleteAllSchemas() { + deleteAllClasses() { return storageAdapterAllCollections(this) .then(collections => Promise.all(collections.map(collection => collection.drop()))); } @@ -135,9 +149,14 @@ export class MongoStorageAdapter { // may do so. // Returns a Promise. - deleteFields(className: string, fieldNames, pointerFieldNames) { - const nonPointerFieldNames = _.difference(fieldNames, pointerFieldNames); - const mongoFormatNames = nonPointerFieldNames.concat(pointerFieldNames.map(name => `_p_${name}`)); + deleteFields(className, schema, fieldNames) { + const mongoFormatNames = fieldNames.map(fieldName => { + if (schema.fields[fieldName].type === 'Pointer') { + return `_p_${fieldName}` + } else { + return fieldName; + } + }); const collectionUpdate = { '$unset' : {} }; mongoFormatNames.forEach(name => { collectionUpdate['$unset'][name] = null; @@ -148,33 +167,33 @@ export class MongoStorageAdapter { schemaUpdate['$unset'][name] = null; }); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection.updateMany({}, collectionUpdate)) - .then(updateResult => this.schemaCollection()) + .then(() => this._schemaCollection()) .then(schemaCollection => schemaCollection.updateSchema(className, schemaUpdate)); } // Return a promise for all schemas known to this adapter, in Parse format. In case the // schemas cannot be retrieved, returns a promise that rejects. Requirements for the // rejection reason are TBD. - getAllSchemas() { - return this.schemaCollection().then(schemasCollection => schemasCollection._fetchAllSchemasFrom_SCHEMA()); + getAllClasses() { + return this._schemaCollection().then(schemasCollection => schemasCollection._fetchAllSchemasFrom_SCHEMA()); } // Return a promise for the schema with the given name, in Parse format. If // this adapter doesn't know about the schema, return a promise that rejects with // undefined as the reason. - getOneSchema(className) { - return this.schemaCollection() + getClass(className) { + return this._schemaCollection() .then(schemasCollection => schemasCollection._fechOneSchemaFrom_SCHEMA(className)); } // TODO: As yet not particularly well specified. Creates an object. Maybe shouldn't even need the schema, // and should infer from the type. Or maybe does need the schema for validations. Or maybe needs // the schem only for the legacy mongo format. We'll figure that out later. - createObject(className, object, schema) { + createObject(className, schema, object) { const mongoObject = parseObjectToMongoObjectForCreate(className, object, schema); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection.insertOne(mongoObject)) .catch(error => { if (error.code === 11000) { // Duplicate value @@ -188,8 +207,8 @@ export class MongoStorageAdapter { // Remove all objects that match the given Parse Query. // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. // If there is some other error, reject with INTERNAL_SERVER_ERROR. - deleteObjectsByQuery(className, query, schema) { - return this.adaptiveCollection(className) + deleteObjectsByQuery(className, schema, query) { + return this._adaptiveCollection(className) .then(collection => { let mongoWhere = transformWhere(className, query, schema); return collection.deleteMany(mongoWhere) @@ -205,36 +224,36 @@ export class MongoStorageAdapter { } // Apply the update to all objects that match the given Parse Query. - updateObjectsByQuery(className, query, schema, update) { + updateObjectsByQuery(className, schema, query, update) { const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection.updateMany(mongoWhere, mongoUpdate)); } // Atomically finds and updates an object based on query. // Resolve with the updated object. - findOneAndUpdate(className, query, schema, update) { + findOneAndUpdate(className, schema, query, update) { const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection._mongoCollection.findAndModify(mongoWhere, [], mongoUpdate, { new: true })) .then(result => result.value); } // Hopefully we can get rid of this. It's only used for config and hooks. - upsertOneObject(className, query, schema, update) { + upsertOneObject(className, schema, query, update) { const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection.upsertOne(mongoWhere, mongoUpdate)); } // Executes a find. Accepts: className, query in Parse format, and { skip, limit, sort }. - find(className, query, schema, { skip, limit, sort }) { + find(className, schema, query, { skip, limit, sort }) { let mongoWhere = transformWhere(className, query, schema); let mongoSort = _.mapKeys(sort, (value, fieldName) => transformKey(className, fieldName, schema)); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection.find(mongoWhere, { skip, limit, sort: mongoSort })) .then(objects => objects.map(object => mongoObjectToParseObject(className, object, schema))); } @@ -244,13 +263,13 @@ export class MongoStorageAdapter { // As such, we shouldn't expose this function to users of parse until we have an out-of-band // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, // which is why we use sparse indexes. - ensureUniqueness(className, fieldNames, schema) { + ensureUniqueness(className, schema, fieldNames) { let indexCreationRequest = {}; let mongoFieldNames = fieldNames.map(fieldName => transformKey(className, fieldName, schema)); mongoFieldNames.forEach(fieldName => { indexCreationRequest[fieldName] = 1; }); - return this.adaptiveCollection(className) + return this._adaptiveCollection(className) .then(collection => collection._ensureSparseUniqueIndexInBackground(indexCreationRequest)) .catch(error => { if (error.code === 11000) { @@ -263,12 +282,12 @@ export class MongoStorageAdapter { // Used in tests _rawFind(className, query) { - return this.adaptiveCollection(className).then(collection => collection.find(query)); + return this._adaptiveCollection(className).then(collection => collection.find(query)); } // Executs a count. - count(className, query, schema) { - return this.adaptiveCollection(className) + count(className, schema, query) { + return this._adaptiveCollection(className) .then(collection => collection.count(transformWhere(className, query, schema))); } } diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js new file mode 100644 index 0000000000..ce0f2dd583 --- /dev/null +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -0,0 +1,187 @@ +const pgp = require('pg-promise')(); + +const PostgresRelationDoesNotExistError = '42P01'; +const PostgresDuplicateRelationError = '42P07'; + + +export class PostgresStorageAdapter { + // Private + _collectionPrefix: string; + _client; + + constructor({ + uri, + collectionPrefix = '', + }) { + this._collectionPrefix = collectionPrefix; + this._client = pgp(uri); + } + + _ensureSchemaCollectionExists() { + return this._client.query('CREATE TABLE "_SCHEMA" ( "className" varChar(120), "schema" jsonb, "isParseClass" bool, PRIMARY KEY ("className") )') + .catch(error => { + if (error.code === PostgresDuplicateRelationError) { + // Table already exists, must have been created by a different request. Ignore error. + return; + } else { + throw error; + } + }); + }; + + classExists(name) { + return Promise.reject('Not implented yet.') + } + + setClassLevelPermissions(className, CLPs) { + return Promise.reject('Not implented yet.') + } + + createClass(className, schema) { + return this._client.query('CREATE TABLE $ ()', { className }) + .then(() => this._client.query('INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', { className, schema })) + } + + addFieldIfNotExists(className, fieldName, type) { + // TODO: Doing this in a transaction is probably a good idea. + return this._client.query('ALTER TABLE "GameScore" ADD COLUMN "score" double precision', { className, fieldName }) + .catch(error => { + if (error.code === PostgresRelationDoesNotExistError) { + return this.createClass(className, { fields: { [fieldName]: type } }) + } else { + throw error; + } + }) + .then(() => this._client.query('SELECT "schema" FROM "_SCHEMA"', { className })) + .then(result => { + if (fieldName in result[0].schema) { + throw "Attempted to add a field that already exists"; + } else { + result[0].schema.fields[fieldName] = type; + return this._client.query( + 'UPDATE "_SCHEMA" SET "schema"=$ WHERE "className"=$', + { schema: result[0].schema, className } + ); + } + }) + } + + // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) + // and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible. + deleteClass(className) { + return Promise.reject('Not implented yet.') + } + + // Delete all data known to this adatper. Used for testing. + deleteAllClasses() { + return this._client.query('SELECT "className" FROM "_SCHEMA"') + .then(results => { + const classes = ['_SCHEMA', ...results.map(result => result.className)]; + return Promise.all(classes.map(className => this._client.query('DROP TABLE $', { className }))); + }, error => { + if (error.code === PostgresRelationDoesNotExistError) { + // No _SCHEMA collection. Don't delete anything. + return; + } else { + throw error; + } + }) + } + + // Remove the column and all the data. For Relations, the _Join collection is handled + // specially, this function does not delete _Join columns. It should, however, indicate + // that the relation fields does not exist anymore. In mongo, this means removing it from + // the _SCHEMA collection. There should be no actual data in the collection under the same name + // as the relation column, so it's fine to attempt to delete it. If the fields listed to be + // deleted do not exist, this function should return successfully anyways. Checking for + // attempts to delete non-existent fields is the responsibility of Parse Server. + + // Pointer field names are passed for legacy reasons: the original mongo + // format stored pointer field names differently in the database, and therefore + // needed to know the type of the field before it could delete it. Future database + // adatpers should ignore the pointerFieldNames argument. All the field names are in + // fieldNames, they show up additionally in the pointerFieldNames database for use + // by the mongo adapter, which deals with the legacy mongo format. + + // This function is not obligated to delete fields atomically. It is given the field + // names in a list so that databases that are capable of deleting fields atomically + // may do so. + + // Returns a Promise. + deleteFields(className, schema, fieldNames) { + return Promise.reject('Not implented yet.') + } + + // Return a promise for all schemas known to this adapter, in Parse format. In case the + // schemas cannot be retrieved, returns a promise that rejects. Requirements for the + // rejection reason are TBD. + getAllClasses() { + return this._ensureSchemaCollectionExists() + .then(() => this._client.query('SELECT * FROM "_SCHEMA"')) + .then(results => results.map(result => ({ className: result.className, ...result.schema }))) + } + + // Return a promise for the schema with the given name, in Parse format. If + // this adapter doesn't know about the schema, return a promise that rejects with + // undefined as the reason. + getClass(className) { + return this._client.query('SELECT * FROM "_SCHEMA" WHERE "className"=$', { className }) + .then(result => { + if (result.length === 1) { + return result; + } else { + throw undefined; + } + }); + } + + // TODO: remove the mongo format dependency + createObject(className, schema, object) { + return this._client.query('INSERT INTO "GameScore" (score) VALUES ($)', { score: object.score }) + .then(() => ({ ops: [object] })); + } + + // Remove all objects that match the given Parse Query. + // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. + // If there is some other error, reject with INTERNAL_SERVER_ERROR. + deleteObjectsByQuery(className, schema, query) { + return Promise.reject('Not implented yet.') + } + + // Apply the update to all objects that match the given Parse Query. + updateObjectsByQuery(className, schema, query, update) { + return Promise.reject('Not implented yet.') + } + + // Hopefully we can get rid of this in favor of updateObjectsByQuery. + findOneAndUpdate(className, schema, query, update) { + return Promise.reject('Not implented yet.') + } + + // Hopefully we can get rid of this. It's only used for config and hooks. + upsertOneObject(className, schema, query, update) { + return Promise.reject('Not implented yet.') + } + + // Executes a find. Accepts: className, query in Parse format, and { skip, limit, sort }. + find(className, schema, query, { skip, limit, sort }) { + return this._client.query("SELECT * FROM $", { className }) + } + + // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't + // currently know which fields are nullable and which aren't, we ignore that criteria. + // As such, we shouldn't expose this function to users of parse until we have an out-of-band + // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, + // which is why we use sparse indexes. + ensureUniqueness(className, schema, fieldNames) { + return Promise.resolve('ensureUniqueness not implented yet.') + } + + // Executs a count. + count(className, schema, query) { + return Promise.reject('Not implented yet.') + } +} + +export default PostgresStorageAdapter; +module.exports = PostgresStorageAdapter; // Required for tests diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 8801d12ccb..a0b3e75212 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -84,15 +84,11 @@ function DatabaseController(adapter, { skipValidation } = {}) { } DatabaseController.prototype.WithoutValidation = function() { - return new DatabaseController(this.adapter, {collectionPrefix: this.collectionPrefix, skipValidation: true}); + return new DatabaseController(this.adapter, { skipValidation: true }); } -DatabaseController.prototype.schemaCollection = function() { - return this.adapter.schemaCollection(); -}; - DatabaseController.prototype.collectionExists = function(className) { - return this.adapter.collectionExists(className); + return this.adapter.classExists(className); }; DatabaseController.prototype.validateClassName = function(className) { @@ -105,16 +101,11 @@ DatabaseController.prototype.validateClassName = function(className) { return Promise.resolve(); }; -// Returns a promise for a schema object. -// If we are provided a acceptor, then we run it on the schema. -// If the schema isn't accepted, we reload it at most once. +// Returns a promise for a schemaController. DatabaseController.prototype.loadSchema = function() { - if (!this.schemaPromise) { - this.schemaPromise = this.schemaCollection().then(collection => { - delete this.schemaPromise; - return SchemaController.load(collection, this.adapter); - }); + this.schemaPromise = SchemaController.load(this.adapter); + this.schemaPromise.then(() => delete this.schemaPromise); } return this.schemaPromise; }; @@ -232,11 +223,11 @@ DatabaseController.prototype.update = function(className, query, update, { } update = transformObjectACL(update); if (many) { - return this.adapter.updateObjectsByQuery(className, query, schema, update); + return this.adapter.updateObjectsByQuery(className, schema, query, update); } else if (upsert) { - return this.adapter.upsertOneObject(className, query, schema, update); + return this.adapter.upsertOneObject(className, schema, query, update); } else { - return this.adapter.findOneAndUpdate(className, query, schema, update); + return this.adapter.findOneAndUpdate(className, schema, query, update); } }); }) @@ -324,7 +315,7 @@ DatabaseController.prototype.addRelation = function(key, fromClassName, fromId, relatedId: toId, owningId : fromId }; - return this.adapter.upsertOneObject(`_Join:${key}:${fromClassName}`, doc, relationSchema, doc); + return this.adapter.upsertOneObject(`_Join:${key}:${fromClassName}`, relationSchema, doc, doc); }; // Removes a relation. @@ -335,7 +326,7 @@ DatabaseController.prototype.removeRelation = function(key, fromClassName, fromI relatedId: toId, owningId: fromId }; - return this.adapter.deleteObjectsByQuery(`_Join:${key}:${fromClassName}`, doc, relationSchema) + return this.adapter.deleteObjectsByQuery(`_Join:${key}:${fromClassName}`, relationSchema, doc) .catch(error => { // We don't care if they try to delete a non-existent relation. if (error.code == Parse.Error.OBJECT_NOT_FOUND) { @@ -380,7 +371,7 @@ DatabaseController.prototype.destroy = function(className, query, { acl } = {}) } throw error; }) - .then(parseFormatSchema => this.adapter.deleteObjectsByQuery(className, query, parseFormatSchema)) + .then(parseFormatSchema => this.adapter.deleteObjectsByQuery(className, parseFormatSchema, query)) .catch(error => { // When deleting sessions while changing passwords, don't throw an error if they don't have any sessions. if (className === "_Session" && error.code === Parse.Error.OBJECT_NOT_FOUND) { @@ -409,7 +400,7 @@ DatabaseController.prototype.create = function(className, object, { acl } = {}) .then(() => this.handleRelationUpdates(className, null, object)) .then(() => schemaController.enforceClassExists(className)) .then(() => schemaController.getOneSchema(className, true)) - .then(schema => this.adapter.createObject(className, object, schema)) + .then(schema => this.adapter.createObject(className, schema, object)) .then(result => sanitizeDatabaseResult(originalObject, result.ops[0])); }) }; @@ -434,7 +425,7 @@ DatabaseController.prototype.canAddField = function(schema, className, object, a // Returns a promise. DatabaseController.prototype.deleteEverything = function() { this.schemaPromise = null; - return this.adapter.deleteAllSchemas(); + return this.adapter.deleteAllClasses(); }; // Finds the keys in a query. Returns a Set. REST format only @@ -454,14 +445,14 @@ function keysForQuery(query) { // Returns a promise for a list of related ids given an owning id. // className here is the owning className. DatabaseController.prototype.relatedIds = function(className, key, owningId) { - return this.adapter.find(joinTableName(className, key), { owningId }, relationSchema, {}) + return this.adapter.find(joinTableName(className, key), relationSchema, { owningId }, {}) .then(results => results.map(result => result.relatedId)); }; // Returns a promise for a list of owning ids given some related ids. // className here is the owning className. DatabaseController.prototype.owningIds = function(className, key, relatedIds) { - return this.adapter.find(joinTableName(className, key), { relatedId: { '$in': relatedIds } }, relationSchema, {}) + return this.adapter.find(joinTableName(className, key), relationSchema, { relatedId: { '$in': relatedIds } }, {}) .then(results => results.map(result => result.owningId)); }; @@ -689,9 +680,9 @@ DatabaseController.prototype.find = function(className, query, { } validateQuery(query); if (count) { - return this.adapter.count(className, query, schema); + return this.adapter.count(className, schema, query); } else { - return this.adapter.find(className, query, schema, { skip, limit, sort }) + return this.adapter.find(className, schema, query, { skip, limit, sort }) .then(objects => objects.map(object => { object = untransformObjectACL(object); return filterSensitiveData(isMaster, aclGroup, className, object) @@ -727,19 +718,33 @@ const untransformObjectACL = ({_rperm, _wperm, ...output}) => { } DatabaseController.prototype.deleteSchema = function(className) { - return this.collectionExists(className) - .then(exist => { - if (!exist) { - return Promise.resolve(); + return this.loadSchema() + .then(schemaController => schemaController.getOneSchema(className)) + .catch(error => { + if (error === undefined) { + return { fields: {} }; + } else { + throw error; } - return this.adapter.count(className) + }) + .then(schema => { + return this.collectionExists(className) + .then(exist => this.adapter.count(className)) .then(count => { if (count > 0) { throw new Parse.Error(255, `Class ${className} is not empty, contains ${count} objects, cannot drop schema.`); } - return this.adapter.deleteOneSchema(className); + return this.adapter.deleteClass(className); }) - }); + .then(wasParseCollection => { + if (wasParseCollection) { + const relationFieldNames = Object.keys(schema.fields).filter(fieldName => schema.fields[fieldName].type === 'Relation'); + return Promise.all(relationFieldNames.map(name => this.adapter.deleteClass(joinTableName(className, name)))); + } else { + return Promise.resolve(); + } + }); + }) } DatabaseController.prototype.addPointerPermissions = function(schema, className, operation, query, aclGroup = []) { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 49ec0dde9d..562e3c7649 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -233,13 +233,11 @@ const injectDefaultSchema = schema => ({ // Stores the entire schema of the app in a weird hybrid format somewhere between // the mongo format and the Parse format. Soon, this will all be Parse format. class SchemaController { - _collection; _dbAdapter; data; perms; - constructor(collection, databaseAdapter) { - this._collection = collection; + constructor(databaseAdapter) { this._dbAdapter = databaseAdapter; // this.data[className][fieldName] tells you the type of that field, in mongo format @@ -251,7 +249,7 @@ class SchemaController { reloadData() { this.data = {}; this.perms = {}; - return this.getAllSchemas() + return this.getAllClasses() .then(allSchemas => { allSchemas.forEach(schema => { this.data[schema.className] = schema.fields; @@ -269,8 +267,8 @@ class SchemaController { }); } - getAllSchemas() { - return this._dbAdapter.getAllSchemas() + getAllClasses() { + return this._dbAdapter.getAllClasses() .then(allSchemas => allSchemas.map(injectDefaultSchema)); } @@ -278,7 +276,7 @@ class SchemaController { if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) { return Promise.resolve(this.data[className]); } - return this._dbAdapter.getOneSchema(className) + return this._dbAdapter.getClass(className) .then(injectDefaultSchema); } @@ -295,12 +293,12 @@ class SchemaController { return Promise.reject(validationError); } - return this._collection.addSchema(className, fields, classLevelPermissions) + return this._dbAdapter.createClass(className, { fields, classLevelPermissions }) .catch(error => { - if (error === undefined) { + if (error && error.code === Parse.Error.DUPLICATE_VALUE) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } else { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error.'); + throw error; } }); } @@ -383,7 +381,7 @@ class SchemaController { 'schema is frozen, cannot add: ' + className); } // We don't have this class. Update the schema - return this.addClassIfNotExists(className, []).then(() => { + return this.addClassIfNotExists(className, {}).then(() => { // The schema update succeeded. Reload the schema return this.reloadData(); }, () => { @@ -452,16 +450,8 @@ class SchemaController { return Promise.resolve(); } validateCLP(perms, newSchema); - let update = { - _metadata: { - class_permissions: perms - } - }; - update = {'$set': update}; - return this._collection.updateSchema(className, update).then(() => { - // The update succeeded. Reload the schema - return this.reloadData(); - }); + return this._dbAdapter.setClassLevelPermissions(className, perms) + .then(() => this.reloadData()); } // Returns a promise that resolves successfully to the new schema @@ -511,7 +501,7 @@ class SchemaController { type = { type }; } - return this._collection.addFieldIfNotExists(className, fieldName, type).then(() => { + return this._dbAdapter.addFieldIfNotExists(className, fieldName, type).then(() => { // The update succeeded. Reload the schema return this.reloadData(); }, () => { @@ -558,16 +548,16 @@ class SchemaController { if (!this.data[className][fieldName]) { throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); } - + }) + .then(() => this.getOneSchema(className)) + .then(schema => { if (this.data[className][fieldName].type == 'Relation') { //For relations, drop the _Join table - return database.adapter.deleteFields(className, [fieldName], []) - .then(() => database.adapter.deleteOneSchema(`_Join:${fieldName}:${className}`)); + return database.adapter.deleteFields(className, schema, [fieldName]) + .then(() => database.adapter.deleteClass(`_Join:${fieldName}:${className}`)); } - const fieldNames = [fieldName]; - const pointerFieldNames = this.data[className][fieldName].type === 'Pointer' ? [fieldName] : []; - return database.adapter.deleteFields(className, fieldNames, pointerFieldNames); + return database.adapter.deleteFields(className, schema, [fieldName]); }); } @@ -696,8 +686,8 @@ class SchemaController { } // Returns a promise for a new Schema. -function load(collection, dbAdapter) { - let schema = new SchemaController(collection, dbAdapter); +const load = dbAdapter => { + let schema = new SchemaController(dbAdapter); return schema.reloadData().then(() => schema); } diff --git a/src/ParseServer.js b/src/ParseServer.js index b823b00615..7c6ae6c9f1 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -95,12 +95,12 @@ class ParseServer { masterKey = requiredParameter('You must provide a masterKey!'), appName, filesAdapter, - databaseAdapter, push, loggerAdapter, logsFolder, databaseURI, databaseOptions, + databaseAdapter, cloud, collectionPrefix = '', clientKey, @@ -193,13 +193,13 @@ class ParseServer { const databaseController = new DatabaseController(databaseAdapter); const hooksController = new HooksController(appId, databaseController, webhookKey); - let usernameUniqueness = databaseController.adapter.ensureUniqueness('_User', ['username'], requiredUserFields) + let usernameUniqueness = databaseController.adapter.ensureUniqueness('_User', requiredUserFields, ['username']) .catch(error => { logger.warn('Unable to ensure uniqueness for usernames: ', error); return Promise.reject(); }); - let emailUniqueness = databaseController.adapter.ensureUniqueness('_User', ['email'], requiredUserFields) + let emailUniqueness = databaseController.adapter.ensureUniqueness('_User', requiredUserFields, ['email']) .catch(error => { logger.warn('Unabled to ensure uniqueness for user email addresses: ', error); return Promise.reject(); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index da8307c7ea..4024d0a2db 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -16,7 +16,7 @@ function classNameMismatchResponse(bodyClass, pathClass) { function getAllSchemas(req) { return req.config.database.loadSchema() - .then(schemaController => schemaController.getAllSchemas()) + .then(schemaController => schemaController.getAllClasses()) .then(schemas => ({ response: { results: schemas } })); } @@ -47,7 +47,7 @@ function createSchema(req) { } return req.config.database.loadSchema() - .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) + .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) .then(schema => ({ response: schema })); } @@ -64,33 +64,11 @@ function modifySchema(req) { .then(result => ({response: result})); } -// A helper function that removes all join tables for a schema. Returns a promise. -var removeJoinTables = (database, mongoSchema) => { - return Promise.all(Object.keys(mongoSchema) - .filter(field => field !== '_metadata' && mongoSchema[field].startsWith('relation<')) - .map(field => { - let collectionName = `_Join:${field}:${mongoSchema._id}`; - return database.adapter.deleteOneSchema(collectionName); - }) - ); -}; - -function deleteSchema(req) { +const deleteSchema = req => { if (!SchemaController.classNameIsValid(req.params.className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, SchemaController.invalidClassNameMessage(req.params.className)); } return req.config.database.deleteSchema(req.params.className) - .then(() => req.config.database.schemaCollection()) - // We've dropped the collection now, so delete the item from _SCHEMA - // and clear the _Join collections - .then(coll => coll.findAndDeleteSchema(req.params.className)) - .then(document => { - if (document === null) { - //tried to delete non-existent class - return Promise.resolve(); - } - return removeJoinTables(req.config.database, document); - }) .then(() => ({ response: {} })); }