Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
36 changes: 27 additions & 9 deletions packages/dart/lib/src/network/parse_query.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class QueryBuilder<T extends ParseObject> {
String prefix, {
bool caseSensitive = false,
}) {
prefix = Uri.encodeComponent(prefix);
prefix = _encodeStringElement(prefix);

if (caseSensitive) {
queries.add(
Expand All @@ -155,7 +155,7 @@ class QueryBuilder<T extends ParseObject> {
String prefix, {
bool caseSensitive = false,
}) {
prefix = Uri.encodeComponent(prefix);
prefix = _encodeStringElement(prefix);

if (caseSensitive) {
queries.add(
Expand All @@ -178,7 +178,7 @@ class QueryBuilder<T extends ParseObject> {
/// to be equal to the provided [value]
void whereEqualTo(String column, dynamic value) {
if (value is String) {
value = Uri.encodeComponent(value);
value = _encodeStringElement(value);
}

queries.add(
Expand Down Expand Up @@ -237,7 +237,7 @@ class QueryBuilder<T extends ParseObject> {
/// to be not equal to the provided [value]
void whereNotEqualTo(String column, dynamic value) {
if (value is String) {
value = Uri.encodeComponent(value);
value = _encodeStringElement(value);
}

queries.add(
Expand All @@ -252,7 +252,7 @@ class QueryBuilder<T extends ParseObject> {
void whereContainedIn(String column, List<dynamic> value) {
queries.add(
_buildQueryWithColumnValueAndOperator(
MapEntry<String, dynamic>(column, value),
MapEntry<String, dynamic>(column, _encodeStringElements(value)),
'\$in',
),
);
Expand All @@ -262,7 +262,7 @@ class QueryBuilder<T extends ParseObject> {
void whereNotContainedIn(String column, List<dynamic> value) {
queries.add(
_buildQueryWithColumnValueAndOperator(
MapEntry<String, dynamic>(column, value),
MapEntry<String, dynamic>(column, _encodeStringElements(value)),
'\$nin',
),
);
Expand Down Expand Up @@ -312,12 +312,30 @@ class QueryBuilder<T extends ParseObject> {
void whereArrayContainsAll(String column, List<dynamic> value) {
queries.add(
_buildQueryWithColumnValueAndOperator(
MapEntry<String, dynamic>(column, value),
MapEntry<String, dynamic>(column, _encodeStringElements(value)),
'\$all',
),
);
}

/// JSON-escapes [value] and then percent-encodes the escaped content. The
/// JSON escaping keeps `"`, `\` and newlines valid inside the surrounding
/// string literal once the server URL-decodes the query; the percent
/// encoding keeps `&`, `=`, `+` and `#` from being chewed up by querystring
/// parsing on the way in.
String _encodeStringElement(String value) {
final String jsonString = jsonEncode(value);
return Uri.encodeComponent(jsonString.substring(1, jsonString.length - 1));
}
Comment on lines +321 to +329
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _encodeStringElement correctly JSON-escapes before percent-encoding so values with ", \\, or newlines survive server URL-decoding as valid JSON. However, the scalar string constraints in this same class (e.g. whereEqualTo, whereNotEqualTo, whereStartsWith/EndsWith, whereContains) still call Uri.encodeComponent directly on the raw String; if the original value contains " or \\, server URL-decoding will reintroduce those characters unescaped inside the JSON and can make the where={...} value invalid JSON. Consider reusing this helper (or a shared encoder) for all String constraint builders to keep behavior consistent and avoid malformed-JSON queries for quoted/backslashed input.

Copilot uses AI. Check for mistakes.

/// Runs [_encodeStringElement] on each String in [value]; other elements
/// are passed through unchanged.
List<dynamic> _encodeStringElements(List<dynamic> value) {
return value
.map<dynamic>((dynamic e) => e is String ? _encodeStringElement(e) : e)
.toList();
}

/// Returns an object where the [String] column has a regEx performed on,
/// this can include ^StringsWith, or ^EndsWith. This can be manipulated to the users desire
void regEx(String column, String value) {
Expand All @@ -336,7 +354,7 @@ class QueryBuilder<T extends ParseObject> {
String substring, {
bool caseSensitive = false,
}) {
substring = Uri.encodeComponent(substring);
substring = _encodeStringElement(substring);

if (caseSensitive) {
queries.add(
Expand Down Expand Up @@ -365,7 +383,7 @@ class QueryBuilder<T extends ParseObject> {
bool orderByScore = true,
bool diacriticSensitive = false,
}) {
searchTerm = Uri.encodeComponent(searchTerm);
searchTerm = _encodeStringElement(searchTerm);

queries.add(
MapEntry<String, dynamic>(
Expand Down
89 changes: 89 additions & 0 deletions packages/dart/test/src/network/parse_query_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -695,5 +695,94 @@ void main() {

expect(queryString, equals(expectedQueryString.toString()));
});

test(
'list-based where methods encode special characters in String elements',
() {
final queryBuilder = QueryBuilder.name('Diet_Plans');

queryBuilder.whereContainedIn('topics', <String>[
"RFI's & Change Orders",
'Schedule (Impacts, Delays, Inspections)',
]);
queryBuilder.whereNotContainedIn('excluded', <String>['a=b', 'c+d']);
queryBuilder.whereArrayContainsAll('tags', <String>['x#y', 'z&w']);

final queryString = queryBuilder.buildQuery();
const encodedAmp = '%26'; // &
const encodedEq = '%3D'; // =
const encodedPlus = '%2B'; // +
const encodedHash = '%23'; // #

expect(queryString, contains(encodedAmp));
expect(queryString, contains(encodedEq));
expect(queryString, contains(encodedPlus));
expect(queryString, contains(encodedHash));

// Raw delimiters would break querystring parsing on the server, so
// verify they are not present in the encoded output.
expect(queryString.contains('& Change Orders'), isFalse);
expect(queryString.contains('a=b'), isFalse);
expect(queryString.contains('c+d'), isFalse);
expect(queryString.contains('x#y'), isFalse);
},
);

test(
'list-based where methods JSON-escape quotes and backslashes in String elements',
() {
final queryBuilder = QueryBuilder.name('Diet_Plans');

queryBuilder.whereContainedIn('topics', <String>[
'He said "hi"',
r'C:\path',
]);

final queryString = queryBuilder.buildQuery();

// Encoded form of `\"` and `\\`: the backslash must survive URL decoding
// so the server sees valid JSON (e.g. "He said \"hi\"").
expect(queryString, contains('%5C%22'));
expect(queryString, contains('%5C%5C'));

// Unescaped `"` and `\` inside the string values would corrupt the JSON.
expect(queryString.contains('"He said "hi""'), isFalse);
expect(queryString.contains(r'C:\path'), isFalse);
},
);

test('list-based where methods leave non-String elements untouched', () {
final queryBuilder = QueryBuilder.name('Diet_Plans');

queryBuilder.whereContainedIn('nums', <int>[1, 2, 3]);

final queryString = queryBuilder.buildQuery();
expect(queryString, contains('"\$in":[1,2,3]'));
});

test(
'scalar where methods JSON-escape quotes and backslashes in String values',
() {
final queryBuilder = QueryBuilder.name('Diet_Plans');

queryBuilder.whereEqualTo('title', 'He said "hi"');
queryBuilder.whereNotEqualTo('note', r'C:\path');
queryBuilder.whereStartsWith('name', 'quote"prefix');
queryBuilder.whereContains('body', r'back\slash');

final queryString = queryBuilder.buildQuery();

// `"` and `\` must come through percent-encoded as `\"` / `\\` so the
// JSON stays valid after the server URL-decodes the query.
expect(queryString, contains('%5C%22'));
expect(queryString, contains('%5C%5C'));

// Raw `"` and `\` inside the values would break JSON parsing.
expect(queryString.contains('"He said "hi""'), isFalse);
expect(queryString.contains(r'C:\path'), isFalse);
expect(queryString.contains('quote"prefix'), isFalse);
expect(queryString.contains(r'back\slash'), isFalse);
},
);
});
}