Add Indexes to Schema API (#4240)

* Add Indexes to Schema API

* error handling

* ci errors

* postgres support

* full text compound indexes

* pg clean up

* get indexes on startup

* test compound index on startup

* add default _id to index, full Text index on startup

* lint

* fix test
This commit is contained in:
Diamond Lewis
2017-11-25 13:55:34 -06:00
committed by Florent Vilmart
parent 6a1510729a
commit 4bccf96ae7
10 changed files with 965 additions and 83 deletions

View File

@@ -21,6 +21,9 @@ describe('MongoSchemaCollection', () => {
"create":{"*":true}, "create":{"*":true},
"delete":{"*":true}, "delete":{"*":true},
"addField":{"*":true}, "addField":{"*":true},
},
"indexes": {
"name1":{"deviceToken":1}
} }
}, },
"installationId":"string", "installationId":"string",
@@ -66,7 +69,10 @@ describe('MongoSchemaCollection', () => {
update: { '*': true }, update: { '*': true },
delete: { '*': true }, delete: { '*': true },
addField: { '*': true }, addField: { '*': true },
} },
indexes: {
name1: {deviceToken: 1}
},
}); });
done(); done();
}); });

View File

@@ -31,7 +31,8 @@ const fullTextHelper = () => {
const request = { const request = {
method: "POST", method: "POST",
body: { body: {
subject: subjects[i] subject: subjects[i],
comment: subjects[i],
}, },
path: "/1/classes/TestObject" path: "/1/classes/TestObject"
}; };
@@ -280,42 +281,18 @@ describe('Parse.Query Full Text Search testing', () => {
}); });
describe_only_db('mongo')('Parse.Query Full Text Search testing', () => { describe_only_db('mongo')('Parse.Query Full Text Search testing', () => {
it('fullTextSearch: $search, only one text index', (done) => { it('fullTextSearch: does not create text index if compound index exist', (done) => {
return reconfigureServer({ fullTextHelper().then(() => {
appId: 'test', return databaseAdapter.dropAllIndexes('TestObject');
restAPIKey: 'test',
publicServerURL: 'http://localhost:8378/1',
databaseAdapter: new MongoStorageAdapter({ uri: mongoURI })
}).then(() => { }).then(() => {
return rp.post({ return databaseAdapter.getIndexes('TestObject');
url: 'http://localhost:8378/1/batch', }).then((indexes) => {
body: { expect(indexes.length).toEqual(1);
requests: [ return databaseAdapter.createIndex('TestObject', {subject: 'text', comment: 'text'});
{
method: "POST",
body: {
subject: "coffee is java"
},
path: "/1/classes/TestObject"
},
{
method: "POST",
body: {
subject: "java is coffee"
},
path: "/1/classes/TestObject"
}
]
},
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'test'
}
});
}).then(() => {
return databaseAdapter.createIndex('TestObject', {random: 'text'});
}).then(() => { }).then(() => {
return databaseAdapter.getIndexes('TestObject');
}).then((indexes) => {
expect(indexes.length).toEqual(2);
const where = { const where = {
subject: { subject: {
$text: { $text: {
@@ -334,12 +311,91 @@ describe_only_db('mongo')('Parse.Query Full Text Search testing', () => {
} }
}); });
}).then((resp) => { }).then((resp) => {
fail(`Should not be more than one text index: ${JSON.stringify(resp)}`); expect(resp.results.length).toEqual(3);
done(); return databaseAdapter.getIndexes('TestObject');
}).catch((err) => { }).then((indexes) => {
expect(err.error.code).toEqual(Parse.Error.INTERNAL_SERVER_ERROR); expect(indexes.length).toEqual(2);
done(); rp.get({
}); url: 'http://localhost:8378/1/schemas/TestObject',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
json: true,
}, (error, response, body) => {
expect(body.indexes._id_).toBeDefined();
expect(body.indexes._id_._id).toEqual(1);
expect(body.indexes.subject_text_comment_text).toBeDefined();
expect(body.indexes.subject_text_comment_text.subject).toEqual('text');
expect(body.indexes.subject_text_comment_text.comment).toEqual('text');
done();
});
}).catch(done.fail);
});
it('fullTextSearch: does not create text index if schema compound index exist', (done) => {
fullTextHelper().then(() => {
return databaseAdapter.dropAllIndexes('TestObject');
}).then(() => {
return databaseAdapter.getIndexes('TestObject');
}).then((indexes) => {
expect(indexes.length).toEqual(1);
return rp.put({
url: 'http://localhost:8378/1/schemas/TestObject',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'test',
'X-Parse-Master-Key': 'test',
},
body: {
indexes: {
text_test: { subject: 'text', comment: 'text'},
},
},
});
}).then(() => {
return databaseAdapter.getIndexes('TestObject');
}).then((indexes) => {
expect(indexes.length).toEqual(2);
const where = {
subject: {
$text: {
$search: {
$term: 'coffee'
}
}
}
};
return rp.post({
url: 'http://localhost:8378/1/classes/TestObject',
json: { where, '_method': 'GET' },
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'test'
}
});
}).then((resp) => {
expect(resp.results.length).toEqual(3);
return databaseAdapter.getIndexes('TestObject');
}).then((indexes) => {
expect(indexes.length).toEqual(2);
rp.get({
url: 'http://localhost:8378/1/schemas/TestObject',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
json: true,
}, (error, response, body) => {
expect(body.indexes._id_).toBeDefined();
expect(body.indexes._id_._id).toEqual(1);
expect(body.indexes.text_test).toBeDefined();
expect(body.indexes.text_test.subject).toEqual('text');
expect(body.indexes.text_test.comment).toEqual('text');
done();
});
}).catch(done.fail);
}); });
it('fullTextSearch: $diacriticSensitive - false', (done) => { it('fullTextSearch: $diacriticSensitive - false', (done) => {

View File

@@ -274,7 +274,7 @@ describe('SchemaController', () => {
fooSixteen: {type: 'String'}, fooSixteen: {type: 'String'},
fooEighteen: {type: 'String'}, fooEighteen: {type: 'String'},
fooNineteen: {type: 'String'}, fooNineteen: {type: 'String'},
}, levelPermissions, config.database)) }, levelPermissions, {}, config.database))
.then(actualSchema => { .then(actualSchema => {
const expectedSchema = { const expectedSchema = {
className: 'NewClass', className: 'NewClass',
@@ -304,6 +304,9 @@ describe('SchemaController', () => {
fooNineteen: {type: 'String'}, fooNineteen: {type: 'String'},
}, },
classLevelPermissions: { ...levelPermissions }, classLevelPermissions: { ...levelPermissions },
indexes: {
_id_: { _id: 1 }
}
}; };
expect(dd(actualSchema, expectedSchema)).toEqual(undefined); expect(dd(actualSchema, expectedSchema)).toEqual(undefined);

View File

@@ -771,7 +771,7 @@ describe('schemas', () => {
}); });
}); });
it_exclude_dbs(['postgres'])('lets you delete multiple fields and add fields', done => { it('lets you delete multiple fields and add fields', done => {
var obj1 = hasAllPODobject(); var obj1 = hasAllPODobject();
obj1.save() obj1.save()
.then(() => { .then(() => {
@@ -1756,4 +1756,605 @@ describe('schemas', () => {
done(); done();
}); });
}); });
it('cannot create index if field does not exist', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
indexes: {
name1: { aString: 1},
}
}
}, (error, response, body) => {
expect(body.code).toBe(Parse.Error.INVALID_QUERY);
expect(body.error).toBe('Field aString does not exist, cannot add index.');
done();
});
})
});
it('cannot create compound index if field does not exist', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'}
},
indexes: {
name1: { aString: 1, bString: 1},
}
}
}, (error, response, body) => {
expect(body.code).toBe(Parse.Error.INVALID_QUERY);
expect(body.error).toBe('Field bString does not exist, cannot add index.');
done();
});
})
});
it('allows add index when you create a class', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
headers: masterKeyHeaders,
json: true,
body: {
className: "NewClass",
fields: {
aString: {type: 'String'}
},
indexes: {
name1: { aString: 1},
},
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'}
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
name1: { aString: 1},
},
});
config.database.adapter.getIndexes('NewClass').then((indexes) => {
expect(indexes.length).toBe(2);
done();
});
});
});
it('empty index returns nothing', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
headers: masterKeyHeaders,
json: true,
body: {
className: "NewClass",
fields: {
aString: {type: 'String'}
},
indexes: {},
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'}
},
classLevelPermissions: defaultClassLevelPermissions,
});
done();
});
});
it('lets you add indexes', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'}
},
indexes: {
name1: { aString: 1},
},
}
}, (error, response, body) => {
expect(dd(body, {
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'}
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
}
})).toEqual(undefined);
request.get({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'}
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
}
});
config.database.adapter.getIndexes('NewClass').then((indexes) => {
expect(indexes.length).toEqual(2);
done();
});
});
});
})
});
it('lets you add multiple indexes', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
dString: {type: 'String'},
},
indexes: {
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1, dString: 1 },
}
}
}, (error, response, body) => {
expect(dd(body, {
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
dString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1, dString: 1 },
}
})).toEqual(undefined);
request.get({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
dString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1, dString: 1 },
},
});
config.database.adapter.getIndexes('NewClass').then((indexes) => {
expect(indexes.length).toEqual(4);
done();
});
});
});
})
});
it('lets you delete indexes', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'},
},
indexes: {
name1: { aString: 1 },
}
}
}, (error, response, body) => {
expect(dd(body, {
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
}
})).toEqual(undefined);
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
indexes: {
name1: { __op: 'Delete' }
}
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
}
});
config.database.adapter.getIndexes('NewClass').then((indexes) => {
expect(indexes.length).toEqual(1);
done();
});
});
});
})
});
it('lets you delete multiple indexes', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
},
indexes: {
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1 },
}
}
}, (error, response, body) => {
expect(dd(body, {
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1 },
}
})).toEqual(undefined);
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
indexes: {
name1: { __op: 'Delete' },
name2: { __op: 'Delete' },
}
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name3: { cString: 1 },
}
});
config.database.adapter.getIndexes('NewClass').then((indexes) => {
expect(indexes.length).toEqual(2);
done();
});
});
});
})
});
it('lets you add and delete indexes', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
dString: {type: 'String'},
},
indexes: {
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1 },
}
}
}, (error, response, body) => {
expect(dd(body, {
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
dString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name1: { aString: 1 },
name2: { bString: 1 },
name3: { cString: 1 },
}
})).toEqual(undefined);
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
indexes: {
name1: { __op: 'Delete' },
name2: { __op: 'Delete' },
name4: { dString: 1 },
}
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
aString: {type: 'String'},
bString: {type: 'String'},
cString: {type: 'String'},
dString: {type: 'String'},
},
classLevelPermissions: defaultClassLevelPermissions,
indexes: {
_id_: { _id: 1 },
name3: { cString: 1 },
name4: { dString: 1 },
}
});
config.database.adapter.getIndexes('NewClass').then((indexes) => {
expect(indexes.length).toEqual(3);
done();
});
});
});
})
});
it('cannot delete index that does not exist', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
indexes: {
unknownIndex: { __op: 'Delete' }
}
}
}, (error, response, body) => {
expect(body.code).toBe(Parse.Error.INVALID_QUERY);
expect(body.error).toBe('Index unknownIndex does not exist, cannot delete.');
done();
});
})
});
it('cannot update index that exist', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {},
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
fields: {
aString: {type: 'String'},
},
indexes: {
name1: { aString: 1 }
}
}
}, () => {
request.put({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
indexes: {
name1: { field2: 1 }
}
}
}, (error, response, body) => {
expect(body.code).toBe(Parse.Error.INVALID_QUERY);
expect(body.error).toBe('Index name1 exists, cannot update.');
done();
});
});
})
});
it_exclude_dbs(['postgres'])('get indexes on startup', (done) => {
const obj = new Parse.Object('TestObject');
obj.save().then(() => {
return reconfigureServer({
appId: 'test',
restAPIKey: 'test',
publicServerURL: 'http://localhost:8378/1',
});
}).then(() => {
request.get({
url: 'http://localhost:8378/1/schemas/TestObject',
headers: masterKeyHeaders,
json: true,
}, (error, response, body) => {
expect(body.indexes._id_).toBeDefined();
done();
});
});
});
it_exclude_dbs(['postgres'])('get compound indexes on startup', (done) => {
const obj = new Parse.Object('TestObject');
obj.set('subject', 'subject');
obj.set('comment', 'comment');
obj.save().then(() => {
return config.database.adapter.createIndex('TestObject', {subject: 'text', comment: 'text'});
}).then(() => {
return reconfigureServer({
appId: 'test',
restAPIKey: 'test',
publicServerURL: 'http://localhost:8378/1',
});
}).then(() => {
request.get({
url: 'http://localhost:8378/1/schemas/TestObject',
headers: masterKeyHeaders,
json: true,
}, (error, response, body) => {
expect(body.indexes._id_).toBeDefined();
expect(body.indexes._id_._id).toEqual(1);
expect(body.indexes.subject_text_comment_text).toBeDefined();
expect(body.indexes.subject_text_comment_text.subject).toEqual('text');
expect(body.indexes.subject_text_comment_text.comment).toEqual('text');
done();
});
});
});
}); });

