From 5a9dbaaa53d269fd39f5c995782be39ea8ac9eb9 Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Tue, 7 Oct 2025 18:04:54 +0700 Subject: [PATCH 1/3] seems to work? --- spec/rest.spec.js | 122 +++++++++++++++++++++++++ src/Adapters/Storage/StorageAdapter.js | 1 + src/Controllers/DatabaseController.js | 8 ++ src/RestQuery.js | 49 ++++++++-- src/Routers/ClassesRouter.js | 8 ++ 5 files changed, 181 insertions(+), 7 deletions(-) diff --git a/spec/rest.spec.js b/spec/rest.spec.js index fed64c988b..e5c460996b 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -803,6 +803,128 @@ 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 }), + }) + ); + + 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 095c2e83c1..9396be9ddd 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 621700984b..c0373419cf 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -113,6 +113,19 @@ function _UnsafeRestQuery( this.response = null; this.findOptions = {}; this.context = context || {}; + const hasIgnoreIncludeErrors = Object.prototype.hasOwnProperty.call( + restOptions, + 'ignoreIncludeErrors' + ); + this.ignoreIncludeErrors = hasIgnoreIncludeErrors + ? !!restOptions.ignoreIncludeErrors + : false; + if (hasIgnoreIncludeErrors) { + this.restOptions.ignoreIncludeErrors = this.ignoreIncludeErrors; + if (this.ignoreIncludeErrors) { + this.findOptions.ignoreIncludeErrors = true; + } + } if (!this.auth.isMaster) { if (this.className == '_Session') { if (!this.auth.user) { @@ -215,6 +228,8 @@ function _UnsafeRestQuery( case 'comment': this.findOptions[option] = restOptions[option]; break; + case 'ignoreIncludeErrors': + break; case 'order': var fields = restOptions.order.split(','); this.findOptions.sort = fields.reduce((sortMap, field) => { @@ -741,6 +756,9 @@ _UnsafeRestQuery.prototype.runFind = async function (options = {}) { return Promise.resolve(); } const findOptions = Object.assign({}, this.findOptions); + if (this.ignoreIncludeErrors) { + findOptions.ignoreIncludeErrors = true; + } if (this.keys) { findOptions.keys = this.keys.map(key => { return key.split('.')[0]; @@ -1013,6 +1031,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]); @@ -1054,7 +1079,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; @@ -1095,13 +1122,17 @@ 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)); + // TODO: Is this change really correct? If we do this then preserveMissing will essentially + // cause the array to have undefined values inside? + return preserveMissing ? mapped : mapped.filter(obj => typeof obj !== 'undefined'); } if (typeof object !== 'object' || !object) { @@ -1110,7 +1141,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; } @@ -1119,7 +1154,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; } From 77aa041b0d66d51c28059036df5e8d83d19fcd29 Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Tue, 7 Oct 2025 18:39:22 +0700 Subject: [PATCH 2/3] feat: ignoreIncludeErrors flag --- spec/rest.spec.js | 2 ++ src/RestQuery.js | 20 ++------------------ 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/spec/rest.spec.js b/spec/rest.spec.js index e5c460996b..24877baf98 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -851,6 +851,8 @@ describe('rest create', () => { }) ); + // 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`, diff --git a/src/RestQuery.js b/src/RestQuery.js index c0373419cf..6e3b5cf500 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -113,19 +113,6 @@ function _UnsafeRestQuery( this.response = null; this.findOptions = {}; this.context = context || {}; - const hasIgnoreIncludeErrors = Object.prototype.hasOwnProperty.call( - restOptions, - 'ignoreIncludeErrors' - ); - this.ignoreIncludeErrors = hasIgnoreIncludeErrors - ? !!restOptions.ignoreIncludeErrors - : false; - if (hasIgnoreIncludeErrors) { - this.restOptions.ignoreIncludeErrors = this.ignoreIncludeErrors; - if (this.ignoreIncludeErrors) { - this.findOptions.ignoreIncludeErrors = true; - } - } if (!this.auth.isMaster) { if (this.className == '_Session') { if (!this.auth.user) { @@ -218,6 +205,7 @@ function _UnsafeRestQuery( case 'includeAll': this.includeAll = true; break; + // Propagate these options from restOptions to findOptions too case 'explain': case 'hint': case 'distinct': @@ -226,9 +214,8 @@ function _UnsafeRestQuery( case 'limit': case 'readPreference': case 'comment': - this.findOptions[option] = restOptions[option]; - break; case 'ignoreIncludeErrors': + this.findOptions[option] = restOptions[option]; break; case 'order': var fields = restOptions.order.split(','); @@ -756,9 +743,6 @@ _UnsafeRestQuery.prototype.runFind = async function (options = {}) { return Promise.resolve(); } const findOptions = Object.assign({}, this.findOptions); - if (this.ignoreIncludeErrors) { - findOptions.ignoreIncludeErrors = true; - } if (this.keys) { findOptions.keys = this.keys.map(key => { return key.split('.')[0]; From e287f1045240100e80bfc2974900aa2fc816f4af Mon Sep 17 00:00:00 2001 From: Switt Kongdachalert Date: Tue, 7 Oct 2025 19:07:54 +0700 Subject: [PATCH 3/3] feat: ignoreIncludeErrors flag --- src/RestQuery.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index 6e3b5cf500..dc933501cc 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -1114,8 +1114,6 @@ function replacePointers(object, path, replace, options = {}) { const preserveMissing = !!options.preserveMissing; if (object instanceof Array) { const mapped = object.map(obj => replacePointers(obj, path, replace, options)); - // TODO: Is this change really correct? If we do this then preserveMissing will essentially - // cause the array to have undefined values inside? return preserveMissing ? mapped : mapped.filter(obj => typeof obj !== 'undefined'); }