Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options enableCollationCaseComparison, convertEmailToLowercase, convertUsernameToLowercase #8805

Merged
merged 5 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 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,253 @@ describe('DatabaseController', function () {
done();
});
});

describe('disableCollation', () => {
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 disableCollation option', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
disableCollation: 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 disableCollation 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 disableCollation', async () => {
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/disableCollationFalse',
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 disableCollation', async () => {
await reconfigureServer({
disableCollation: true,
databaseURI: 'mongodb://localhost:27017/disableCollationTrue',
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('transformEmailToLowerCase', () => {
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 transformEmailToLowerCase 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 transformEmailToLowerCase option on create', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
transformEmailToLowerCase: 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 transformEmailToLowerCase 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 transformEmailToLowerCase option on update', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
transformEmailToLowerCase: 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 transformEmailToLowerCase', async () => {
await reconfigureServer({ transformEmailToLowerCase: true });
const user = new Parse.User();
await user.save({ 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('transformUsernameToLowerCase', () => {
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 transformUsernameToLowerCase 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 transformUsernameToLowerCase option on create', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
transformUsernameToLowerCase: 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 transformUsernameToLowerCase 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 transformUsernameToLowerCase option on update', async () => {
const databaseController = new DatabaseController(dummyStorageAdapter, {
transformUsernameToLowerCase: 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 transformUsernameToLowerCase', async () => {
await reconfigureServer({ transformUsernameToLowerCase: 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
20 changes: 20 additions & 0 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 transformEmailToLowerCase = (object, className, options) => {
if (className === '_User' && options.transformEmailToLowerCase) {
if (typeof object['email'] === 'string') {
object['email'] = object['email'].toLowerCase();
}
}
};

const transformUsernameToLowerCase = (object, className, options) => {
if (className === '_User' && options.transformUsernameToLowerCase) {
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);
transformEmailToLowerCase(update, className, this.options);
transformUsernameToLowerCase(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);

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

Expand Down
18 changes: 18 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: true,
},
disableCollation: {
env: 'PARSE_SERVER_DISABLE_COLLATION',
help:
'Disable case insensitivity (collation) on queries and indexes, needed if you use MongoDB serverless or AWS DocumentDB.',
action: parsers.booleanParser,
},
dotNetKey: {
env: 'PARSE_SERVER_DOT_NET_KEY',
help: 'Key for Unity and .Net SDK',
Expand Down Expand Up @@ -533,6 +539,18 @@ module.exports.ParseServerOptions = {
help: 'Starts the liveQuery server',
action: parsers.booleanParser,
},
transformEmailToLowerCase: {
env: 'PARSE_SERVER_TRANSFORM_EMAIL_TO_LOWER_CASE',
help:
'Transform Email to lowercase on create/update/login/signup. On queries client needs to ensure to send Email in lowercase format.',
action: parsers.booleanParser,
},
transformUsernameToLowerCase: {
env: 'PARSE_SERVER_TRANSFORM_USERNAME_TO_LOWER_CASE',
help:
'Transform Username to lowercase on create/update/login/signup. On queries client needs to ensure to send Username in lowercase format.',
action: parsers.booleanParser,
},
trustProxy: {
env: 'PARSE_SERVER_TRUST_PROXY',
help:
Expand Down
2 changes: 2 additions & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ export interface ParseServerOptions {
databaseOptions: ?DatabaseOptions;
/* Adapter module for the database; any options that are not explicitly described here are passed directly to the database client. */
databaseAdapter: ?Adapter<StorageAdapter>;
/* 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`.
:DEFAULT: false */
enableCollationCaseComparison: ?boolean;
/* 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`.
:DEFAULT: false */
convertEmailToLowercase: ?boolean;
/* 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`.
:DEFAULT: false */
convertUsernameToLowercase: ?boolean;
/* Full path to your cloud code main.js */
cloud: ?string;
/* A collection prefix for the classes
Expand Down
Loading