Merge pull request #2053 from drew-gross/postgres-3

Continue with postgres support
This commit is contained in:
Fosco Marotto
2016-06-16 10:19:07 -07:00
committed by GitHub
18 changed files with 412 additions and 196 deletions

View File

@@ -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})
```

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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];

View File

@@ -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) => {

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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({

View File

@@ -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) {

View File

@@ -1,4 +1,3 @@
import MongoCollection from './MongoCollection';
function mongoFieldToParseSchemaField(type) {

View File

@@ -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)));
}

View File

@@ -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':

View File

@@ -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:name> ()', { 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 ($<className>, $<schema>, 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 $<className:name> ADD COLUMN $<fieldName:name> $<postgresType:raw>', { 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>', { 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>)', { 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 $<className:name> 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>", { 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

View File

@@ -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.`);

View File

@@ -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) {
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 someone tries to create a new field with null/undefined as the value, return;
if (!type) {
return Promise.resolve(this);
}
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';
}
if (!fieldNameIsValid(fieldName)) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`);
}
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 {
throw new Parse.Error(
Parse.Error.INCORRECT_TYPE,
`schema mismatch for ${className}.${fieldName}; expected ${expected.type || expected} 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);
}
let expectedType = this.getExpectedType(className, fieldName);
if (typeof type === 'string') {
type = { type };
}
if (expectedType) {
if (!dbTypeMatchesObjectType(expectedType, type)) {
throw new Parse.Error(
Parse.Error.INCORRECT_TYPE,
`schema mismatch for ${className}.${fieldName}; expected ${expectedType.type || expectedType} but got ${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,
};

View File

@@ -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]];

View File

@@ -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) {

View File

@@ -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();
});
}