View File

@@ -63,13 +63,20 @@ const defaultCLPS = Object.freeze({
function mongoSchemaToParseSchema(mongoSchema) { function mongoSchemaToParseSchema(mongoSchema) {
let clps = defaultCLPS; let clps = defaultCLPS;
if (mongoSchema._metadata && mongoSchema._metadata.class_permissions) { let indexes = {}
clps = {...emptyCLPS, ...mongoSchema._metadata.class_permissions}; if (mongoSchema._metadata) {
if (mongoSchema._metadata.class_permissions) {
clps = {...emptyCLPS, ...mongoSchema._metadata.class_permissions};
}
if (mongoSchema._metadata.indexes) {
indexes = {...mongoSchema._metadata.indexes};
}
} }
return { return {
className: mongoSchema._id, className: mongoSchema._id,
fields: mongoSchemaFieldsToParseSchemaFields(mongoSchema), fields: mongoSchemaFieldsToParseSchemaFields(mongoSchema),
classLevelPermissions: clps, classLevelPermissions: clps,
indexes: indexes,
}; };
} }

View File

@@ -53,7 +53,7 @@ const convertParseSchemaToMongoSchema = ({...schema}) => {
// Returns { code, error } if invalid, or { result }, an object // Returns { code, error } if invalid, or { result }, an object
// suitable for inserting into _SCHEMA collection, otherwise. // suitable for inserting into _SCHEMA collection, otherwise.
const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPermissions) => { const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPermissions, indexes) => {
const mongoObject = { const mongoObject = {
_id: className, _id: className,
objectId: 'string', objectId: 'string',
@@ -74,6 +74,11 @@ const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPe
} }
} }
if (indexes && typeof indexes === 'object' && Object.keys(indexes).length > 0) {
mongoObject._metadata = mongoObject._metadata || {};
mongoObject._metadata.indexes = indexes;
}
return mongoObject; return mongoObject;
} }
@@ -165,11 +170,81 @@ export class MongoStorageAdapter {
})); }));
} }
setIndexesWithSchemaFormat(className, submittedIndexes, existingIndexes = {}, fields) {
if (submittedIndexes === undefined) {
return Promise.resolve();
}
if (Object.keys(existingIndexes).length === 0) {
existingIndexes = { _id_: { _id: 1} };
}
const deletePromises = [];
const insertedIndexes = [];
Object.keys(submittedIndexes).forEach(name => {
const field = submittedIndexes[name];
if (existingIndexes[name] && field.__op !== 'Delete') {
throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`);
}
if (!existingIndexes[name] && field.__op === 'Delete') {
throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} does not exist, cannot delete.`);
}
if (field.__op === 'Delete') {
const promise = this.dropIndex(className, name);
deletePromises.push(promise);
delete existingIndexes[name];
} else {
Object.keys(field).forEach(key => {
if (!fields.hasOwnProperty(key)) {
throw new Parse.Error(Parse.Error.INVALID_QUERY, `Field ${key} does not exist, cannot add index.`);
}
});
existingIndexes[name] = field;
insertedIndexes.push({
key: field,
name,
});
}
});
let insertPromise = Promise.resolve();
if (insertedIndexes.length > 0) {
insertPromise = this.createIndexes(className, insertedIndexes);
}
return Promise.all(deletePromises)
.then(() => insertPromise)
.then(() => this._schemaCollection())
.then(schemaCollection => schemaCollection.updateSchema(className, {
$set: { _metadata: { indexes: existingIndexes } }
}));
}
setIndexesFromMongo(className) {
return this.getIndexes(className).then((indexes) => {
indexes = indexes.reduce((obj, index) => {
if (index.key._fts) {
delete index.key._fts;
delete index.key._ftsx;
for (const field in index.weights) {
index.key[field] = 'text';
}
}
obj[index.name] = index.key;
return obj;
}, {});
return this._schemaCollection()
.then(schemaCollection => schemaCollection.updateSchema(className, {
$set: { _metadata: { indexes: indexes } }
}));
}).catch(() => {
// Ignore if collection not found
return Promise.resolve();
});
}
createClass(className, schema) { createClass(className, schema) {
schema = convertParseSchemaToMongoSchema(schema); schema = convertParseSchemaToMongoSchema(schema);
const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(schema.fields, className, schema.classLevelPermissions); const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(schema.fields, className, schema.classLevelPermissions, schema.indexes);
mongoObject._id = className; mongoObject._id = className;
return this._schemaCollection() return this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields)
.then(() => this._schemaCollection())
.then(schemaCollection => schemaCollection._collection.insertOne(mongoObject)) .then(schemaCollection => schemaCollection._collection.insertOne(mongoObject))
.then(result => MongoSchemaCollection._TESTmongoSchemaToParseSchema(result.ops[0])) .then(result => MongoSchemaCollection._TESTmongoSchemaToParseSchema(result.ops[0]))
.catch(error => { .catch(error => {
@@ -353,7 +428,7 @@ export class MongoStorageAdapter {
}, {}); }, {});
readPreference = this._parseReadPreference(readPreference); readPreference = this._parseReadPreference(readPreference);
return this.createTextIndexesIfNeeded(className, query) return this.createTextIndexesIfNeeded(className, query, schema)
.then(() => this._adaptiveCollection(className)) .then(() => this._adaptiveCollection(className))
.then(collection => collection.find(mongoWhere, { .then(collection => collection.find(mongoWhere, {
skip, skip,
@@ -463,6 +538,11 @@ export class MongoStorageAdapter {
.then(collection => collection._mongoCollection.createIndex(index)); .then(collection => collection._mongoCollection.createIndex(index));
} }
createIndexes(className, indexes) {
return this._adaptiveCollection(className)
.then(collection => collection._mongoCollection.createIndexes(indexes));
}
createIndexesIfNeeded(className, fieldName, type) { createIndexesIfNeeded(className, fieldName, type) {
if (type && type.type === 'Polygon') { if (type && type.type === 'Polygon') {
const index = { const index = {
@@ -473,20 +553,26 @@ export class MongoStorageAdapter {
return Promise.resolve(); return Promise.resolve();
} }
createTextIndexesIfNeeded(className, query) { createTextIndexesIfNeeded(className, query, schema) {
for(const fieldName in query) { for(const fieldName in query) {
if (!query[fieldName] || !query[fieldName].$text) { if (!query[fieldName] || !query[fieldName].$text) {
continue; continue;
} }
const index = { const existingIndexes = schema.indexes;
[fieldName]: 'text' for (const key in existingIndexes) {
const index = existingIndexes[key];
if (index.hasOwnProperty(fieldName)) {
return Promise.resolve();
}
}
const indexName = `${fieldName}_text`;
const textIndex = {
[indexName]: { [fieldName]: 'text' }
}; };
return this.createIndex(className, index) return this.setIndexesWithSchemaFormat(className, textIndex, existingIndexes, schema.fields)
.catch((error) => { .catch((error) => {
if (error.code === 85) { if (error.code === 85) { // Index exist with different options
throw new Parse.Error( return this.setIndexesFromMongo(className);
Parse.Error.INTERNAL_SERVER_ERROR,
'Only one text index is supported, please delete all text indexes to use new field.');
} }
throw error; throw error;
}); });
@@ -498,6 +584,26 @@ export class MongoStorageAdapter {
return this._adaptiveCollection(className) return this._adaptiveCollection(className)
.then(collection => collection._mongoCollection.indexes()); .then(collection => collection._mongoCollection.indexes());
} }
dropIndex(className, index) {
return this._adaptiveCollection(className)
.then(collection => collection._mongoCollection.dropIndex(index));
}
dropAllIndexes(className) {
return this._adaptiveCollection(className)
.then(collection => collection._mongoCollection.dropIndexes());
}
updateSchemaWithIndexes() {
return this.getAllClasses()
.then((classes) => {
const promises = classes.map((schema) => {
return this.setIndexesFromMongo(schema.className);
});
return Promise.all(promises);
});
}
} }
export default MongoStorageAdapter; export default MongoStorageAdapter;

View File

@@ -98,10 +98,15 @@ const toParseSchema = (schema) => {
if (schema.classLevelPermissions) { if (schema.classLevelPermissions) {
clps = {...emptyCLPS, ...schema.classLevelPermissions}; clps = {...emptyCLPS, ...schema.classLevelPermissions};
} }
let indexes = {};
if (schema.indexes) {
indexes = {...schema.indexes};
}
return { return {
className: schema.className, className: schema.className,
fields: schema.fields, fields: schema.fields,
classLevelPermissions: clps, classLevelPermissions: clps,
indexes,
}; };
} }
@@ -608,12 +613,64 @@ export class PostgresStorageAdapter {
}); });
} }
setIndexesWithSchemaFormat(className, submittedIndexes, existingIndexes = {}, fields, conn) {
conn = conn || this._client;
if (submittedIndexes === undefined) {
return Promise.resolve();
}
if (Object.keys(existingIndexes).length === 0) {
existingIndexes = { _id_: { _id: 1} };
}
const deletedIndexes = [];
const insertedIndexes = [];
Object.keys(submittedIndexes).forEach(name => {
const field = submittedIndexes[name];
if (existingIndexes[name] && field.__op !== 'Delete') {
throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`);
}
if (!existingIndexes[name] && field.__op === 'Delete') {
throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} does not exist, cannot delete.`);
}
if (field.__op === 'Delete') {
deletedIndexes.push(name);
delete existingIndexes[name];
} else {
Object.keys(field).forEach(key => {
if (!fields.hasOwnProperty(key)) {
throw new Parse.Error(Parse.Error.INVALID_QUERY, `Field ${key} does not exist, cannot add index.`);
}
});
existingIndexes[name] = field;
insertedIndexes.push({
key: field,
name,
});
}
});
let insertPromise = Promise.resolve();
if (insertedIndexes.length > 0) {
insertPromise = this.createIndexes(className, insertedIndexes, conn);
}
let deletePromise = Promise.resolve();
if (deletedIndexes.length > 0) {
deletePromise = this.dropIndexes(className, deletedIndexes, conn);
}
return deletePromise
.then(() => insertPromise)
.then(() => this._ensureSchemaCollectionExists())
.then(() => {
const values = [className, 'schema', 'indexes', JSON.stringify(existingIndexes)]
return conn.none(`UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className"=$1 `, values);
});
}
createClass(className, schema) { createClass(className, schema) {
return this._client.tx(t => { return this._client.tx(t => {
const q1 = this.createTable(className, schema, t); const q1 = this.createTable(className, schema, t);
const q2 = t.none('INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($<className>, $<schema>, true)', { className, schema }); const q2 = t.none('INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($<className>, $<schema>, true)', { className, schema });
const q3 = this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields, t);
return t.batch([q1, q2]); return t.batch([q1, q2, q3]);
}) })
.then(() => { .then(() => {
return toParseSchema(schema) return toParseSchema(schema)
@@ -1548,6 +1605,25 @@ export class PostgresStorageAdapter {
console.error(error); console.error(error);
}); });
} }
createIndexes(className, indexes, conn) {
return (conn || this._client).tx(t => t.batch(indexes.map(i => {
return t.none('CREATE INDEX $1:name ON $2:name ($3:name)', [i.name, className, i.key]);
})));
}
dropIndexes(className, indexes, conn) {
return (conn || this._client).tx(t => t.batch(indexes.map(i => t.none('DROP INDEX $1:name', i))));
}
getIndexes(className) {
const qs = 'SELECT * FROM pg_indexes WHERE tablename = ${className}';
return this._client.any(qs, {className});
}
updateSchemaWithIndexes() {
return Promise.resolve();
}
} }
function convertPolygonToSQL(polygon) { function convertPolygonToSQL(polygon) {

View File

@@ -1029,9 +1029,11 @@ DatabaseController.prototype.performInitialization = function() {
throw error; throw error;
}); });
const indexPromise = this.adapter.updateSchemaWithIndexes();
// Create tables for volatile classes // Create tables for volatile classes
const adapterInit = this.adapter.performInitialization({ VolatileClassesSchemas: SchemaController.VolatileClassesSchemas }); const adapterInit = this.adapter.performInitialization({ VolatileClassesSchemas: SchemaController.VolatileClassesSchemas });
return Promise.all([usernameUniqueness, emailUniqueness, roleUniqueness, adapterInit]); return Promise.all([usernameUniqueness, emailUniqueness, roleUniqueness, adapterInit, indexPromise]);
} }
function joinTableName(className, key) { function joinTableName(className, key) {

View File

@@ -287,18 +287,28 @@ const convertAdapterSchemaToParseSchema = ({...schema}) => {
schema.fields.password = { type: 'String' }; schema.fields.password = { type: 'String' };
} }
if (schema.indexes && Object.keys(schema.indexes).length === 0) {
delete schema.indexes;
}
return schema; return schema;
} }
const injectDefaultSchema = ({className, fields, classLevelPermissions}) => ({ const injectDefaultSchema = ({className, fields, classLevelPermissions, indexes}) => {
className, const defaultSchema = {
fields: { className,
...defaultColumns._Default, fields: {
...(defaultColumns[className] || {}), ...defaultColumns._Default,
...fields, ...(defaultColumns[className] || {}),
}, ...fields,
classLevelPermissions, },
}); classLevelPermissions,
};
if (indexes && Object.keys(indexes).length !== 0) {
defaultSchema.indexes = indexes;
}
return defaultSchema;
};
const _HooksSchema = {className: "_Hooks", fields: defaultColumns._Hooks}; const _HooksSchema = {className: "_Hooks", fields: defaultColumns._Hooks};
const _GlobalConfigSchema = { className: "_GlobalConfig", fields: defaultColumns._GlobalConfig } const _GlobalConfigSchema = { className: "_GlobalConfig", fields: defaultColumns._GlobalConfig }
@@ -344,6 +354,7 @@ export default class SchemaController {
_dbAdapter; _dbAdapter;
data; data;
perms; perms;
indexes;
constructor(databaseAdapter, schemaCache) { constructor(databaseAdapter, schemaCache) {
this._dbAdapter = databaseAdapter; this._dbAdapter = databaseAdapter;
@@ -352,6 +363,8 @@ export default class SchemaController {
this.data = {}; this.data = {};
// this.perms[className][operation] tells you the acl-style permissions // this.perms[className][operation] tells you the acl-style permissions
this.perms = {}; this.perms = {};
// this.indexes[className][operation] tells you the indexes
this.indexes = {};
} }
reloadData(options = {clearCache: false}) { reloadData(options = {clearCache: false}) {
@@ -370,9 +383,11 @@ export default class SchemaController {
.then(allSchemas => { .then(allSchemas => {
const data = {}; const data = {};
const perms = {}; const perms = {};
const indexes = {};
allSchemas.forEach(schema => { allSchemas.forEach(schema => {
data[schema.className] = injectDefaultSchema(schema).fields; data[schema.className] = injectDefaultSchema(schema).fields;
perms[schema.className] = schema.classLevelPermissions; perms[schema.className] = schema.classLevelPermissions;
indexes[schema.className] = schema.indexes;
}); });
// Inject the in-memory classes // Inject the in-memory classes
@@ -380,13 +395,16 @@ export default class SchemaController {
const schema = injectDefaultSchema({ className }); const schema = injectDefaultSchema({ className });
data[className] = schema.fields; data[className] = schema.fields;
perms[className] = schema.classLevelPermissions; perms[className] = schema.classLevelPermissions;
indexes[className] = schema.indexes;
}); });
this.data = data; this.data = data;
this.perms = perms; this.perms = perms;
this.indexes = indexes;
delete this.reloadDataPromise; delete this.reloadDataPromise;
}, (err) => { }, (err) => {
this.data = {}; this.data = {};
this.perms = {}; this.perms = {};
this.indexes = {};
delete this.reloadDataPromise; delete this.reloadDataPromise;
throw err; throw err;
}); });
@@ -424,7 +442,8 @@ export default class SchemaController {
return Promise.resolve({ return Promise.resolve({
className, className,
fields: this.data[className], fields: this.data[className],
classLevelPermissions: this.perms[className] classLevelPermissions: this.perms[className],
indexes: this.indexes[className]
}); });
} }
return this._cache.getOneSchema(className).then((cached) => { return this._cache.getOneSchema(className).then((cached) => {
@@ -449,13 +468,13 @@ export default class SchemaController {
// on success, and rejects with an error on fail. Ensure you // on success, and rejects with an error on fail. Ensure you
// have authorization (master key, or client class creation // have authorization (master key, or client class creation
// enabled) before calling this function. // enabled) before calling this function.
addClassIfNotExists(className, fields = {}, classLevelPermissions) { addClassIfNotExists(className, fields = {}, classLevelPermissions, indexes = {}) {
var validationError = this.validateNewClass(className, fields, classLevelPermissions); var validationError = this.validateNewClass(className, fields, classLevelPermissions);
if (validationError) { if (validationError) {
return Promise.reject(validationError); return Promise.reject(validationError);
} }
return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className })) return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, indexes, className }))
.then(convertAdapterSchemaToParseSchema) .then(convertAdapterSchemaToParseSchema)
.then((res) => { .then((res) => {
return this._cache.clear().then(() => { return this._cache.clear().then(() => {
@@ -471,7 +490,7 @@ export default class SchemaController {
}); });
} }
updateClass(className, submittedFields, classLevelPermissions, database) { updateClass(className, submittedFields, classLevelPermissions, indexes, database) {
return this.getOneSchema(className) return this.getOneSchema(className)
.then(schema => { .then(schema => {
const existingFields = schema.fields; const existingFields = schema.fields;
@@ -509,7 +528,6 @@ export default class SchemaController {
if (deletedFields.length > 0) { if (deletedFields.length > 0) {
deletePromise = this.deleteFields(deletedFields, className, database); deletePromise = this.deleteFields(deletedFields, className, database);
} }
return deletePromise // Delete Everything return deletePromise // Delete Everything
.then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values
.then(() => { .then(() => {
@@ -520,12 +538,20 @@ export default class SchemaController {
return Promise.all(promises); return Promise.all(promises);
}) })
.then(() => this.setPermissions(className, classLevelPermissions, newSchema)) .then(() => this.setPermissions(className, classLevelPermissions, newSchema))
.then(() => this._dbAdapter.setIndexesWithSchemaFormat(className, indexes, schema.indexes, newSchema))
.then(() => this.reloadData({ clearCache: true }))
//TODO: Move this logic into the database adapter //TODO: Move this logic into the database adapter
.then(() => ({ .then(() => {
className: className, const reloadedSchema = {
fields: this.data[className], className: className,
classLevelPermissions: this.perms[className] fields: this.data[className],
})); classLevelPermissions: this.perms[className],
};
if (this.indexes[className] && Object.keys(this.indexes[className]).length !== 0) {
reloadedSchema.indexes = this.indexes[className];
}
return reloadedSchema;
});
}) })
.catch(error => { .catch(error => {
if (error === undefined) { if (error === undefined) {
@@ -620,8 +646,7 @@ export default class SchemaController {
return Promise.resolve(); return Promise.resolve();
} }
validateCLP(perms, newSchema); validateCLP(perms, newSchema);
return this._dbAdapter.setClassLevelPermissions(className, perms) return this._dbAdapter.setClassLevelPermissions(className, perms);
.then(() => this.reloadData({ clearCache: true }));
} }
// Returns a promise that resolves successfully to the new schema // Returns a promise that resolves successfully to the new schema

View File

@@ -49,7 +49,7 @@ function createSchema(req) {
} }
return req.config.database.loadSchema({ clearCache: true}) return req.config.database.loadSchema({ clearCache: true})
.then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions, req.body.indexes))
.then(schema => ({ response: schema })); .then(schema => ({ response: schema }));
} }
@@ -65,7 +65,7 @@ function modifySchema(req) {
const className = req.params.className; const className = req.params.className;
return req.config.database.loadSchema({ clearCache: true}) return req.config.database.loadSchema({ clearCache: true})
.then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.config.database)) .then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.body.indexes, req.config.database))
.then(result => ({response: result})); .then(result => ({response: result}));
} }