feat: Add requestComplexity.subqueryLimit option to limit subquery results (#10420)

This commit is contained in:
Manuel
2026-04-10 16:49:34 +01:00
committed by GitHub
parent df5e97fd40
commit bf40004d25
5 changed files with 158 additions and 0 deletions
+120
View File
@@ -162,6 +162,7 @@ describe('request complexity', () => {
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
subqueryLimit: -1,
queryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
@@ -732,4 +733,123 @@ describe('request complexity', () => {
});
});
});
describe('subquery result limit', () => {
let config;
const totalObjects = 5;
const resultLimit = 3;
beforeEach(async () => {
await reconfigureServer({
requestComplexity: { subqueryLimit: resultLimit },
});
config = Config.get('test');
// Create target objects
const targets = [];
for (let i = 0; i < totalObjects; i++) {
const obj = new Parse.Object('Target');
obj.set('value', `v${i}`);
targets.push(obj);
}
await Parse.Object.saveAll(targets);
// Create source objects, each pointing to a target
const sources = [];
for (let i = 0; i < totalObjects; i++) {
const obj = new Parse.Object('Source');
obj.set('ref', targets[i]);
obj.set('value', targets[i].get('value'));
sources.push(obj);
}
await Parse.Object.saveAll(sources);
});
it('should limit $inQuery subquery results', async () => {
const where = {
ref: {
$inQuery: { className: 'Target', where: {} },
},
};
const result = await rest.find(config, auth.nobody(config), 'Source', where);
expect(result.results.length).toBe(resultLimit);
});
it('should limit $notInQuery subquery results', async () => {
const where = {
ref: {
$notInQuery: { className: 'Target', where: {} },
},
};
const result = await rest.find(config, auth.nobody(config), 'Source', where);
// With limit, only `resultLimit` targets are excluded, so (totalObjects - resultLimit) sources remain
expect(result.results.length).toBe(totalObjects - resultLimit);
});
it('should limit $select subquery results', async () => {
const where = {
value: {
$select: { query: { className: 'Target', where: {} }, key: 'value' },
},
};
const result = await rest.find(config, auth.nobody(config), 'Source', where);
expect(result.results.length).toBe(resultLimit);
});
it('should limit $dontSelect subquery results', async () => {
const where = {
value: {
$dontSelect: { query: { className: 'Target', where: {} }, key: 'value' },
},
};
const result = await rest.find(config, auth.nobody(config), 'Source', where);
expect(result.results.length).toBe(totalObjects - resultLimit);
});
it('should allow unlimited subquery results with master key', async () => {
const where = {
ref: {
$inQuery: { className: 'Target', where: {} },
},
};
const result = await rest.find(config, auth.master(config), 'Source', where);
expect(result.results.length).toBe(totalObjects);
});
it('should allow unlimited subquery results with maintenance key', async () => {
const where = {
ref: {
$inQuery: { className: 'Target', where: {} },
},
};
const result = await rest.find(config, auth.maintenance(config), 'Source', where);
expect(result.results.length).toBe(totalObjects);
});
it('should allow unlimited subquery results when subqueryLimit is -1', async () => {
await reconfigureServer({
requestComplexity: { subqueryLimit: -1 },
});
config = Config.get('test');
const where = {
ref: {
$inQuery: { className: 'Target', where: {} },
},
};
const result = await rest.find(config, auth.nobody(config), 'Source', where);
expect(result.results.length).toBe(totalObjects);
});
it('should include subqueryLimit in config defaults', async () => {
await reconfigureServer({});
config = Config.get('test');
expect(config.requestComplexity.subqueryLimit).toBe(-1);
});
it('should accept subqueryLimit in config validation', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: { subqueryLimit: 100 },
})
).toBeResolved();
});
});
});
+6
View File
@@ -752,6 +752,12 @@ module.exports.RequestComplexityOptions = {
action: parsers.numberParser('subqueryDepth'),
default: -1,
},
subqueryLimit: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_LIMIT',
help: 'Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('subqueryLimit'),
default: -1,
},
};
module.exports.SecurityOptions = {
checkGroups: {
+1
View File
@@ -144,6 +144,7 @@
* @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.
* @property {Number} queryDepth Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.
* @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.
* @property {Number} subqueryLimit Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`.
*/
/**
+3
View File
@@ -460,6 +460,9 @@ export interface RequestComplexityOptions {
/* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
subqueryDepth: ?number;
/* Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
subqueryLimit: ?number;
/* Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
queryDepth: ?number;
+28
View File
@@ -563,6 +563,13 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
if (!this.auth.isMaster && !this.auth.isMaintenance) {
const rc = this.config.requestComplexity;
if (rc && rc.subqueryLimit > 0) {
additionalOptions.limit = rc.subqueryLimit;
}
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
@@ -624,6 +631,13 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
if (!this.auth.isMaster && !this.auth.isMaintenance) {
const rc = this.config.requestComplexity;
if (rc && rc.subqueryLimit > 0) {
additionalOptions.limit = rc.subqueryLimit;
}
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
@@ -698,6 +712,13 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
if (!this.auth.isMaster && !this.auth.isMaintenance) {
const rc = this.config.requestComplexity;
if (rc && rc.subqueryLimit > 0) {
additionalOptions.limit = rc.subqueryLimit;
}
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
@@ -762,6 +783,13 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
if (!this.auth.isMaster && !this.auth.isMaintenance) {
const rc = this.config.requestComplexity;
if (rc && rc.subqueryLimit > 0) {
additionalOptions.limit = rc.subqueryLimit;
}
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,