6262use function is_string ;
6363use function spl_object_id ;
6464use function sprintf ;
65+ use function str_contains ;
6566use function strpos ;
6667use function strtolower ;
6768use 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