Skip to content

Commit d934f3a

Browse files
committed
Add class creation logic with validation
1 parent 6a3718e commit d934f3a

File tree

4 files changed

+298
-11
lines changed

4 files changed

+298
-11
lines changed

ExportAdapter.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,7 @@ ExportAdapter.prototype.connect = function() {
6060
var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;
6161
var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
6262
ExportAdapter.prototype.collection = function(className) {
63-
if (className !== '_User' &&
64-
className !== '_Installation' &&
65-
className !== '_Session' &&
66-
className !== '_SCHEMA' &&
67-
className !== '_Role' &&
68-
!joinRegex.test(className) &&
69-
!otherRegex.test(className)) {
63+
if (!Schema.classNameIsValid(className)) {
7064
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME,
7165
'invalid className: ' + className);
7266
}

Schema.js

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,137 @@
1717
var Parse = require('parse/node').Parse;
1818
var transform = require('./transform');
1919

20+
defaultColumns = {
21+
// Contain the default columns for every parse object type (except _Join collection)
22+
_Default: {
23+
"objectId": {type:'String'},
24+
"createdAt": {type:'Date'},
25+
"updatedAt": {type:'Date'},
26+
"ACL": {type:'ACL'},
27+
},
28+
// The additional default columns for the _User collection (in addition to DefaultCols)
29+
_User: {
30+
"username": {type:'String'},
31+
"password": {type:'String'},
32+
"authData": {type:'Object'},
33+
"email": {type:'String'},
34+
"emailVerified": {type:'Boolean'},
35+
},
36+
// The additional default columns for the _User collection (in addition to DefaultCols)
37+
_Installation: {
38+
"installationId": {type:'String'},
39+
"deviceToken": {type:'String'},
40+
"channels": {type:'Array'},
41+
"deviceType": {type:'String'},
42+
"pushType": {type:'String'},
43+
"GCMSenderId": {type:'String'},
44+
"timeZone": {type:'String'},
45+
"localeIdentifier": {type:'String'},
46+
"badge": {type:'Number'},
47+
},
48+
// The additional default columns for the _User collection (in addition to DefaultCols)
49+
_Role: {
50+
"name": {type:'String'},
51+
"users": {type:'Relation',className:'_User'},
52+
"roles": {type:'Relation',className:'_Role'},
53+
},
54+
// The additional default columns for the _User collection (in addition to DefaultCols)
55+
_Session: {
56+
"restricted": {type:'Boolean'},
57+
"user": {type:'Pointer', className:'_User'},
58+
"installationId": {type:'String'},
59+
"sessionToken": {type:'String'},
60+
"expiresAt": {type:'Date'},
61+
"createdWith": {type:'Object'},
62+
},
63+
}
64+
65+
// Valid classes must:
66+
// Be one of _User, _Installation, _Role, _Session OR
67+
// Be a join table OR
68+
// Include only alpha-numeric and underscores, and not start with an underscore or number
69+
var joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;
70+
var classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
71+
function classNameIsValid(className) {
72+
return (
73+
className === '_User' ||
74+
className === '_Installation' ||
75+
className === '_Session' ||
76+
className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects.
77+
className === '_Role' ||
78+
joinClassRegex.test(className) ||
79+
classAndFieldRegex.test(className)
80+
);
81+
}
82+
83+
// Valid fields must be alpha-numeric, and not start with an underscore or number
84+
function fieldNameIsValid(fieldName) {
85+
return classAndFieldRegex.test(fieldName);
86+
}
87+
88+
// Checks that it's not trying to clobber one of the default fields of the class.
89+
function fieldNameIsValidForClass(fieldName, className) {
90+
if (!fieldNameIsValid(fieldName)) {
91+
return false;
92+
}
93+
if (defaultColumns._Default[fieldName]) {
94+
return false;
95+
}
96+
if (defaultColumns[className] && defaultColumns[className][fieldName]) {
97+
return false;
98+
}
99+
return true;
100+
}
101+
102+
function invalidClassNameMessage(className) {
103+
if (!className) {
104+
className = '';
105+
}
106+
return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character ';
107+
}
108+
109+
// Returns { error: "message", code: ### } if the type could not be
110+
// converted, otherwise returns a returns { result: "mongotype" }
111+
// where mongotype is suitable for inserting into mongo _SCHEMA collection
112+
function schemaAPITypeToMongoFieldType(type) {
113+
var invalidJsonError = { error: "invalid JSON", code: Parse.Error.INVALID_JSON };
114+
if (type.type == 'Pointer') {
115+
if (!type.targetClass) {
116+
return { error: 'type Pointer needs a class name', code: 135 };
117+
} else if (typeof type.targetClass !== 'string') {
118+
return invalidJsonError;
119+
} else if (!classNameIsValid(type.targetClass)) {
120+
return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME };
121+
} else {
122+
return { result: '*' + type.targetClass };
123+
}
124+
}
125+
if (type.type == 'Relation') {
126+
if (!type.targetClass) {
127+
return { error: 'type Relation needs a class name', code: 135 };
128+
} else if (typeof type.targetClass !== 'string') {
129+
return invalidJsonError;
130+
} else if (!classNameIsValid(type.targetClass)) {
131+
return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME };
132+
} else {
133+
return { result: 'relation<' + type.targetClass + '>' };
134+
}
135+
}
136+
if (typeof type.type !== 'string') {
137+
return { error: "invalid JSON", code: Parse.Error.INVALID_JSON };
138+
}
139+
switch (type.type) {
140+
default : return { error: 'invalid field type: ' + type.type };
141+
case 'Number': return { result: 'number' };
142+
case 'String': return { result: 'string' };
143+
case 'Boolean': return { result: 'boolean' };
144+
case 'Date': return { result: 'date' };
145+
case 'Object': return { result: 'object' };
146+
case 'Array': return { result: 'array' };
147+
case 'GeoPoint': return { result: 'geopoint' };
148+
case 'File': return { result: 'file' };
149+
}
150+
}
20151

