diff --git a/2.3.0.md b/2.3.0.md index 2528c290b6..fe58835498 100644 --- a/2.3.0.md +++ b/2.3.0.md @@ -77,6 +77,6 @@ coll.aggregate([ {$match: {count: {"$gt": 1}}}, {$project: {id: "$uniqueIds", username: "$_id", _id : 0} }, {$unwind: "$id" }, - {$out: '_duplicates'} // Save the list of duplicates to a new, "_duplicates collection. Remove this line to just output the list. + {$out: '_duplicates'} // Save the list of duplicates to a new, "_duplicates" collection. Remove this line to just output the list. ], {allowDiskUse:true}) ``` diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 2ead5c1ebc..6c2666a4b0 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -49,7 +49,7 @@ describe('MongoStorageAdapter', () => { it('stores objectId in _id', done => { let adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('Foo', {}, { objectId: 'abcde' }) + adapter.createObject('Foo', { fields: {} }, { objectId: 'abcde' }) .then(() => adapter._rawFind('Foo', {})) .then(results => { expect(results.length).toEqual(1); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 7bba6c9b74..b82035b15a 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -37,7 +37,10 @@ describe('miscellaneous', function() { expect(obj2.id).toEqual(obj.id); done(); }, - error: fail + error: error => { + fail(JSON.stringify(error)); + done(); + } }); }); }); @@ -48,8 +51,8 @@ describe('miscellaneous', function() { expect(data.getSessionToken()).not.toBeUndefined(); expect(data.get('password')).toBeUndefined(); done(); - }, function(err) { - fail(err); + }, error => { + fail(JSON.stringify(error)); done(); }); }); @@ -86,9 +89,8 @@ describe('miscellaneous', function() { }); it('ensure that email is uniquely indexed', done => { - let numCreated = 0; let numFailed = 0; - + let numCreated = 0; let user1 = new Parse.User(); user1.setPassword('asdf'); user1.setUsername('u1'); @@ -215,8 +217,9 @@ describe('miscellaneous', function() { expect(user.get('password')).toBeUndefined(); expect(user.getSessionToken()).not.toBeUndefined(); Parse.User.logOut().then(done); - }, error: function(error) { - fail(error); + }, error: error => { + fail(JSON.stringify(error)); + done(); } }); }, fail); @@ -232,15 +235,14 @@ describe('miscellaneous', function() { expect(user.get('foo')).toEqual(1); user.increment('foo'); return user.save(); - }).then(() => { - Parse.User.logOut(); - return Parse.User.logIn('test', 'moon-y'); - }).then((user) => { + }).then(() => Parse.User.logOut()) + .then(() => Parse.User.logIn('test', 'moon-y')) + .then((user) => { expect(user.get('foo')).toEqual(2); Parse.User.logOut() .then(done); }, (error) => { - fail(error); + fail(JSON.stringify(error)); done(); }); }); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 3d1e45d67b..7663e9a48f 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(() => config.database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => 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(() => config.database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => 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(() => config.database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); var obj = results[0]; diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 1131e95c0c..b3486177a8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -10,6 +10,7 @@ var request = require('request'); var passwordCrypto = require('../src/password'); var Config = require('../src/Config'); +const rp = require('request-promise'); function verifyACL(user) { const ACL = user.getACL(); @@ -2131,7 +2132,7 @@ describe('Parse.User testing', () => { let database = new Config(Parse.applicationId).database; database.create('_User', { username: 'user', - password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', + _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', _auth_data_facebook: null }, {}).then(() => { return new Promise((resolve, reject) => { @@ -2258,42 +2259,43 @@ describe('Parse.User testing', () => { }); it('should fail to become user with expired token', (done) => { - Parse.User.signUp("auser", "somepass", null, { - success: function(user) { - request.get({ - url: 'http://localhost:8378/1/classes/_Session', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - }, (error, response, body) => { - var id = body.results[0].objectId; - var expiresAt = new Date((new Date()).setYear(2015)); - var token = body.results[0].sessionToken; - request.put({ - url: "http://localhost:8378/1/classes/_Session/" + id, - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - body: { - expiresAt: { __type: "Date", iso: expiresAt.toISOString() }, - }, - }, (error, response, body) => { - Parse.User.become(token) - .then(() => { fail("Should not have succeded"); }) - .fail((err) => { - expect(err.code).toEqual(209); - expect(err.message).toEqual("Session token is expired."); - Parse.User.logOut() // Logout to prevent polluting CLI with messages - .then(done()); - }); - }); - }); - } - }); + let token; + Parse.User.signUp("auser", "somepass", null) + .then(user => rp({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + })) + .then(body => { + var id = body.results[0].objectId; + var expiresAt = new Date((new Date()).setYear(2015)); + token = body.results[0].sessionToken; + return rp({ + method: 'PUT', + url: "http://localhost:8378/1/classes/_Session/" + id, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + body: { + expiresAt: { __type: "Date", iso: expiresAt.toISOString() }, + }, + }) + }) + .then(() => Parse.User.become(token)) + .then(() => { + fail("Should not have succeded") + done(); + }, error => { + expect(error.code).toEqual(209); + expect(error.message).toEqual("Session token is expired."); + done(); + }) }); it('should not create extraneous session tokens', (done) => { diff --git a/spec/PointerPermissions.spec.js b/spec/PointerPermissions.spec.js index d78b5b6edf..4ac5f8c880 100644 --- a/spec/PointerPermissions.spec.js +++ b/spec/PointerPermissions.spec.js @@ -36,8 +36,8 @@ describe('Pointer Permissions', () => { expect(res.length).toBe(1); expect(res[0].id).toBe(obj.id); done(); - }).catch((err) => { - fail('Should not fail'); + }).catch(error => { + fail(JSON.stringify(error)); done(); }); }); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 0af93b2db3..089ab91383 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -693,7 +693,7 @@ describe('SchemaController', () => { objectId: { type: 'String' }, updatedAt: { type: 'Date' }, createdAt: { type: 'Date' }, - ACL: { type: 'ACL' } + ACL: { type: 'ACL' }, }; expect(dd(schema.data.NewClass, expectedSchema)).toEqual(undefined); done(); diff --git a/spec/helper.js b/spec/helper.js index 472eea983e..48b5b9ba8e 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -11,7 +11,6 @@ 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'); @@ -30,7 +29,6 @@ if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { }) } - var port = 8378; let gridStoreAdapter = new GridStoreAdapter(mongoURI); @@ -142,6 +140,12 @@ beforeEach(done => { }); afterEach(function(done) { + let afterLogOut = () => { + if (Object.keys(openConnections).length > 0) { + fail('There were open connections to the server left after the test finished'); + } + done(); + }; Parse.Cloud._removeAllHooks(); databaseAdapter.getAllClasses() .then(allSchemas => { @@ -159,16 +163,7 @@ afterEach(function(done) { }); }) .then(() => Parse.User.logOut()) - .then(() => { - if (Object.keys(openConnections).length > 0) { - fail('There were open connections to the server left after the test finished'); - } - done(); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); + .then(afterLogOut, afterLogOut) }); var TestObject = Parse.Object.extend({ diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 889e640643..757be2091a 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -52,7 +52,7 @@ export default class MongoCollection { // If there is nothing that matches the query - does insert // Postgres Note: `INSERT ... ON CONFLICT UPDATE` that is available since 9.5. upsertOne(query, update) { - return this._mongoCollection.update(query, update, { upsert: true }); + return this._mongoCollection.update(query, update, { upsert: true }) } updateOne(query, update) { diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index 0b7394ac34..274d8c9423 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -1,4 +1,3 @@ - import MongoCollection from './MongoCollection'; function mongoFieldToParseSchemaField(type) { diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 55710aaa1f..b24ed6b92d 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -34,6 +34,21 @@ const storageAdapterAllCollections = mongoAdapter => { }); } +const convertParseSchemaToMongoSchema = ({...schema}) => { + delete schema.fields._rperm; + delete schema.fields._wperm; + + if (schema.className === '_User') { + // Legacy mongo adapter knows about the difference between password and _hashed_password. + // Future database adapters will only know about _hashed_password. + // Note: Parse Server will bring back password with injectDefaultSchema, so we don't need + // to add _hashed_password back ever. + delete schema.fields._hashed_password; + } + + return schema; +} + export class MongoStorageAdapter { // Private _uri: string; @@ -97,6 +112,7 @@ export class MongoStorageAdapter { } createClass(className, schema) { + schema = convertParseSchemaToMongoSchema(schema); return this._schemaCollection() .then(schemaCollection => schemaCollection.addSchema(className, schema.fields, schema.classLevelPermissions)); } @@ -185,13 +201,14 @@ export class MongoStorageAdapter { // undefined as the reason. getClass(className) { return this._schemaCollection() - .then(schemasCollection => schemasCollection._fechOneSchemaFrom_SCHEMA(className)); + .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, schema, object) { + schema = convertParseSchemaToMongoSchema(schema); const mongoObject = parseObjectToMongoObjectForCreate(className, object, schema); return this._adaptiveCollection(className) .then(collection => collection.insertOne(mongoObject)) @@ -208,6 +225,7 @@ export class MongoStorageAdapter { // 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) { + schema = convertParseSchemaToMongoSchema(schema); return this._adaptiveCollection(className) .then(collection => { let mongoWhere = transformWhere(className, query, schema); @@ -225,6 +243,7 @@ export class MongoStorageAdapter { // Apply the update to all objects that match the given Parse Query. updateObjectsByQuery(className, schema, query, update) { + schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) @@ -232,8 +251,9 @@ export class MongoStorageAdapter { } // Atomically finds and updates an object based on query. - // Resolve with the updated object. + // Return value not currently well specified. findOneAndUpdate(className, schema, query, update) { + schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) @@ -243,6 +263,7 @@ export class MongoStorageAdapter { // Hopefully we can get rid of this. It's only used for config and hooks. upsertOneObject(className, schema, query, update) { + schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) @@ -251,11 +272,12 @@ export class MongoStorageAdapter { // Executes a find. Accepts: className, query in Parse format, and { skip, limit, sort }. find(className, schema, query, { skip, limit, sort }) { + schema = convertParseSchemaToMongoSchema(schema); let mongoWhere = transformWhere(className, query, schema); let mongoSort = _.mapKeys(sort, (value, fieldName) => transformKey(className, fieldName, schema)); return this._adaptiveCollection(className) .then(collection => collection.find(mongoWhere, { skip, limit, sort: mongoSort })) - .then(objects => objects.map(object => mongoObjectToParseObject(className, object, schema))); + .then(objects => objects.map(object => mongoObjectToParseObject(className, object, schema))) } // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't @@ -264,6 +286,7 @@ export class MongoStorageAdapter { // 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) { + schema = convertParseSchemaToMongoSchema(schema); let indexCreationRequest = {}; let mongoFieldNames = fieldNames.map(fieldName => transformKey(className, fieldName, schema)); mongoFieldNames.forEach(fieldName => { @@ -287,6 +310,7 @@ export class MongoStorageAdapter { // Executs a count. count(className, schema, query) { + schema = convertParseSchemaToMongoSchema(schema); return this._adaptiveCollection(className) .then(collection => collection.count(transformWhere(className, query, schema))); } diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 4858a4364b..1fff63787e 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -197,20 +197,12 @@ function transformWhere(className, restWhere, schema) { return mongoWhere; } -const parseObjectKeyValueToMongoObjectKeyValue = (className, restKey, restValue, schema) => { +const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => { // Check if the schema is known since it's a built-in field. let transformedValue; let coercedToDate; switch(restKey) { case 'objectId': return {key: '_id', value: restValue}; - case 'createdAt': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: '_created_at', value: coercedToDate}; - case 'updatedAt': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: '_updated_at', value: coercedToDate}; case 'expiresAt': transformedValue = transformTopLevelAtom(restValue); coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue @@ -271,8 +263,6 @@ const parseObjectKeyValueToMongoObjectKeyValue = (className, restKey, restValue, return {key: restKey, value}; } -// Main exposed method to create new objects. -// restCreate is the "create" clause in REST API form. const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { if (className == '_User') { restCreate = transformAuthData(restCreate); @@ -281,7 +271,6 @@ const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { let mongoCreate = {} for (let restKey in restCreate) { let { key, value } = parseObjectKeyValueToMongoObjectKeyValue( - className, restKey, restCreate[restKey], schema @@ -290,6 +279,17 @@ const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { mongoCreate[key] = value; } } + + // Use the legacy mongo format for createdAt and updatedAt + if (mongoCreate.createdAt) { + mongoCreate._created_at = new Date(mongoCreate.createdAt.iso || mongoCreate.createdAt); + delete mongoCreate.createdAt; + } + if (mongoCreate.updatedAt) { + mongoCreate._updated_at = new Date(mongoCreate.updatedAt.iso || mongoCreate.updatedAt); + delete mongoCreate.updatedAt; + } + return mongoCreate; } @@ -517,13 +517,7 @@ function transformConstraint(constraint, inArray) { break; case '$options': - var options = constraint[key]; - if (!answer['$regex'] || (typeof options !== 'string') - || !options.match(/^[imxs]+$/)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'got a bad $options'); - } - answer[key] = options; + answer[key] = constraint[key]; break; case '$nearSphere': @@ -735,7 +729,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { restObject['objectId'] = '' + mongoObject[key]; break; case '_hashed_password': - restObject['password'] = mongoObject[key]; + restObject._hashed_password = mongoObject[key]; break; case '_acl': case '_email_verify_token': diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 62f0703bdd..d1c299cb8e 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -3,6 +3,23 @@ const pgp = require('pg-promise')(); const PostgresRelationDoesNotExistError = '42P01'; const PostgresDuplicateRelationError = '42P07'; +const parseTypeToPostgresType = type => { + switch (type.type) { + case 'String': return 'text'; + case 'Date': return 'timestamp'; + case 'Object': return 'jsonb'; + case 'Boolean': return 'boolean'; + case 'Pointer': return 'char(10)'; + case 'Number': return 'double precision'; + case 'Array': + if (type.contents && type.contents.type === 'String') { + return 'text[]'; + } else { + throw `no type for ${JSON.stringify(type)} yet`; + } + default: throw `no type for ${JSON.stringify(type)} yet`; + } +}; export class PostgresStorageAdapter { // Private @@ -37,13 +54,24 @@ export class PostgresStorageAdapter { } createClass(className, schema) { - return this._client.query('CREATE TABLE $ ()', { className }) + let valuesArray = []; + let patternsArray = []; + Object.keys(schema.fields).forEach((fieldName, index) => { + valuesArray.push(fieldName); + let parseType = schema.fields[fieldName]; + if (['_rperm', '_wperm'].includes(fieldName)) { + parseType.contents = { type: 'String' }; + } + valuesArray.push(parseTypeToPostgresType(parseType)); + patternsArray.push(`$${index * 2 + 2}:name $${index * 2 + 3}:raw`); + }); + return this._client.query(`CREATE TABLE $1:name (${patternsArray.join(',')})`, [className, ...valuesArray]) .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 }) + // TODO: Doing this in a transaction might be a good idea. + return this._client.query('ALTER TABLE $ ADD COLUMN $ $', { className, fieldName, postgresType: parseTypeToPostgresType(type) }) .catch(error => { if (error.code === PostgresRelationDoesNotExistError) { return this.createClass(className, { fields: { [fieldName]: type } }) @@ -112,7 +140,7 @@ export class PostgresStorageAdapter { } // 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 + // schemas cannot be retrieved, returns a promise that rejects. Rquirements for the // rejection reason are TBD. getAllClasses() { return this._ensureSchemaCollectionExists() @@ -127,24 +155,54 @@ export class PostgresStorageAdapter { return this._client.query('SELECT * FROM "_SCHEMA" WHERE "className"=$', { className }) .then(result => { if (result.length === 1) { - return result; + return result[0]; } else { throw undefined; } }); } - // TODO: remove the mongo format dependency + // TODO: remove the mongo format dependency in the return value createObject(className, schema, object) { - return this._client.query('INSERT INTO "GameScore" (score) VALUES ($)', { score: object.score }) - .then(() => ({ ops: [object] })); + let columnsArray = []; + let valuesArray = []; + console.log('creating'); + console.log(schema); + console.log(object); + console.log(className); + console.log(new Error().stack); + Object.keys(object).forEach(fieldName => { + columnsArray.push(fieldName); + switch (schema.fields[fieldName].type) { + case 'Date': + valuesArray.push(object[fieldName].iso); + break; + case 'Pointer': + valuesArray.push(object[fieldName].objectId); + break; + default: + valuesArray.push(object[fieldName]); + break; + } + }); + let columnsPattern = columnsArray.map((col, index) => `$${index + 2}:name`).join(','); + let valuesPattern = valuesArray.map((val, index) => `$${index + 2 + columnsArray.length}`).join(','); + return this._client.query(`INSERT INTO $1:name (${columnsPattern}) VALUES (${valuesPattern})`, [className, ...columnsArray, ...valuesArray]) + .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.') + return this._client.query(`WITH deleted AS (DELETE FROM $ RETURNING *) SELECT count(*) FROM deleted`, { className }) + .then(result => { + if (result[0].count === 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } else { + return result[0].count; + } + }); } // Apply the update to all objects that match the given Parse Query. @@ -152,9 +210,53 @@ export class PostgresStorageAdapter { return Promise.reject('Not implented yet.') } - // Hopefully we can get rid of this in favor of updateObjectsByQuery. + // Return value not currently well specified. findOneAndUpdate(className, schema, query, update) { - return Promise.reject('Not implented yet.') + let conditionPatterns = []; + let updatePatterns = []; + let values = [] + values.push(className); + let index = 2; + + for (let fieldName in update) { + let fieldValue = update[fieldName]; + if (fieldValue.__op === 'Increment') { + updatePatterns.push(`$${index}:name = COALESCE($${index}:name, 0) + $${index + 1}`); + values.push(fieldName, fieldValue.amount); + index += 2; + } else if (fieldName === 'updatedAt') { //TODO: stop special casing this. It should check for __type === 'Date' and use .iso + updatePatterns.push(`$${index}:name = $${index + 1}`) + values.push(fieldName, new Date(fieldValue)); + index += 2; + } else { + return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `Postgres doesn't support this type of update yet`)); + } + } + + for (let fieldName in query) { + let fieldValue = query[fieldName]; + if (typeof fieldValue === 'string') { + conditionPatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); + index += 2; + } else if (Array.isArray(fieldValue.$in)) { + let inPatterns = []; + values.push(fieldName); + fieldValue.$in.forEach((listElem, listIndex) => { + values.push(listElem); + inPatterns.push(`$${index + 1 + listIndex}`); + }); + conditionPatterns.push(`$${index}:name && ARRAY[${inPatterns.join(',')}]`); + index = index + 1 + inPatterns.length; + } else { + return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `Postgres doesn't support this type of request yet`)); + } + } + let qs = `UPDATE $1:name SET ${updatePatterns.join(',')} WHERE ${conditionPatterns.join(' AND ')} RETURNING *`; + return this._client.query(qs, values) + .then(val => { + return val[0]; + }) } // Hopefully we can get rid of this. It's only used for config and hooks. @@ -164,7 +266,63 @@ export class PostgresStorageAdapter { // 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 }) + let conditionPatterns = []; + let values = []; + values.push(className); + let index = 2; + + for (let fieldName in query) { + let fieldValue = query[fieldName]; + if (typeof fieldValue === 'string') { + conditionPatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); + index += 2; + } else if (fieldValue.$ne) { + conditionPatterns.push(`$${index}:name <> $${index + 1}`); + values.push(fieldName, fieldValue.$ne) + index += 2; + } else if (Array.isArray(fieldValue.$in)) { + let inPatterns = []; + values.push(fieldName); + fieldValue.$in.forEach((listElem, listIndex) => { + values.push(listElem); + inPatterns.push(`$${index + 1 + listIndex}`); + }); + conditionPatterns.push(`$${index}:name IN (${inPatterns.join(',')})`); + index = index + 1 + inPatterns.length; + } else if (fieldValue.__type === 'Pointer') { + conditionPatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue.objectId); + index += 2; + } else { + return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, "Postgres doesn't support this query type yet")); + } + } + + return this._client.query(`SELECT * FROM $1:name WHERE ${conditionPatterns.join(' AND ')}`, values) + .then(results => results.map(object => { + Object.keys(schema.fields).filter(field => schema.fields[field].type === 'Pointer').forEach(fieldName => { + object[fieldName] = { objectId: object[fieldName], __type: 'Pointer', className: schema.fields[fieldName].targetClass }; + }); + //TODO: remove this reliance on the mongo format. DB adapter shouldn't know there is a difference between created at and any other date field. + if (object.createdAt) { + object.createdAt = object.createdAt.toISOString(); + } + if (object.updatedAt) { + object.updatedAt = object.updatedAt.toISOString(); + } + if (object.expiresAt) { + object.expiresAt = { __type: 'Date', iso: object.expiresAt.toISOString() }; + } + + for (let fieldName in object) { + if (object[fieldName] === null) { + delete object[fieldName]; + } + } + + return object; + })) } // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index a897dd2246..9e3c6e3858 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -67,6 +67,13 @@ const validateQuery = query => { } Object.keys(query).forEach(key => { + if (query && query[key] && query[key].$regex) { + if (typeof query[key].$options === 'string') { + if (!query[key].$options.match(/^[imxs]+$/)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Bad $options value for query: ${query[key].$options}`); + } + } + } if (!specialQuerykeys.includes(key) && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`); } @@ -93,12 +100,8 @@ DatabaseController.prototype.collectionExists = function(className) { DatabaseController.prototype.purgeCollection = function(className) { return this.loadSchema() - .then((schema) => { - schema.getOneSchema(className) - }) - .then((schema) => { - this.adapter.deleteObjectsByQuery(className, {}, schema); - }); + .then(schemaController => schemaController.getOneSchema(className)) + .then(schema => this.adapter.deleteObjectsByQuery(className, schema, {})); }; DatabaseController.prototype.validateClassName = function(className) { @@ -159,6 +162,9 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => { return object; } + object.password = object._hashed_password; + delete object._hashed_password; + delete object.sessionToken; if (isMaster || (aclGroup.indexOf(object.objectId) > -1)) { @@ -237,7 +243,7 @@ DatabaseController.prototype.update = function(className, query, update, { } else if (upsert) { return this.adapter.upsertOneObject(className, schema, query, update); } else { - return this.adapter.findOneAndUpdate(className, schema, query, update); + return this.adapter.findOneAndUpdate(className, schema, query, update) } }); }) @@ -400,6 +406,9 @@ DatabaseController.prototype.create = function(className, object, { acl } = {}) let originalObject = object; object = transformObjectACL(object); + object.createdAt = { iso: object.createdAt, __type: 'Date' }; + object.updatedAt = { iso: object.updatedAt, __type: 'Date' }; + var isMaster = acl === undefined; var aclGroup = acl || []; @@ -410,7 +419,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, schema, object)) + .then(schema => this.adapter.createObject(className, SchemaController.convertSchemaToAdapterSchema(schema), object)) .then(result => sanitizeDatabaseResult(originalObject, result.ops[0])); }) }; @@ -639,13 +648,18 @@ DatabaseController.prototype.find = function(className, query, { let isMaster = acl === undefined; let aclGroup = acl || []; let op = typeof query.objectId == 'string' && Object.keys(query).length === 1 ? 'get' : 'find'; + let classExists = true; return this.loadSchema() .then(schemaController => { - return schemaController.getOneSchema(className) + //Allow volatile classes if querying with Master (for _PushStatus) + //TODO: Move volatile classes concept into mongo adatper, postgres adapter shouldn't care + //that api.parse.com breaks when _PushStatus exists in mongo. + return schemaController.getOneSchema(className, isMaster) .catch(error => { - // If the schema doesn't exist, pretend it exists with no fields. This behaviour - // will likely need revisiting. + // Behaviour for non-existent classes is kinda weird on Parse.com. Probably doesn't matter too much. + // For now, pretend the class exists but has no objects, if (error === undefined) { + classExists = false; return { fields: {} }; } throw error; @@ -679,10 +693,9 @@ DatabaseController.prototype.find = function(className, query, { } if (!query) { if (op == 'get') { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } else { - return Promise.resolve([]); + return []; } } if (!isMaster) { @@ -690,13 +703,21 @@ DatabaseController.prototype.find = function(className, query, { } validateQuery(query); if (count) { - return this.adapter.count(className, schema, query); + if (!classExists) { + return 0; + } else { + return this.adapter.count(className, schema, query); + } } else { - return this.adapter.find(className, schema, query, { skip, limit, sort }) - .then(objects => objects.map(object => { - object = untransformObjectACL(object); - return filterSensitiveData(isMaster, aclGroup, className, object) - })); + if (!classExists) { + return []; + } else { + return this.adapter.find(className, schema, query, { skip, limit, sort }) + .then(objects => objects.map(object => { + object = untransformObjectACL(object); + return filterSensitiveData(isMaster, aclGroup, className, object) + })); + } } }); }); @@ -739,7 +760,7 @@ DatabaseController.prototype.deleteSchema = function(className) { }) .then(schema => { return this.collectionExists(className) - .then(exist => this.adapter.count(className)) + .then(exist => this.adapter.count(className, { fields: {} })) .then(count => { if (count > 0) { throw new Parse.Error(255, `Class ${className} is not empty, contains ${count} objects, cannot drop schema.`); diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 0c5502157a..77e22f8ac8 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -97,7 +97,7 @@ const requiredColumns = Object.freeze({ const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus']); -const volatileClasses = Object.freeze(['_PushStatus']); +const volatileClasses = Object.freeze(['_PushStatus', '_Hooks', '_GlobalConfig']); // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; @@ -220,6 +220,34 @@ const fieldTypeIsInvalid = ({ type, targetClass }) => { return undefined; } +const convertSchemaToAdapterSchema = schema => { + schema = injectDefaultSchema(schema); + delete schema.fields.ACL; + schema.fields._rperm = { type: 'Array' }; + schema.fields._wperm = { type: 'Array' }; + + if (schema.className === '_User') { + delete schema.fields.password; + schema.fields._hashed_password = { type: 'String' }; + } + + return schema; +} + +const convertAdapterSchemaToParseSchema = ({...schema}) => { + delete schema.fields._rperm; + delete schema.fields._wperm; + + schema.fields.ACL = { type: 'ACL' }; + + if (schema.className === '_User') { + delete schema.fields._hashed_password; + schema.fields.password = { type: 'String' }; + } + + return schema; +} + const injectDefaultSchema = schema => ({ className: schema.className, fields: { @@ -230,6 +258,14 @@ const injectDefaultSchema = schema => ({ classLevelPermissions: schema.classLevelPermissions, }) +const dbTypeMatchesObjectType = (dbType, objectType) => { + if (dbType.type !== objectType.type) return false; + if (dbType.targetClass !== objectType.targetClass) return false; + if (dbType === objectType.type) return true; + if (dbType.type === objectType.type) return true; + return false; +} + // 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 { @@ -293,7 +329,8 @@ class SchemaController { return Promise.reject(validationError); } - return this._dbAdapter.createClass(className, { fields, classLevelPermissions }) + return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className })) + .then(convertAdapterSchemaToParseSchema) .catch(error => { if (error && error.code === Parse.Error.DUPLICATE_VALUE) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); @@ -320,6 +357,8 @@ class SchemaController { } }); + delete existingFields._rperm; + delete existingFields._wperm; let newSchema = buildMergedSchemaObject(existingFields, submittedFields); let validationError = this.validateSchemaData(className, newSchema, classLevelPermissions); if (validationError) { @@ -344,7 +383,7 @@ class SchemaController { .then(() => { let promises = insertedFields.map(fieldName => { const type = submittedFields[fieldName]; - return this.validateField(className, fieldName, type); + return this.enforceFieldExists(className, fieldName, type); }); return Promise.all(promises); }) @@ -360,20 +399,15 @@ class SchemaController { // Returns a promise that resolves successfully to the new schema // object or fails with a reason. - // If 'freeze' is true, refuse to modify the schema. - enforceClassExists(className, freeze) { + enforceClassExists(className) { if (this.data[className]) { return Promise.resolve(this); } - if (freeze) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - '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(); - }, () => { + }, error => { // The schema update failed. This can be okay - it might // have failed because there's a race condition and a different // client is making the exact same schema update that we want. @@ -381,8 +415,12 @@ class SchemaController { return this.reloadData(); }).then(() => { // Ensure that the schema now validates - return this.enforceClassExists(className, true); - }, () => { + if (this.data[className]) { + return this; + } else { + throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`); + } + }, error => { // The schema still doesn't validate. Give up throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate'); }); @@ -447,49 +485,36 @@ class SchemaController { // object if the provided className-fieldName-type tuple is valid. // The className must already be validated. // If 'freeze' is true, refuse to update the schema for this field. - validateField(className, fieldName, type, freeze) { - return this.reloadData().then(() => { - if (fieldName.indexOf(".") > 0) { - // subdocument key (x.y) => ok if x is of type 'object' - fieldName = fieldName.split(".")[ 0 ]; - type = 'Object'; - } + enforceFieldExists(className, fieldName, type, freeze) { + if (fieldName.indexOf(".") > 0) { + // subdocument key (x.y) => ok if x is of type 'object' + fieldName = fieldName.split(".")[ 0 ]; + type = 'Object'; + } + if (!fieldNameIsValid(fieldName)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + } - if (!fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + // If someone tries to create a new field with null/undefined as the value, return; + if (!type) { + return Promise.resolve(this); + } + + return this.reloadData().then(() => { + let expectedType = this.getExpectedType(className, fieldName); + if (typeof type === 'string') { + type = { type }; } - let expected = this.data[className][fieldName]; - if (expected) { - expected = (expected === 'map' ? 'Object' : expected); - if (expected.type && type.type - && expected.type == type.type - && expected.targetClass == type.targetClass) { - return Promise.resolve(this); - } else if (expected == type || expected.type == type) { - return Promise.resolve(this); - } else { + if (expectedType) { + if (!dbTypeMatchesObjectType(expectedType, type)) { throw new Parse.Error( Parse.Error.INCORRECT_TYPE, - `schema mismatch for ${className}.${fieldName}; expected ${expected.type || expected} but got ${type}` + `schema mismatch for ${className}.${fieldName}; expected ${expectedType.type || expectedType} but got ${type}` ); } } - if (freeze) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `schema is frozen, cannot add ${fieldName} field`); - } - - // We don't have this field, but if the value is null or undefined, - // we won't update the schema until we get a value with a type. - if (!type) { - return Promise.resolve(this); - } - - if (typeof type === 'string') { - type = { type }; - } - return this._dbAdapter.addFieldIfNotExists(className, fieldName, type).then(() => { // The update succeeded. Reload the schema return this.reloadData(); @@ -500,11 +525,10 @@ class SchemaController { return this.reloadData(); }).then(() => { // Ensure that the schema now validates - return this.validateField(className, fieldName, type, true); - }, (error) => { - // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'schema key will not revalidate'); + if (!dbTypeMatchesObjectType(this.getExpectedType(className, fieldName), type)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`); + } + return this; }); }); } @@ -661,9 +685,10 @@ class SchemaController { // Returns the expected type for a className+key combination // or undefined if the schema is not set - getExpectedType(className, key) { + getExpectedType(className, fieldName) { if (this.data && this.data[className]) { - return this.data[className][key]; + const expectedType = this.data[className][fieldName] + return expectedType === 'map' ? 'Object' : expectedType; } return undefined; }; @@ -714,7 +739,7 @@ function buildMergedSchemaObject(existingFields, putRequest) { // validates this field once the schema loads. function thenValidateField(schemaPromise, className, key, type) { return schemaPromise.then((schema) => { - return schema.validateField(className, key, type); + return schema.enforceFieldExists(className, key, type); }); } @@ -826,4 +851,5 @@ export { buildMergedSchemaObject, systemClasses, defaultColumns, + convertSchemaToAdapterSchema, }; diff --git a/src/RestQuery.js b/src/RestQuery.js index 387052878e..349e4e14e8 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -526,16 +526,14 @@ function findPointers(object, path) { } if (typeof object !== 'object') { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'can only include pointer fields'); + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'can only include pointer fields'); } if (path.length == 0) { if (object.__type == 'Pointer') { return [object]; } - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'can only include pointer fields'); + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'can only include pointer fields'); } var subobject = object[path[0]]; diff --git a/src/RestWrite.js b/src/RestWrite.js index 9479991032..ebd7a2d2d6 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -31,8 +31,7 @@ function RestWrite(config, auth, className, query, data, originalData) { this.runOptions = {}; if (!query && data.objectId) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + - 'is an invalid field name.'); + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId is an invalid field name.'); } // When the operation is complete, this.response may have several @@ -712,8 +711,7 @@ RestWrite.prototype.runDatabaseOperation = function() { if (this.className === '_User' && this.query && !this.auth.couldUpdateUserId(this.query.objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'cannot modify user ' + this.query.objectId); + throw new Parse.Error(Parse.Error.SESSION_MISSING, `Cannot modify user ${this.query.objectId}.`); } if (this.className === '_Product' && this.data.download) { diff --git a/src/rest.js b/src/rest.js index 45f0d7db74..4259529558 100644 --- a/src/rest.js +++ b/src/rest.js @@ -117,8 +117,7 @@ function update(config, auth, className, objectId, restObject) { originalRestObject = response.results[0]; } - var write = new RestWrite(config, auth, className, - {objectId: objectId}, restObject, originalRestObject); + var write = new RestWrite(config, auth, className, {objectId: objectId}, restObject, originalRestObject); return write.execute(); }); }