diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 1fff4fad59..6c7b284f35 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -803,6 +803,130 @@ describe('rest create', () => { ); }); + it('supports ignoreIncludeErrors for unreadable pointers', async () => { + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'IncludeChild', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { + get: { pointerFields: ['owner'] }, + find: { pointerFields: ['owner'] }, + } + ); + await config.schemaCache.clear(); + + const owner = await Parse.User.signUp('includeOwner', 'password'); + const child = new Parse.Object('IncludeChild'); + child.set('owner', owner); + child.set('label', 'unreadable'); + await child.save(null, { useMasterKey: true }); + + const parent = new Parse.Object('IncludeParent'); + parent.set('child', child); + const parentACL = new Parse.ACL(); + parentACL.setPublicReadAccess(true); + parentACL.setPublicWriteAccess(false); + parent.setACL(parentACL); + await parent.save(null, { useMasterKey: true }); + + await Parse.User.logOut(); + + const headers = { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + const baseUrl = `${Parse.serverURL}/classes/IncludeParent/${parent.id}?include=child`; + + await expectAsync( + request({ + method: 'GET', + url: baseUrl, + headers, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 404, + data: jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND }), + }) + ); + + // when we try to include and unreadable child & ignore include errors + // then the raw pointer is simply returned unhydrated. + const response = await request({ + method: 'GET', + url: `${baseUrl}&ignoreIncludeErrors=true`, + headers, + }); + + expect(response.status).toBe(200); + expect(response.data.child).toEqual( + jasmine.objectContaining({ + __type: 'Pointer', + className: 'IncludeChild', + objectId: child.id, + }) + ); + }); + + it('preserves unresolved pointers in arrays when ignoreIncludeErrors is true', async () => { + const childOne = await new Parse.Object('IgnoreIncludeChild').save({ name: 'first' }); + const childTwo = await new Parse.Object('IgnoreIncludeChild').save({ name: 'second' }); + + const parent = new Parse.Object('IgnoreIncludeParent'); + parent.set('primary', childOne); + parent.set('others', [childOne, childTwo]); + await parent.save(); + + await childOne.destroy({ useMasterKey: true }); + + const headers = { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + const baseUrl = `${Parse.serverURL}/classes/IgnoreIncludeParent/${parent.id}?include=primary,others`; + + const defaultResponse = await request({ + method: 'GET', + url: baseUrl, + headers, + }); + expect(defaultResponse.status).toBe(200); + expect(Array.isArray(defaultResponse.data.others)).toBeTrue(); + expect(defaultResponse.data.others.length).toBe(1); + + const response = await request({ + method: 'GET', + url: `${baseUrl}&ignoreIncludeErrors=true`, + headers, + }); + + expect(response.status).toBe(200); + expect(response.data.primary).toEqual( + jasmine.objectContaining({ + __type: 'Pointer', + className: 'IgnoreIncludeChild', + objectId: childOne.id, + }) + ); + expect(response.data.others.length).toBe(2); + expect(response.data.others[0]).toEqual( + jasmine.objectContaining({ + __type: 'Pointer', + className: 'IgnoreIncludeChild', + objectId: childOne.id, + }) + ); + expect(response.data.others[1]).toEqual( + jasmine.objectContaining({ + __type: 'Object', + className: 'IgnoreIncludeChild', + objectId: childTwo.id, + }) + ); + }); + it('locks down session', done => { let currentUser; Parse.User.signUp('foo', 'bar') diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index d25c9753c0..bf006a05fa 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -20,6 +20,7 @@ export type QueryOptions = { action?: string, addsField?: boolean, comment?: string, + ignoreIncludeErrors?: boolean, }; export type UpdateQueryOptions = { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 15207a7295..77e5909ecb 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1189,6 +1189,7 @@ class DatabaseController { caseInsensitive = false, explain, comment, + ignoreIncludeErrors, }: any = {}, auth: any = {}, validSchemaController: SchemaController.SchemaController @@ -1285,6 +1286,13 @@ class DatabaseController { } if (!query) { if (op === 'get') { + // If there's no query returned; then it didn't pass `addPointerPermissions` + // permissions checks + // Default is to return OBJECT_NOT_FOUND, but if we ignore include errors we can + // return [] here. + if (ignoreIncludeErrors) { + return []; + } throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } else { return []; diff --git a/src/RestQuery.js b/src/RestQuery.js index dd226f249c..d542ddf336 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -209,6 +209,7 @@ function _UnsafeRestQuery( case 'includeAll': this.includeAll = true; break; + // Propagate these options from restOptions to findOptions too case 'explain': case 'hint': case 'distinct': @@ -217,6 +218,7 @@ function _UnsafeRestQuery( case 'limit': case 'readPreference': case 'comment': + case 'ignoreIncludeErrors': this.findOptions[option] = restOptions[option]; break; case 'order': @@ -1018,6 +1020,13 @@ function includePath(config, auth, response, path, context, restOptions = {}) { } else if (restOptions.readPreference) { includeRestOptions.readPreference = restOptions.readPreference; } + // Flag for replacePointers if missing pointers should be preserved without throwing errors + // defaults to false to continue previous behaviour + let preserveMissing = false; + if (restOptions.ignoreIncludeErrors) { + includeRestOptions.ignoreIncludeErrors = restOptions.ignoreIncludeErrors; + preserveMissing = true; + } const queryPromises = Object.keys(pointersHash).map(async className => { const objectIds = Array.from(pointersHash[className]); @@ -1059,7 +1068,9 @@ function includePath(config, auth, response, path, context, restOptions = {}) { }, {}); var resp = { - results: replacePointers(response.results, path, replace), + results: replacePointers(response.results, path, replace, { + preserveMissing, + }), }; if (response.count) { resp.count = response.count; @@ -1100,13 +1111,15 @@ function findPointers(object, path) { // in, or it may be a single object. // Path is a list of fields to search into. // replace is a map from object id -> object. +// `options` is an optional options object; options currently include +// `preserveMissing?: boolean` where if it is true // Returns something analogous to object, but with the appropriate // pointers inflated. -function replacePointers(object, path, replace) { +function replacePointers(object, path, replace, options = {}) { + const preserveMissing = !!options.preserveMissing; if (object instanceof Array) { - return object - .map(obj => replacePointers(obj, path, replace)) - .filter(obj => typeof obj !== 'undefined'); + const mapped = object.map(obj => replacePointers(obj, path, replace, options)); + return preserveMissing ? mapped : mapped.filter(obj => typeof obj !== 'undefined'); } if (typeof object !== 'object' || !object) { @@ -1115,7 +1128,11 @@ function replacePointers(object, path, replace) { if (path.length === 0) { if (object && object.__type === 'Pointer') { - return replace[object.objectId]; + const replacement = replace[object.objectId]; + if (typeof replacement === 'undefined') { + return preserveMissing ? object : undefined; + } + return replacement; } return object; } @@ -1124,7 +1141,7 @@ function replacePointers(object, path, replace) { if (!subobject) { return object; } - var newsub = replacePointers(subobject, path.slice(1), replace); + var newsub = replacePointers(subobject, path.slice(1), replace, options); var answer = {}; for (var key in object) { if (key == path[0]) { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 8b6e447757..e7422d0926 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -11,6 +11,7 @@ const ALLOWED_GET_QUERY_KEYS = [ 'readPreference', 'includeReadPreference', 'subqueryReadPreference', + 'ignoreIncludeErrors', ]; export class ClassesRouter extends PromiseRouter { @@ -75,6 +76,9 @@ export class ClassesRouter extends PromiseRouter { if (typeof body.subqueryReadPreference === 'string') { options.subqueryReadPreference = body.subqueryReadPreference; } + if (body.ignoreIncludeErrors != null) { + options.ignoreIncludeErrors = !!body.ignoreIncludeErrors; + } return rest .get( @@ -174,6 +178,7 @@ export class ClassesRouter extends PromiseRouter { 'hint', 'explain', 'comment', + 'ignoreIncludeErrors', ]; for (const key of Object.keys(body)) { @@ -226,6 +231,9 @@ export class ClassesRouter extends PromiseRouter { if (body.comment && typeof body.comment === 'string') { options.comment = body.comment; } + if (body.ignoreIncludeErrors) { + options.ignoreIncludeErrors = true; + } return options; }