@@ -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
@@ -933,6 +945,8 @@ public function createIndex(string $collection, string $id, string $type, array
933945 case Database::INDEX_UNIQUE :
934946 $ indexes ['unique ' ] = true ;
935947 break ;
948+ case Database::INDEX_TTL :
949+ break ;
936950 default :
937951 return false ;
938952 }
@@ -961,6 +975,11 @@ public function createIndex(string $collection, string $id, string $type, array
961975 $ indexes ['default_language ' ] = 'none ' ;
962976 }
963977
978+ // Handle TTL indexes
979+ if ($ type === Database::INDEX_TTL && $ ttl > 0 ) {
980+ $ indexes ['expireAfterSeconds ' ] = $ ttl ;
981+ }
982+
964983 // Add partial filter for indexes to avoid indexing null values
965984 if (in_array ($ type , [Database::INDEX_UNIQUE , Database::INDEX_KEY ])) {
966985 $ partialFilter = [];
@@ -1073,7 +1092,7 @@ public function renameIndex(string $collection, string $old, string $new): bool
10731092
10741093 try {
10751094 $ deletedindex = $ this ->deleteIndex ($ collection , $ old );
1076- $ createdindex = $ this ->createIndex ($ collection , $ new , $ index ['type ' ], $ index ['attributes ' ], $ index ['lengths ' ] ?? [], $ index ['orders ' ] ?? [], $ indexAttributeTypes );
1095+ $ createdindex = $ this ->createIndex ($ collection , $ new , $ index ['type ' ], $ index ['attributes ' ], $ index ['lengths ' ] ?? [], $ index ['orders ' ] ?? [], $ indexAttributeTypes, [], $ index [ ' ttl ' ] ?? 0 );
10771096 } catch (\Exception $ e ) {
10781097 throw $ this ->processException ($ e );
10791098 }
@@ -1245,30 +1264,7 @@ public function castingAfter(Document $collection, Document $document): Document
12451264 $ node = (int )$ node ;
12461265 break ;
12471266 case Database::VAR_DATETIME :
1248- if ($ node instanceof UTCDateTime) {
1249- // Handle UTCDateTime objects
1250- $ node = DateTime::format ($ node ->toDateTime ());
1251- } elseif (is_array ($ node ) && isset ($ node ['$date ' ])) {
1252- // Handle Extended JSON format from (array) cast
1253- // Format: {"$date":{"$numberLong":"1760405478290"}}
1254- if (is_array ($ node ['$date ' ]) && isset ($ node ['$date ' ]['$numberLong ' ])) {
1255- $ milliseconds = (int )$ node ['$date ' ]['$numberLong ' ];
1256- $ seconds = intdiv ($ milliseconds , 1000 );
1257- $ microseconds = ($ milliseconds % 1000 ) * 1000 ;
1258- $ dateTime = \DateTime::createFromFormat ('U.u ' , $ seconds . '. ' . str_pad ((string )$ microseconds , 6 , '0 ' ));
1259- if ($ dateTime ) {
1260- $ dateTime ->setTimezone (new \DateTimeZone ('UTC ' ));
1261- $ node = DateTime::format ($ dateTime );
1262- }
1263- }
1264- } elseif (is_string ($ node )) {
1265- // Already a string, validate and pass through
1266- try {
1267- new \DateTime ($ node );
1268- } catch (\Exception $ e ) {
1269- // Invalid date string, skip
1270- }
1271- }
1267+ $ node = $ this ->convertUTCDateToString ($ node );
12721268 break ;
12731269 case Database::VAR_OBJECT :
12741270 // Convert stdClass objects to arrays for object attributes
@@ -1289,6 +1285,8 @@ public function castingAfter(Document $collection, Document $document): Document
12891285 // mongodb results out a stdclass for objects
12901286 if (is_object ($ value ) && get_class ($ value ) === stdClass::class) {
12911287 $ document ->setAttribute ($ key , $ this ->convertStdClassToArray ($ value ));
1288+ } elseif ($ value instanceof UTCDateTime) {
1289+ $ document ->setAttribute ($ key , $ this ->convertUTCDateToString ($ value ));
12921290 }
12931291 }
12941292 }
@@ -1371,6 +1369,24 @@ public function castingBefore(Document $collection, Document $document): Documen
13711369 unset($ node );
13721370 $ document ->setAttribute ($ key , ($ array ) ? $ value : $ value [0 ]);
13731371 }
1372+ $ indexes = $ collection ->getAttribute ('indexes ' );
1373+ $ ttlIndexes = array_filter ($ indexes , fn ($ index ) => $ index ->getAttribute ('type ' ) === Database::INDEX_TTL );
1374+
1375+ if (!$ this ->getSupportForAttributes ()) {
1376+ foreach ($ document ->getArrayCopy () as $ key => $ value ) {
1377+ if (in_array ($ this ->getInternalKeyForAttribute ($ key ), Database::INTERNAL_ATTRIBUTE_KEYS )) {
1378+ continue ;
1379+ }
1380+ if (is_string ($ value ) && (in_array ($ key , $ ttlIndexes ) || $ this ->isExtendedISODatetime ($ value ))) {
1381+ try {
1382+ $ newValue = new UTCDateTime (new \DateTime ($ value ));
1383+ $ document ->setAttribute ($ key , $ newValue );
1384+ } catch (\Throwable $ th ) {
1385+ // skip -> a valid string
1386+ }
1387+ }
1388+ }
1389+ }
13741390
13751391 return $ document ;
13761392 }
@@ -2752,6 +2768,25 @@ protected function getOrder(string $order): int
27522768 };
27532769 }
27542770
2771+ /**
2772+ * Check if tenant should be added to index
2773+ *
2774+ * @param Document|string $indexOrType Index document or index type string
2775+ * @return bool
2776+ */
2777+ protected function shouldAddTenantToIndex (Document |string $ indexOrType ): bool
2778+ {
2779+ if (!$ this ->sharedTables ) {
2780+ return false ;
2781+ }
2782+
2783+ $ indexType = $ indexOrType instanceof Document
2784+ ? $ indexOrType ->getAttribute ('type ' )
2785+ : $ indexOrType ;
2786+
2787+ return $ indexType !== Database::INDEX_TTL ;
2788+ }
2789+
27552790 /**
27562791 * @param array<Query> $selects
27572792 *
@@ -3519,4 +3554,128 @@ public function getSupportForTrigramIndex(): bool
35193554 {
35203555 return false ;
35213556 }
3557+
3558+ public function getSupportForTTLIndexes (): bool
3559+ {
3560+ return true ;
3561+ }
3562+
3563+ protected function isExtendedISODatetime (string $ val ): bool
3564+ {
3565+ /**
3566+ * Min:
3567+ * YYYY-MM-DDTHH:mm:ssZ (20)
3568+ * YYYY-MM-DDTHH:mm:ss+HH:MM (25)
3569+ *
3570+ * Max:
3571+ * YYYY-MM-DDTHH:mm:ss.fffffZ (26)
3572+ * YYYY-MM-DDTHH:mm:ss.fffff+HH:MM (31)
3573+ */
3574+
3575+ $ len = strlen ($ val );
3576+
3577+ // absolute minimum
3578+ if ($ len < 20 ) {
3579+ return false ;
3580+ }
3581+
3582+ // fixed datetime fingerprints
3583+ if (
3584+ !isset ($ val [19 ]) ||
3585+ $ val [4 ] !== '- ' ||
3586+ $ val [7 ] !== '- ' ||
3587+ $ val [10 ] !== 'T ' ||
3588+ $ val [13 ] !== ': ' ||
3589+ $ val [16 ] !== ': '
3590+ ) {
3591+ return false ;
3592+ }
3593+
3594+ // timezone detection
3595+ $ hasZ = ($ val [$ len - 1 ] === 'Z ' );
3596+
3597+ $ hasOffset = (
3598+ $ len >= 25 &&
3599+ ($ val [$ len - 6 ] === '+ ' || $ val [$ len - 6 ] === '- ' ) &&
3600+ $ val [$ len - 3 ] === ': '
3601+ );
3602+
3603+ if (!$ hasZ && !$ hasOffset ) {
3604+ return false ;
3605+ }
3606+
3607+ if ($ hasOffset && $ len > 31 ) {
3608+ return false ;
3609+ }
3610+
3611+ if ($ hasZ && $ len > 26 ) {
3612+ return false ;
3613+ }
3614+
3615+ $ digitPositions = [
3616+ 0 ,1 ,2 ,3 ,
3617+ 5 ,6 ,
3618+ 8 ,9 ,
3619+ 11 ,12 ,
3620+ 14 ,15 ,
3621+ 17 ,18
3622+ ];
3623+
3624+ $ timeEnd = $ hasZ ? $ len - 1 : $ len - 6 ;
3625+
3626+ // fractional seconds
3627+ if ($ timeEnd > 19 ) {
3628+ if ($ val [19 ] !== '. ' || $ timeEnd < 21 ) {
3629+ return false ;
3630+ }
3631+ for ($ i = 20 ; $ i < $ timeEnd ; $ i ++) {
3632+ $ digitPositions [] = $ i ;
3633+ }
3634+ }
3635+
3636+ // timezone offset numeric digits
3637+ if ($ hasOffset ) {
3638+ foreach ([$ len - 5 , $ len - 4 , $ len - 2 , $ len - 1 ] as $ i ) {
3639+ $ digitPositions [] = $ i ;
3640+ }
3641+ }
3642+
3643+ foreach ($ digitPositions as $ i ) {
3644+ if (!ctype_digit ($ val [$ i ])) {
3645+ return false ;
3646+ }
3647+ }
3648+
3649+ return true ;
3650+ }
3651+
3652+ protected function convertUTCDateToString (mixed $ node ): mixed
3653+ {
3654+ if ($ node instanceof UTCDateTime) {
3655+ // Handle UTCDateTime objects
3656+ $ node = DateTime::format ($ node ->toDateTime ());
3657+ } elseif (is_array ($ node ) && isset ($ node ['$date ' ])) {
3658+ // Handle Extended JSON format from (array) cast
3659+ // Format: {"$date":{"$numberLong":"1760405478290"}}
3660+ if (is_array ($ node ['$date ' ]) && isset ($ node ['$date ' ]['$numberLong ' ])) {
3661+ $ milliseconds = (int )$ node ['$date ' ]['$numberLong ' ];
3662+ $ seconds = intdiv ($ milliseconds , 1000 );
3663+ $ microseconds = ($ milliseconds % 1000 ) * 1000 ;
3664+ $ dateTime = \DateTime::createFromFormat ('U.u ' , $ seconds . '. ' . str_pad ((string )$ microseconds , 6 , '0 ' ));
3665+ if ($ dateTime ) {
3666+ $ dateTime ->setTimezone (new \DateTimeZone ('UTC ' ));
3667+ $ node = DateTime::format ($ dateTime );
3668+ }
3669+ }
3670+ } elseif (is_string ($ node )) {
3671+ // Already a string, validate and pass through
3672+ try {
3673+ new \DateTime ($ node );
3674+ } catch (\Exception $ e ) {
3675+ // Invalid date string, skip
3676+ }
3677+ }
3678+
3679+ return $ node ;
3680+ }
35223681}
0 commit comments