21152
// Create a schema from a Mongo collection and the exported schema format.
22153
// mongoSchema should be a list of objects, each with:
@@ -71,9 +202,72 @@ Schema.prototype.reload = function() {
71202
return load(this.collection);
72203
};
73204

205+
// Create a new class that includes the three default fields.
206+
// ACL is an implicit column that does not get an entry in the
207+
// _SCHEMAS database. Returns a promise that resolves with the
208+
// created schema, in mongo format.
209+
// on success, and rejects with an error on fail. Ensure you
210+
// have authorization (master key, or client class creation
211+
// enabled) before calling this function.
212+
Schema.prototype.addClassIfNotExists = function(className, fields) {
213+
if (this.data[className]) {
214+
return Promise.reject(new Parse.Error(
215+
Parse.Error.DUPLICATE_VALUE,
216+
'class ' + className + ' already exists'
217+
));
218+
}
219+
220+
if (!classNameIsValid(className)) {
221+
return Promise.reject({
222+
code: Parse.Error.INVALID_CLASS_NAME,
223+
error: invalidClassNameMessage(className),
224+
});
225+
return Promise.reject({
226+
code: Parse.Error.INVALID_CLASS_NAME,
227+
});
228+
}
229+
for (fieldName in fields) {
230+
if (!fieldNameIsValid(fieldName)) {
231+
return Promise.reject({
232+
code: Parse.Error.INVALID_KEY_NAME,
233+
error: 'invalid field name: ' + fieldName,
234+
});
235+
}
236+
if (!fieldNameIsValidForClass(fieldName, className)) {
237+
return Promise.reject({
238+
code: 136,
239+
error: 'field ' + fieldName + ' cannot be added',
240+
});
241+
}
242+
}
243+
244+
245+
246+
return this.collection.insertOne({
247+
_id: className,
248+
objectId: 'string',
249+
updatedAt: 'string',
250+
createdAt: 'string',
251+
})
252+
.then(result => result.ops[0])
253+
.catch(error => {
254+
if (error.code === 11000) { //Mongo's duplicate key error
255+
return Promise.reject({
256+
code: Parse.Error.INVALID_CLASS_NAME,
257+
error: 'class ' + className + ' already exists',
258+
});
259+
}
260+
return Promise.reject(error);
261+
});
262+
}
263+
74264
// Returns a promise that resolves successfully to the new schema
75-
// object.
265+
// object or fails with a reason.
76266
// If 'freeze' is true, refuse to update the schema.
267+
// WARNING: this function has side-effects, and doesn't actually
268+
// do any validation of the format of the className. You probably
269+
// should use classNameIsValid or addClassIfNotExists or something
270+
// like that instead. TODO: rename or remove this function.
77271
Schema.prototype.validateClassName = function(className, freeze) {
78272
if (this.data[className]) {
79273
return Promise.resolve(this);
@@ -348,5 +542,6 @@ function getObjectType(obj) {
348542

349543

350544
module.exports = {
351-
load: load
545+
load: load,
546+
classNameIsValid: classNameIsValid,
352547
};

schemas.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ var express = require('express'),
55

66
var router = new PromiseRouter();
77

8-
function mongoFieldTypeToApiResponseType(type) {
8+
function mongoFieldTypeToSchemaAPIType(type) {
99
if (type[0] === '*') {
1010
return {
1111
type: 'Pointer',
@@ -34,7 +34,7 @@ function mongoSchemaAPIResponseFields(schema) {
3434
fieldNames = Object.keys(schema).filter(key => key !== '_id');
3535
response = {};
3636
fieldNames.forEach(fieldName => {
37-
response[fieldName] = mongoFieldTypeToApiResponseType(schema[fieldName]);
37+
response[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]);
3838
});
3939
response.ACL = {type: 'ACL'};
4040
response.createdAt = {type: 'Date'};

spec/Schema.spec.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,102 @@ describe('Schema', () => {
131131
});
132132
});
133133
});
134+
135+
it('can add classes without needing an object', done => {
136+
config.database.loadSchema()
137+
.then(schema => schema.addClassIfNotExists('NewClass', {
138+
foo: {type: 'String'}
139+
}))
140+
.then(result => {
141+
expect(result).toEqual({
142+
_id: 'NewClass',
143+
objectId: 'string',
144+
updatedAt: 'string',
145+
createdAt: 'string'
146+
})
147+
done();
148+
});
149+
});
150+
151+
it('will fail to create a class if that class was already created by an object', done => {
152+
config.database.loadSchema()
153+
.then(schema => {
154+
schema.validateObject('NewClass', {foo: 7})
155+
.then(() => {
156+
schema.addClassIfNotExists('NewClass', {
157+
foo: {type: 'String'}
158+
}).catch(error => {
159+
expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME)
160+
expect(error.error).toEqual('class NewClass already exists');
161+
done();
162+
});
163+
});
164+
})
165+
});
166+
167+
it('will resolve class creation races appropriately', done => {
168+
// If two callers race to create the same schema, the response to the
169+
// loser should be the same as if they hadn't been racing. Furthermore,
170+
// The caller that wins the race should resolve it's promise before the
171+
// caller that loses the race.
172+
config.database.loadSchema()
173+
.then(schema => {
174+
var p1 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}});
175+
var p2 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}});
176+
var raceWinnerHasSucceeded = false;
177+
var raceLoserHasFailed = false;
178+
Promise.race([p1, p2]) //Use race because we expect the first completed promise to be the successful one
179+
.then(response => {
180+
raceWinnerHasSucceeded = true;
181+
expect(raceLoserHasFailed).toEqual(false);
182+
expect(response).toEqual({
183+
_id: 'NewClass',
184+
objectId: 'string',
185+
updatedAt: 'string',
186+
createdAt: 'string'
187+
});
188+
});
189+
Promise.all([p1,p2])
190+
.catch(error => {
191+
expect(raceWinnerHasSucceeded).toEqual(true);
192+
expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME);
193+
expect(error.error).toEqual('class NewClass already exists');
194+
done();
195+
raceLoserHasFailed = true;
196+
});
197+
});
198+
});
199+
200+
it('refuses to create classes with invalid names', done => {
201+
config.database.loadSchema()
202+
.then(schema => {
203+
schema.addClassIfNotExists('_InvalidName', {foo: {type: 'String'}})
204+
.catch(error => {
205+
expect(error.error).toEqual(
206+
'Invalid classname: _InvalidName, classnames can only have alphanumeric characters and _, and must start with an alpha character '
207+
);
208+
done();
209+
});
210+
});
211+
});
212+
213+
it('refuses to add fields with invalid names', done => {
214+
config.database.loadSchema()
215+
.then(schema => schema.addClassIfNotExists('NewClass', {'0InvalidName': {type: 'String'}}))
216+
.catch(error => {
217+
expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME);
218+
expect(error.error).toEqual('invalid field name: 0InvalidName');
219+
done();
220+
});
221+
});
222+
223+
it('refuses to explicitly create the default fields', done => {
224+
config.database.loadSchema()
225+
.then(schema => schema.addClassIfNotExists('_Installation', {localeIdentifier: {type: 'String'}}))
226+
.catch(error => {
227+
expect(error.code).toEqual(136);
228+
expect(error.error).toEqual('field localeIdentifier cannot be added');
229+
done();
230+
});
231+
});
134232
});

0 commit comments

Comments
 (0)