Skip to content

Commit

Permalink
feat: Add compatibility for MongoDB Atlas Serverless and AWS Amazon D…
Browse files Browse the repository at this point in the history
…ocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` (parse-community#8805)
  • Loading branch information
mattia1208 authored Nov 13, 2023
1 parent 80b987d commit 09fbeeb
Show file tree
Hide file tree
Showing 5 changed files with 323 additions and 14 deletions.
254 changes: 254 additions & 0 deletions spec/DatabaseController.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Config = require('../lib/Config');
const DatabaseController = require('../lib/Controllers/DatabaseController.js');
const validateQuery = DatabaseController._validateQuery;

Expand Down Expand Up @@ -361,6 +362,259 @@ describe('DatabaseController', function () {
done();
});
});

describe('enableCollationCaseComparison', () => {
const dummyStorageAdapter = {
find: () => Promise.resolve([]),
watch: () => Promise.resolve(),
getAllClasses: () => Promise.resolve([]),
};

beforeEach(() => {
Config.get(Parse.applicationId).schemaCache.clear();
});

it('should force caseInsensitive to false with enableCollationCaseComparison option', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
enableCollationCaseComparison: true,
});
const spy = spyOn(dummyStorageAdapter, 'find');
spy.and.callThrough();
await databaseController.find('SomeClass', {}, { caseInsensitive: true });
expect(spy.calls.all()[0].args[3].caseInsensitive).toEqual(false);
});

it('should support caseInsensitive without enableCollationCaseComparison option', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {});
const spy = spyOn(dummyStorageAdapter, 'find');
spy.and.callThrough();
await databaseController.find('_User', {}, { caseInsensitive: true });
expect(spy.calls.all()[0].args[3].caseInsensitive).toEqual(true);
});

it_only_db('mongo')(
'should create insensitive indexes without enableCollationCaseComparison',
async () => {
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/enableCollationCaseComparisonFalse',
databaseAdapter: undefined,
});
const user = new Parse.User();
await user.save({
username: 'example',
password: 'password',
email: 'example@example.com',
});
const schemas = await Parse.Schema.all();
const UserSchema = schemas.find(({ className }) => className === '_User');
expect(UserSchema.indexes).toEqual({
_id_: { _id: 1 },
username_1: { username: 1 },
case_insensitive_username: { username: 1 },
case_insensitive_email: { email: 1 },
email_1: { email: 1 },
});
}
);

it_only_db('mongo')(
'should not create insensitive indexes with enableCollationCaseComparison',
async () => {
await reconfigureServer({
enableCollationCaseComparison: true,
databaseURI: 'mongodb://localhost:27017/enableCollationCaseComparisonTrue',
databaseAdapter: undefined,
});
const user = new Parse.User();
await user.save({
username: 'example',
password: 'password',
email: 'example@example.com',
});
const schemas = await Parse.Schema.all();
const UserSchema = schemas.find(({ className }) => className === '_User');
expect(UserSchema.indexes).toEqual({
_id_: { _id: 1 },
username_1: { username: 1 },
email_1: { email: 1 },
});
}
);
});

describe('convertEmailToLowercase', () => {
const dummyStorageAdapter = {
createObject: () => Promise.resolve({ ops: [{}] }),
findOneAndUpdate: () => Promise.resolve({}),
watch: () => Promise.resolve(),
getAllClasses: () =>
Promise.resolve([
{
className: '_User',
fields: { email: 'String' },
indexes: {},
classLevelPermissions: { protectedFields: {} },
},
]),
};
const dates = {
createdAt: { iso: undefined, __type: 'Date' },
updatedAt: { iso: undefined, __type: 'Date' },
};

it('should not transform email to lower case without convertEmailToLowercase option on create', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {});
const spy = spyOn(dummyStorageAdapter, 'createObject');
spy.and.callThrough();
await databaseController.create('_User', {
email: 'EXAMPLE@EXAMPLE.COM',
});
expect(spy.calls.all()[0].args[2]).toEqual({
email: 'EXAMPLE@EXAMPLE.COM',
...dates,
});
});

it('should transform email to lower case with convertEmailToLowercase option on create', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
convertEmailToLowercase: true,
});
const spy = spyOn(dummyStorageAdapter, 'createObject');
spy.and.callThrough();
await databaseController.create('_User', {
email: 'EXAMPLE@EXAMPLE.COM',
});
expect(spy.calls.all()[0].args[2]).toEqual({
email: 'example@example.com',
...dates,
});
});

it('should not transform email to lower case without convertEmailToLowercase option on update', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {});
const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate');
spy.and.callThrough();
await databaseController.update('_User', { id: 'example' }, { email: 'EXAMPLE@EXAMPLE.COM' });
expect(spy.calls.all()[0].args[3]).toEqual({
email: 'EXAMPLE@EXAMPLE.COM',
});
});

it('should transform email to lower case with convertEmailToLowercase option on update', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
convertEmailToLowercase: true,
});
const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate');
spy.and.callThrough();
await databaseController.update('_User', { id: 'example' }, { email: 'EXAMPLE@EXAMPLE.COM' });
expect(spy.calls.all()[0].args[3]).toEqual({
email: 'example@example.com',
});
});

it('should not find a case insensitive user by email with convertEmailToLowercase', async () => {
await reconfigureServer({ convertEmailToLowercase: true });
const user = new Parse.User();
await user.save({ username: 'EXAMPLE', email: 'EXAMPLE@EXAMPLE.COM', password: 'password' });

const query = new Parse.Query(Parse.User);
query.equalTo('email', 'EXAMPLE@EXAMPLE.COM');
const result = await query.find({ useMasterKey: true });
expect(result.length).toEqual(0);

const query2 = new Parse.Query(Parse.User);
query2.equalTo('email', 'example@example.com');
const result2 = await query2.find({ useMasterKey: true });
expect(result2.length).toEqual(1);
});
});

describe('convertUsernameToLowercase', () => {
const dummyStorageAdapter = {
createObject: () => Promise.resolve({ ops: [{}] }),
findOneAndUpdate: () => Promise.resolve({}),
watch: () => Promise.resolve(),
getAllClasses: () =>
Promise.resolve([
{
className: '_User',
fields: { username: 'String' },
indexes: {},
classLevelPermissions: { protectedFields: {} },
},
]),
};
const dates = {
createdAt: { iso: undefined, __type: 'Date' },
updatedAt: { iso: undefined, __type: 'Date' },
};

it('should not transform username to lower case without convertUsernameToLowercase option on create', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {});
const spy = spyOn(dummyStorageAdapter, 'createObject');
spy.and.callThrough();
await databaseController.create('_User', {
username: 'EXAMPLE',
});
expect(spy.calls.all()[0].args[2]).toEqual({
username: 'EXAMPLE',
...dates,
});
});

it('should transform username to lower case with convertUsernameToLowercase option on create', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
convertUsernameToLowercase: true,
});
const spy = spyOn(dummyStorageAdapter, 'createObject');
spy.and.callThrough();
await databaseController.create('_User', {
username: 'EXAMPLE',
});
expect(spy.calls.all()[0].args[2]).toEqual({
username: 'example',
...dates,
});
});

it('should not transform username to lower case without convertUsernameToLowercase option on update', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {});
const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate');
spy.and.callThrough();
await databaseController.update('_User', { id: 'example' }, { username: 'EXAMPLE' });
expect(spy.calls.all()[0].args[3]).toEqual({
username: 'EXAMPLE',
});
});

it('should transform username to lower case with convertUsernameToLowercase option on update', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
convertUsernameToLowercase: true,
});
const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate');
spy.and.callThrough();
await databaseController.update('_User', { id: 'example' }, { username: 'EXAMPLE' });
expect(spy.calls.all()[0].args[3]).toEqual({
username: 'example',
});
});

it('should not find a case insensitive user by username with convertUsernameToLowercase', async () => {
await reconfigureServer({ convertUsernameToLowercase: true });
const user = new Parse.User();
await user.save({ username: 'EXAMPLE', password: 'password' });

const query = new Parse.Query(Parse.User);
query.equalTo('username', 'EXAMPLE');
const result = await query.find({ useMasterKey: true });
expect(result.length).toEqual(0);

const query2 = new Parse.Query(Parse.User);
query2.equalTo('username', 'example');
const result2 = await query2.find({ useMasterKey: true });
expect(result2.length).toEqual(1);
});
});
});

function buildCLP(pointerNames) {
Expand Down
50 changes: 36 additions & 14 deletions src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,22 @@ const relationSchema = {
fields: { relatedId: { type: 'String' }, owningId: { type: 'String' } },
};

const convertEmailToLowercase = (object, className, options) => {
if (className === '_User' && options.convertEmailToLowercase) {
if (typeof object['email'] === 'string') {
object['email'] = object['email'].toLowerCase();
}
}
};

const convertUsernameToLowercase = (object, className, options) => {
if (className === '_User' && options.convertUsernameToLowercase) {
if (typeof object['username'] === 'string') {
object['username'] = object['username'].toLowerCase();
}
}
};

class DatabaseController {
adapter: StorageAdapter;
schemaCache: any;
Expand Down Expand Up @@ -573,6 +589,8 @@ class DatabaseController {
}
}
update = transformObjectACL(update);
convertEmailToLowercase(update, className, this.options);
convertUsernameToLowercase(update, className, this.options);
transformAuthData(className, update, schema);
if (validateOnly) {
return this.adapter.find(className, schema, query, {}).then(result => {
Expand Down Expand Up @@ -822,6 +840,8 @@ class DatabaseController {
const originalObject = object;
object = transformObjectACL(object);

convertEmailToLowercase(object, className, this.options);
convertUsernameToLowercase(object, className, this.options);
object.createdAt = { iso: object.createdAt, __type: 'Date' };
object.updatedAt = { iso: object.updatedAt, __type: 'Date' };

Expand Down Expand Up @@ -1215,7 +1235,7 @@ class DatabaseController {
keys,
readPreference,
hint,
caseInsensitive,
caseInsensitive: this.options.enableCollationCaseComparison ? false : caseInsensitive,
explain,
};
Object.keys(sort).forEach(fieldName => {
Expand Down Expand Up @@ -1719,25 +1739,27 @@ class DatabaseController {
throw error;
});

await this.adapter
.ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true)
.catch(error => {
logger.warn('Unable to create case insensitive username index: ', error);
throw error;
});
if (!this.options.enableCollationCaseComparison) {
await this.adapter
.ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true)
.catch(error => {
logger.warn('Unable to create case insensitive username index: ', error);
throw error;
});

await this.adapter
.ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true)
.catch(error => {
logger.warn('Unable to create case insensitive email index: ', error);
throw error;
});
}

await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => {
logger.warn('Unable to ensure uniqueness for user email addresses: ', error);
throw error;
});

await this.adapter
.ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true)
.catch(error => {
logger.warn('Unable to create case insensitive email index: ', error);
throw error;
});

await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => {
logger.warn('Unable to ensure uniqueness for role name: ', error);
throw error;
Expand Down
21 changes: 21 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ module.exports.ParseServerOptions = {
help: 'A collection prefix for the classes',
default: '',
},
convertEmailToLowercase: {
env: 'PARSE_SERVER_CONVERT_EMAIL_TO_LOWERCASE',
help:
'Optional. If set to `true`, the `email` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `email` property is stored as set, without any case modifications. Default is `false`.',
action: parsers.booleanParser,
default: false,
},
convertUsernameToLowercase: {
env: 'PARSE_SERVER_CONVERT_USERNAME_TO_LOWERCASE',
help:
'Optional. If set to `true`, the `username` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `username` property is stored as set, without any case modifications. Default is `false`.',
action: parsers.booleanParser,
default: false,
},
customPages: {
env: 'PARSE_SERVER_CUSTOM_PAGES',
help: 'custom pages for password validation and reset',
Expand Down Expand Up @@ -203,6 +217,13 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: true,
},
enableCollationCaseComparison: {
env: 'PARSE_SERVER_ENABLE_COLLATION_CASE_COMPARISON',
help:
'Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`.',
action: parsers.booleanParser,
default: false,
},
enableExpressErrorHandler: {
env: 'PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER',
help: 'Enables the default express error handler for all errors',
Expand Down
Loading

0 comments on commit 09fbeeb

Please sign in to comment.