Skip to content

Commit 0529a48

Browse files
authored
Support dot syntax when preparing nested query values (#2827)
* Support dot syntax when preparing nested query values * Refactor query element preparation for more consistency * Address code review feedback * Add tests and fix preparation of positional operators * Fix preparation of references in queries * Update PHPStan baseline * Narrow type for value when preparing persistent collection
1 parent 7765d83 commit 0529a48

File tree

6 files changed

+520
-141
lines changed

6 files changed

+520
-141
lines changed

lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php

Lines changed: 132 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
use function is_string;
6363
use function spl_object_id;
6464
use function sprintf;
65+
use function str_contains;
6566
use function strpos;
6667
use function strtolower;
6768
use function trigger_deprecation;
@@ -1041,34 +1042,37 @@ public function addFilterToPreparedQuery(array $preparedQuery): array
10411042
*
10421043
* PHP field names and types will be converted to those used by MongoDB.
10431044
*
1044-
* @param array<string, mixed> $query
1045+
* @param array<string|int, mixed> $query
10451046
*
10461047
* @return array<string, mixed>
10471048
*/
10481049
public function prepareQueryOrNewObj(array $query, bool $isNewObj = false): array
10491050
{
10501051
$preparedQuery = [];
10511052

1052-
foreach ($query as $key => $value) {
1053-
$key = (string) $key;
1053+
foreach ($query as $field => $value) {
1054+
$field = (string) $field;
10541055

1055-
// Recursively prepare logical query clauses
1056-
if (in_array($key, ['$and', '$or', '$nor'], true) && is_array($value)) {
1057-
foreach ($value as $k2 => $v2) {
1058-
$preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
1059-
}
1056+
// Recursively prepare logical query clauses, treating each value as a separate query element
1057+
if (in_array($field, ['$and', '$or', '$nor'], true) && is_array($value)) {
1058+
$preparedQuery[$field] = array_map(
1059+
fn ($v) => $this->prepareQueryOrNewObj($v, $isNewObj),
1060+
$value,
1061+
);
10601062

10611063
continue;
10621064
}
10631065

1064-
if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
1065-
$preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
1066+
// Recursively prepare nested operators, treating the value as a single query element
1067+
if (isset($field[0]) && $field[0] === '$' && is_array($value)) {
1068+
$preparedQuery[$field] = $this->prepareQueryOrNewObj($value, $isNewObj);
1069+
10661070
continue;
10671071
}
10681072

1069-
$preparedQueryElements = $this->prepareQueryElement($key, $value, null, true, $isNewObj);
1073+
// Prepare a single query element. This may produce multiple queries (e.g. for references)
1074+
$preparedQueryElements = $this->prepareQueryElement($field, $value, null, true, $isNewObj);
10701075
foreach ($preparedQueryElements as [$preparedKey, $preparedValue]) {
1071-
$preparedValue = $this->convertToDatabaseValue($key, $preparedValue);
10721076
$preparedQuery[$preparedKey] = $preparedValue;
10731077
}
10741078
}
@@ -1083,29 +1087,29 @@ public function prepareQueryOrNewObj(array $query, bool $isNewObj = false): arra
10831087
*
10841088
* @return mixed
10851089
*/
1086-
private function convertToDatabaseValue(string $fieldName, $value)
1090+
private function convertToDatabaseValue(string $fieldName, $value, ?ClassMetadata $class = null)
10871091
{
10881092
if (is_array($value)) {
10891093
foreach ($value as $k => $v) {
10901094
if ($k === '$exists' || $k === '$type' || $k === '$currentDate') {
10911095
continue;
10921096
}
10931097

1094-
$value[$k] = $this->convertToDatabaseValue($fieldName, $v);
1098+
$value[$k] = $this->convertToDatabaseValue($fieldName, $v, $class);
10951099
}
10961100

10971101
return $value;
10981102
}
10991103

1100-
if (! $this->class->hasField($fieldName)) {
1104+
if (! $class || ! $class->hasField($fieldName)) {
11011105
if ($value instanceof BackedEnum) {
11021106
$value = $value->value;
11031107
}
11041108

11051109
return Type::convertPHPToDatabaseValue($value);
11061110
}
11071111

1108-
$mapping = $this->class->fieldMappings[$fieldName];
1112+
$mapping = $class->fieldMappings[$fieldName];
11091113
$typeName = $mapping['type'];
11101114

11111115
if (! empty($mapping['reference']) || ! empty($mapping['embedded'])) {
@@ -1132,6 +1136,22 @@ private function convertToDatabaseValue(string $fieldName, $value)
11321136
return $value;
11331137
}
11341138

1139+
private function prepareQueryReference(mixed $value, ClassMetadata $class): mixed
1140+
{
1141+
if (
1142+
// Scalar values are prepared immediately
1143+
! is_array($value)
1144+
// Objects without operators can be prepared immediately
1145+
|| ! $this->hasQueryOperators($value)
1146+
// Objects with DBRef fields can be prepared immediately
1147+
|| $this->hasDBRefFields($value)
1148+
) {
1149+
return $class->getDatabaseIdentifierValue($value);
1150+
}
1151+
1152+
return $this->prepareQueryExpression($value, $class);
1153+
}
1154+
11351155
/**
11361156
* Prepares a query value and converts the PHP value to the database value
11371157
* if it is an identifier.
@@ -1141,18 +1161,24 @@ private function convertToDatabaseValue(string $fieldName, $value)
11411161
*
11421162
* @param mixed $value
11431163
*
1144-
* @return array<array{string, mixed}>
1164+
* @return array<array{string, mixed}> Returns an array of tuples containing the prepared field name and value
11451165
*/
1146-
private function prepareQueryElement(string $fieldName, $value = null, ?ClassMetadata $class = null, bool $prepareValue = true, bool $inNewObj = false): array
1166+
private function prepareQueryElement(string $originalFieldName, $value = null, ?ClassMetadata $class = null, bool $prepareValue = true, bool $inNewObj = false, string $fieldNamePrefix = ''): array
11471167
{
1148-
$class ??= $this->class;
1168+
$class ??= $this->class;
1169+
$fieldName = $fieldNamePrefix . $originalFieldName;
11491170

1150-
// @todo Consider inlining calls to ClassMetadata methods
1171+
// Process identifier fields
1172+
if (($class->hasField($originalFieldName) && $class->isIdentifier($originalFieldName)) || $originalFieldName === '_id') {
1173+
$fieldName = $fieldNamePrefix . '_id';
1174+
1175+
return [[$fieldName, $prepareValue ? $this->prepareQueryReference($value, $class) : $value]];
1176+
}
11511177

11521178
// Process all non-identifier fields by translating field names
1153-
if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
1154-
$mapping = $class->fieldMappings[$fieldName];
1155-
$fieldName = $mapping['name'];
1179+
if ($class->hasField($originalFieldName)) {
1180+
$mapping = $class->fieldMappings[$originalFieldName];
1181+
$fieldName = $fieldNamePrefix . $mapping['name'];
11561182

11571183
if (! $prepareValue) {
11581184
return [[$fieldName, $value]];
@@ -1176,7 +1202,7 @@ private function prepareQueryElement(string $fieldName, $value = null, ?ClassMet
11761202

11771203
// No further preparation unless we're dealing with a simple reference
11781204
if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID || empty((array) $value)) {
1179-
return [[$fieldName, $value]];
1205+
return [[$fieldName, $this->convertToDatabaseValue($originalFieldName, $value, $class)]];
11801206
}
11811207

11821208
// Additional preparation for one or more simple reference values
@@ -1194,29 +1220,9 @@ private function prepareQueryElement(string $fieldName, $value = null, ?ClassMet
11941220
return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
11951221
}
11961222

1197-
// Process identifier fields
1198-
if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
1199-
$fieldName = '_id';
1200-
1201-
if (! $prepareValue) {
1202-
return [[$fieldName, $value]];
1203-
}
1204-
1205-
if (! is_array($value)) {
1206-
return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1207-
}
1208-
1209-
// Objects without operators or with DBRef fields can be converted immediately
1210-
if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1211-
return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1212-
}
1213-
1214-
return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
1215-
}
1216-
12171223
// No processing for unmapped, non-identifier, non-dotted field names
1218-
if (strpos($fieldName, '.') === false) {
1219-
return [[$fieldName, $value]];
1224+
if (! str_contains($originalFieldName, '.')) {
1225+
return [[$fieldName, $prepareValue ? $this->convertToDatabaseValue($originalFieldName, $value, $class) : $value]];
12201226
}
12211227

12221228
/* Process "fieldName.objectProperty" queries (on arrays or objects).
@@ -1225,121 +1231,111 @@ private function prepareQueryElement(string $fieldName, $value = null, ?ClassMet
12251231
* significant: "fieldName.objectProperty" with an optional index or key
12261232
* for collections stored as either BSON arrays or objects.
12271233
*/
1228-
$e = explode('.', $fieldName, 4);
1234+
$fieldNameParts = explode('.', $originalFieldName, 4);
1235+
$partCount = count($fieldNameParts);
1236+
assert($partCount >= 2);
12291237

12301238
// No further processing for unmapped fields
1231-
if (! isset($class->fieldMappings[$e[0]])) {
1232-
return [[$fieldName, $value]];
1239+
if (! $class->hasField($fieldNameParts[0])) {
1240+
return [[$fieldName, $prepareValue ? $this->convertToDatabaseValue($fieldNameParts[0], $value, $class) : $value]];
12331241
}
12341242

1235-
$mapping = $class->fieldMappings[$e[0]];
1236-
$e[0] = $mapping['name'];
1243+
$mapping = $class->fieldMappings[$fieldNameParts[0]];
1244+
$fieldName = $fieldNamePrefix . $mapping['name'] . '.' . implode('.', array_slice($fieldNameParts, 1));
12371245

12381246
// Hash and raw fields will not be prepared beyond the field name
12391247
if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
1240-
$fieldName = implode('.', $e);
1241-
12421248
return [[$fieldName, $value]];
12431249
}
12441250

1245-
if (
1246-
$mapping['type'] === ClassMetadata::MANY && CollectionHelper::isHash($mapping['strategy'])
1247-
&& isset($e[2])
1248-
) {
1249-
$objectProperty = $e[2];
1250-
$objectPropertyPrefix = $e[1] . '.';
1251-
$nextObjectProperty = implode('.', array_slice($e, 3));
1252-
} elseif ($e[1] !== '$') {
1253-
$fieldName = $e[0] . '.' . $e[1];
1254-
$objectProperty = $e[1];
1255-
$objectPropertyPrefix = '';
1256-
$nextObjectProperty = implode('.', array_slice($e, 2));
1257-
} elseif (isset($e[2])) {
1258-
$fieldName = $e[0] . '.' . $e[1] . '.' . $e[2];
1259-
$objectProperty = $e[2];
1260-
$objectPropertyPrefix = $e[1] . '.';
1261-
$nextObjectProperty = implode('.', array_slice($e, 3));
1251+
if (isset($mapping['targetDocument'])) {
1252+
// For associations with a targetDocument (i.e. embedded or reference), get the class metadata for the target document
1253+
$targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1254+
} elseif (is_object($value) && ! $this->dm->getMetadataFactory()->isTransient($value::class)) {
1255+
// For associations without a targetDocument, try to infer the class metadata from the object
1256+
$targetClass = $this->dm->getClassMetadata($value::class);
12621257
} else {
1263-
$fieldName = $e[0] . '.' . $e[1];
1264-
1265-
return [[$fieldName, $value]];
1266-
}
1267-
1268-
// No further processing for fields without a targetDocument mapping
1269-
if (! isset($mapping['targetDocument'])) {
1270-
if ($nextObjectProperty) {
1271-
$fieldName .= '.' . $nextObjectProperty;
1272-
}
1273-
1274-
return [[$fieldName, $value]];
1275-
}
1276-
1277-
$targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1278-
1279-
// No further processing for unmapped targetDocument fields
1280-
if (! $targetClass->hasField($objectProperty)) {
1281-
if ($nextObjectProperty) {
1282-
$fieldName .= '.' . $nextObjectProperty;
1283-
}
1284-
1285-
return [[$fieldName, $value]];
1286-
}
1287-
1288-
$targetMapping = $targetClass->getFieldMapping($objectProperty);
1289-
$objectPropertyIsId = $targetClass->isIdentifier($objectProperty);
1290-
1291-
// Prepare DBRef identifiers or the mapped field's property path
1292-
$fieldName = $objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID
1293-
? ClassMetadata::getReferenceFieldName($mapping['storeAs'], $e[0])
1294-
: $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];
1295-
1296-
// Process targetDocument identifier fields
1297-
if ($objectPropertyIsId) {
1298-
if (! $prepareValue) {
1299-
return [[$fieldName, $value]];
1258+
// Without a target document, no further processing is possible
1259+
return [[$fieldName, $prepareValue ? $this->convertToDatabaseValue($fieldNameParts[0], $value) : $value]];
1260+
}
1261+
1262+
// Don't recurse for references in queries. Instead, prepare them directly
1263+
if (! $inNewObj && ! empty($mapping['reference'])) {
1264+
// First part is the name of the reference
1265+
// Second part is either a positional operator, index/key, or the name of a field
1266+
// Third part (if any) is the name of a field
1267+
// That means, we can implode all field parts except the first as the next field name
1268+
if ($fieldNameParts[1] === '$') {
1269+
assert($partCount >= 3);
1270+
$objectProperty = $fieldNameParts[2];
1271+
$referencePrefix = $fieldNamePrefix . $mapping['name'] . '.$';
1272+
} else {
1273+
$objectProperty = $fieldNameParts[1];
1274+
$referencePrefix = $fieldNamePrefix . $mapping['name'];
13001275
}
13011276

1302-
if (! is_array($value)) {
1303-
return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1304-
}
1277+
if ($targetClass->hasField($objectProperty) && $targetClass->isIdentifier($objectProperty)) {
1278+
$fieldName = ClassMetadata::getReferenceFieldName($mapping['storeAs'], $referencePrefix);
13051279

1306-
// Objects without operators or with DBRef fields can be converted immediately
1307-
if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1308-
return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1280+
return [[$fieldName, $prepareValue ? $this->prepareQueryReference($value, $targetClass) : $value]];
13091281
}
13101282

1311-
return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1283+
return [[$fieldName, $prepareValue ? $this->convertToDatabaseValue($objectProperty, $value, $targetClass) : $value]];
13121284
}
13131285

1314-
/* The property path may include a third field segment, excluding the
1315-
* collection item pointer. If present, this next object property must
1316-
* be processed recursively.
1286+
/*
1287+
* 1 element: impossible (because of the dot)
1288+
* 2 elements: fieldName.objectProperty, fieldName.<index>, or fieldName.$. For EmbedMany and ReferenceMany, treat the second element as index if $inNewObj is true and convert the value. Otherwise, recurse.
1289+
* 3+ elements: fieldname.foo.bar, fieldName.<index>.foo, or fieldName.$.foo. For EmbedMany and ReferenceMany, treat the second element as index, and recurse into the third element. Otherwise, recurse with the second element as field name.
13171290
*/
1318-
if ($nextObjectProperty) {
1319-
// Respect the targetDocument's class metadata when recursing
1320-
$nextTargetClass = isset($targetMapping['targetDocument'])
1321-
? $this->dm->getClassMetadata($targetMapping['targetDocument'])
1322-
: null;
1323-
1324-
if (empty($targetMapping['reference'])) {
1325-
$fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);
1326-
} else {
1327-
// No recursive processing for references as most probably somebody is querying DBRef or alike
1328-
if ($nextObjectProperty[0] !== '$' && in_array($targetMapping['storeAs'], [ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB, ClassMetadata::REFERENCE_STORE_AS_DB_REF])) {
1329-
$nextObjectProperty = '$' . $nextObjectProperty;
1291+
if ($mapping['type'] === ClassMetadata::MANY) {
1292+
if ($inNewObj || CollectionHelper::isHash($mapping['strategy'])) {
1293+
// When there are only two segments in a hash or when serialising a new object, we seem to be replacing an entire element. Don't recurse, just convert the value.
1294+
if ($partCount === 2) {
1295+
// In order to prepare the embedded document value, we need to recurse with the original field name, then append the second segment
1296+
$prepared = $this->prepareQueryElement(
1297+
$mapping['name'],
1298+
$value,
1299+
$targetClass,
1300+
$prepareValue,
1301+
$inNewObj,
1302+
$fieldNamePrefix,
1303+
);
1304+
1305+
$preparedFieldName = $prepared[0][0];
1306+
$preparedValue = $prepared[0][1];
1307+
1308+
return [[$preparedFieldName . '.' . $fieldNameParts[1], $preparedValue]];
13301309
}
13311310

1332-
$fieldNames = [[$nextObjectProperty, $value]];
1311+
// When there are more than two segments, treat the second segment (index/key/positional operator) as part of the field name and recurse into the rest
1312+
$newPrefix = $fieldNamePrefix . $mapping['name'] . '.' . $fieldNameParts[1] . '.';
1313+
$newFieldName = implode('.', array_slice($fieldNameParts, 2));
1314+
} else {
1315+
// When serializing a query, the second segment is a positional operator ($), a numeric index for collections, or anything else for a hash.
1316+
$newPrefix = $fieldNamePrefix . $mapping['name'] . '.';
1317+
$newFieldName = implode('.', array_slice($fieldNameParts, 1));
13331318
}
13341319

1335-
return array_map(static function ($preparedTuple) use ($fieldName) {
1336-
[$key, $value] = $preparedTuple;
1337-
1338-
return [$fieldName . '.' . $key, $value];
1339-
}, $fieldNames);
1320+
return $this->prepareQueryElement(
1321+
$newFieldName,
1322+
$value,
1323+
$targetClass,
1324+
$prepareValue,
1325+
$inNewObj,
1326+
$newPrefix,
1327+
);
13401328
}
13411329

1342-
return [[$fieldName, $value]];
1330+
// For everything else, recurse with the first segment as field name and the target document class
1331+
return $this->prepareQueryElement(
1332+
implode('.', array_slice($fieldNameParts, 1)),
1333+
$value,
1334+
$targetClass,
1335+
$prepareValue,
1336+
$inNewObj,
1337+
$fieldNamePrefix . $mapping['name'] . '.',
1338+
);
13431339
}
13441340

13451341
/**

0 commit comments

Comments
 (0)