@@ -488,7 +488,7 @@ public function createCollection(string $name, array $attributes = [], array $in
488488 $ orders = $ index ->getAttribute ('orders ' );
489489
490490 // If sharedTables, always add _tenant as the first key
491- if ($ this ->sharedTables ) {
491+ if ($ this ->shouldAddTenantToIndex ( $ index ) ) {
492492 $ key ['_tenant ' ] = $ this ->getOrder (Database::ORDER_ASC );
493493 }
494494
@@ -508,6 +508,9 @@ public function createCollection(string $name, array $attributes = [], array $in
508508 $ order = $ this ->getOrder ($ this ->filter ($ orders [$ j ] ?? Database::ORDER_ASC ));
509509 $ unique = true ;
510510 break ;
511+ case Database::INDEX_TTL :
512+ $ order = $ this ->getOrder ($ this ->filter ($ orders [$ j ] ?? Database::ORDER_ASC ));
513+ break ;
511514 default :
512515 // index not supported
513516 return false ;
@@ -526,6 +529,14 @@ public function createCollection(string $name, array $attributes = [], array $in
526529 $ newIndexes [$ i ]['default_language ' ] = 'none ' ;
527530 }
528531
532+ // Handle TTL indexes
533+ if ($ index ->getAttribute ('type ' ) === Database::INDEX_TTL ) {
534+ $ ttl = $ index ->getAttribute ('ttl ' , 0 );
535+ if ($ ttl > 0 ) {
536+ $ newIndexes [$ i ]['expireAfterSeconds ' ] = $ ttl ;
537+ }
538+ }
539+
529540 // Add partial filter for indexes to avoid indexing null values
530541 if (in_array ($ index ->getAttribute ('type ' ), [
531542 Database::INDEX_UNIQUE ,
@@ -901,10 +912,11 @@ public function deleteRelationship(
901912 * @param array<string> $orders
902913 * @param array<string, string> $indexAttributeTypes
903914 * @param array<string, mixed> $collation
915+ * @param int $ttl
904916 * @return bool
905917 * @throws Exception
906918 */
907- public function createIndex (string $ collection , string $ id , string $ type , array $ attributes , array $ lengths , array $ orders , array $ indexAttributeTypes = [], array $ collation = []): bool
919+ public function createIndex (string $ collection , string $ id , string $ type , array $ attributes , array $ lengths , array $ orders , array $ indexAttributeTypes = [], array $ collation = [], int $ ttl = 1 ): bool
908920 {
909921 $ name = $ this ->getNamespace () . '_ ' . $ this ->filter ($ collection );
910922 $ id = $ this ->filter ($ id );
@@ -913,7 +925,7 @@ public function createIndex(string $collection, string $id, string $type, array
913925 $ indexes ['name ' ] = $ id ;
914926
915927 // If sharedTables, always add _tenant as the first key
916- if ($ this ->sharedTables ) {
928+ if ($ this ->shouldAddTenantToIndex ( $ type ) ) {
917929 $ indexes ['key ' ]['_tenant ' ] = $ this ->getOrder (Database::ORDER_ASC );
918930 }
919931
@@ -939,6 +951,8 @@ public function createIndex(string $collection, string $id, string $type, array
939951 case Database::INDEX_UNIQUE :
940952 $ indexes ['unique ' ] = true ;
941953 break ;
954+ case Database::INDEX_TTL :
955+ break ;
942956 default :
943957 return false ;
944958 }
@@ -967,6 +981,11 @@ public function createIndex(string $collection, string $id, string $type, array
967981 $ indexes ['default_language ' ] = 'none ' ;
968982 }
969983
984+ // Handle TTL indexes
985+ if ($ type === Database::INDEX_TTL && $ ttl > 0 ) {
986+ $ indexes ['expireAfterSeconds ' ] = $ ttl ;
987+ }
988+
970989 // Add partial filter for indexes to avoid indexing null values
971990 if (in_array ($ type , [Database::INDEX_UNIQUE , Database::INDEX_KEY ])) {
972991 $ partialFilter = [];
@@ -1079,7 +1098,7 @@ public function renameIndex(string $collection, string $old, string $new): bool
10791098
10801099 try {
10811100 $ deletedindex = $ this ->deleteIndex ($ collection , $ old );
1082- $ createdindex = $ this ->createIndex ($ collection , $ new , $ index ['type ' ], $ index ['attributes ' ], $ index ['lengths ' ] ?? [], $ index ['orders ' ] ?? [], $ indexAttributeTypes );
1101+ $ createdindex = $ this ->createIndex ($ collection , $ new , $ index ['type ' ], $ index ['attributes ' ], $ index ['lengths ' ] ?? [], $ index ['orders ' ] ?? [], $ indexAttributeTypes, [], $ index [ ' ttl ' ] ?? 0 );
10831102 } catch (\Exception $ e ) {
10841103 throw $ this ->processException ($ e );
10851104 }
@@ -1241,30 +1260,7 @@ public function castingAfter(Document $collection, Document $document): Document
12411260 $ node = (int )$ node ;
12421261 break ;
12431262 case Database::VAR_DATETIME :
1244- if ($ node instanceof UTCDateTime) {
1245- // Handle UTCDateTime objects
1246- $ node = DateTime::format ($ node ->toDateTime ());
1247- } elseif (is_array ($ node ) && isset ($ node ['$date ' ])) {
1248- // Handle Extended JSON format from (array) cast
1249- // Format: {"$date":{"$numberLong":"1760405478290"}}
1250- if (is_array ($ node ['$date ' ]) && isset ($ node ['$date ' ]['$numberLong ' ])) {
1251- $ milliseconds = (int )$ node ['$date ' ]['$numberLong ' ];
1252- $ seconds = intdiv ($ milliseconds , 1000 );
1253- $ microseconds = ($ milliseconds % 1000 ) * 1000 ;
1254- $ dateTime = \DateTime::createFromFormat ('U.u ' , $ seconds . '. ' . str_pad ((string )$ microseconds , 6 , '0 ' ));
1255- if ($ dateTime ) {
1256- $ dateTime ->setTimezone (new \DateTimeZone ('UTC ' ));
1257- $ node = DateTime::format ($ dateTime );
1258- }
1259- }
1260- } elseif (is_string ($ node )) {
1261- // Already a string, validate and pass through
1262- try {
1263- new \DateTime ($ node );
1264- } catch (\Exception $ e ) {
1265- // Invalid date string, skip
1266- }
1267- }
1263+ $ node = $ this ->convertUTCDateToString ($ node );
12681264 break ;
12691265 case Database::VAR_OBJECT :
12701266 // Convert stdClass objects to arrays for object attributes
@@ -1285,6 +1281,8 @@ public function castingAfter(Document $collection, Document $document): Document
12851281 // mongodb results out a stdclass for objects
12861282 if (is_object ($ value ) && get_class ($ value ) === stdClass::class) {
12871283 $ document ->setAttribute ($ key , $ this ->convertStdClassToArray ($ value ));
1284+ } elseif ($ value instanceof UTCDateTime) {
1285+ $ document ->setAttribute ($ key , $ this ->convertUTCDateToString ($ value ));
12881286 }
12891287 }
12901288 }
@@ -1367,6 +1365,24 @@ public function castingBefore(Document $collection, Document $document): Documen
13671365 unset($ node );
13681366 $ document ->setAttribute ($ key , ($ array ) ? $ value : $ value [0 ]);
13691367 }
1368+ $ indexes = $ collection ->getAttribute ('indexes ' );
1369+ $ ttlIndexes = array_filter ($ indexes , fn ($ index ) => $ index ->getAttribute ('type ' ) === Database::INDEX_TTL );
1370+
1371+ if (!$ this ->getSupportForAttributes ()) {
1372+ foreach ($ document ->getArrayCopy () as $ key => $ value ) {
1373+ if (in_array ($ this ->getInternalKeyForAttribute ($ key ), Database::INTERNAL_ATTRIBUTE_KEYS )) {
1374+ continue ;
1375+ }
1376+ if (is_string ($ value ) && (in_array ($ key , $ ttlIndexes ) || $ this ->isExtendedISODatetime ($ value ))) {
1377+ try {
1378+ $ newValue = new UTCDateTime (new \DateTime ($ value ));
1379+ $ document ->setAttribute ($ key , $ newValue );
1380+ } catch (\Throwable $ th ) {
1381+ // skip -> a valid string
1382+ }
1383+ }
1384+ }
1385+ }
13701386
13711387 return $ document ;
13721388 }
@@ -2086,7 +2102,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
20862102
20872103 foreach ($ moreResults as $ result ) {
20882104 $ record = $ this ->replaceChars ('_ ' , '$ ' , (array )$ result );
2089- $ found [] = new Document ($ record );
2105+ $ found [] = new Document ($ this -> convertStdClassToArray ( $ record) );
20902106 }
20912107
20922108 $ cursorId = (int )($ moreResponse ->cursor ->id ?? 0 );
@@ -2716,6 +2732,25 @@ protected function getOrder(string $order): int
27162732 };
27172733 }
27182734
2735+ /**
2736+ * Check if tenant should be added to index
2737+ *
2738+ * @param Document|string $indexOrType Index document or index type string
2739+ * @return bool
2740+ */
2741+ protected function shouldAddTenantToIndex (Document |string $ indexOrType ): bool
2742+ {
2743+ if (!$ this ->sharedTables ) {
2744+ return false ;
2745+ }
2746+
2747+ $ indexType = $ indexOrType instanceof Document
2748+ ? $ indexOrType ->getAttribute ('type ' )
2749+ : $ indexOrType ;
2750+
2751+ return $ indexType !== Database::INDEX_TTL ;
2752+ }
2753+
27192754 /**
27202755 * @param array<string> $selections
27212756 * @param string $prefix
@@ -3475,4 +3510,128 @@ public function getSupportForTrigramIndex(): bool
34753510 {
34763511 return false ;
34773512 }
3513+
3514+ public function getSupportForTTLIndexes (): bool
3515+ {
3516+ return true ;
3517+ }
3518+
3519+ protected function isExtendedISODatetime (string $ val ): bool
3520+ {
3521+ /**
3522+ * Min:
3523+ * YYYY-MM-DDTHH:mm:ssZ (20)
3524+ * YYYY-MM-DDTHH:mm:ss+HH:MM (25)
3525+ *
3526+ * Max:
3527+ * YYYY-MM-DDTHH:mm:ss.fffffZ (26)
3528+ * YYYY-MM-DDTHH:mm:ss.fffff+HH:MM (31)
3529+ */
3530+
3531+ $ len = strlen ($ val );
3532+
3533+ // absolute minimum
3534+ if ($ len < 20 ) {
3535+ return false ;
3536+ }
3537+
3538+ // fixed datetime fingerprints
3539+ if (
3540+ !isset ($ val [19 ]) ||
3541+ $ val [4 ] !== '- ' ||
3542+ $ val [7 ] !== '- ' ||
3543+ $ val [10 ] !== 'T ' ||
3544+ $ val [13 ] !== ': ' ||
3545+ $ val [16 ] !== ': '
3546+ ) {
3547+ return false ;
3548+ }
3549+
3550+ // timezone detection
3551+ $ hasZ = ($ val [$ len - 1 ] === 'Z ' );
3552+
3553+ $ hasOffset = (
3554+ $ len >= 25 &&
3555+ ($ val [$ len - 6 ] === '+ ' || $ val [$ len - 6 ] === '- ' ) &&
3556+ $ val [$ len - 3 ] === ': '
3557+ );
3558+
3559+ if (!$ hasZ && !$ hasOffset ) {
3560+ return false ;
3561+ }
3562+
3563+ if ($ hasOffset && $ len > 31 ) {
3564+ return false ;
3565+ }
3566+
3567+ if ($ hasZ && $ len > 26 ) {
3568+ return false ;
3569+ }
3570+
3571+ $ digitPositions = [
3572+ 0 ,1 ,2 ,3 ,
3573+ 5 ,6 ,
3574+ 8 ,9 ,
3575+ 11 ,12 ,
3576+ 14 ,15 ,
3577+ 17 ,18
3578+ ];
3579+
3580+ $ timeEnd = $ hasZ ? $ len - 1 : $ len - 6 ;
3581+
3582+ // fractional seconds
3583+ if ($ timeEnd > 19 ) {
3584+ if ($ val [19 ] !== '. ' || $ timeEnd < 21 ) {
3585+ return false ;
3586+ }
3587+ for ($ i = 20 ; $ i < $ timeEnd ; $ i ++) {
3588+ $ digitPositions [] = $ i ;
3589+ }
3590+ }
3591+
3592+ // timezone offset numeric digits
3593+ if ($ hasOffset ) {
3594+ foreach ([$ len - 5 , $ len - 4 , $ len - 2 , $ len - 1 ] as $ i ) {
3595+ $ digitPositions [] = $ i ;
3596+ }
3597+ }
3598+
3599+ foreach ($ digitPositions as $ i ) {
3600+ if (!ctype_digit ($ val [$ i ])) {
3601+ return false ;
3602+ }
3603+ }
3604+
3605+ return true ;
3606+ }
3607+
3608+ protected function convertUTCDateToString (mixed $ node ): mixed
3609+ {
3610+ if ($ node instanceof UTCDateTime) {
3611+ // Handle UTCDateTime objects
3612+ $ node = DateTime::format ($ node ->toDateTime ());
3613+ } elseif (is_array ($ node ) && isset ($ node ['$date ' ])) {
3614+ // Handle Extended JSON format from (array) cast
3615+ // Format: {"$date":{"$numberLong":"1760405478290"}}
3616+ if (is_array ($ node ['$date ' ]) && isset ($ node ['$date ' ]['$numberLong ' ])) {
3617+ $ milliseconds = (int )$ node ['$date ' ]['$numberLong ' ];
3618+ $ seconds = intdiv ($ milliseconds , 1000 );
3619+ $ microseconds = ($ milliseconds % 1000 ) * 1000 ;
3620+ $ dateTime = \DateTime::createFromFormat ('U.u ' , $ seconds . '. ' . str_pad ((string )$ microseconds , 6 , '0 ' ));
3621+ if ($ dateTime ) {
3622+ $ dateTime ->setTimezone (new \DateTimeZone ('UTC ' ));
3623+ $ node = DateTime::format ($ dateTime );
3624+ }
3625+ }
3626+ } elseif (is_string ($ node )) {
3627+ // Already a string, validate and pass through
3628+ try {
3629+ new \DateTime ($ node );
3630+ } catch (\Exception $ e ) {
3631+ // Invalid date string, skip
3632+ }
3633+ }
3634+
3635+ return $ node ;
3636+ }
34783637}
0 commit comments