diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index c1c80398..80e7b195 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1555,7 +1555,12 @@ component accessors="true" { refreshConstraints.where( this.qualifyColumn( entityKeyNames[ i ] ), entityKeyValues[ i ] ); } refreshQB.addNestedWhereQuery( refreshConstraints ); - var refreshedData = refreshedEntity.first(); + var refreshedData = refreshedEntity.first(); + // Raw snapshots need new casts; hydrated reads already contain the new cast values. + // Copy only the cache map so its entries remain isolated without copying cast objects. + variables._castCache = isStruct( refreshedData ) && !structKeyExists( refreshedData, "isQuickEntity" ) + ? {} + : structCopy( refreshedData.get_castCache() ); assignAttributesData( isStruct( refreshedData ) && !structKeyExists( refreshedData, "isQuickEntity" ) ? refreshedData diff --git a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc index 85c6df9c..6bf1c7ff 100644 --- a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc +++ b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc @@ -106,6 +106,57 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( pn.isNullAttribute( "confirmed" ) ).toBeTrue( "[confirmed] should remain null after saving" ); } ); + it( "refreshes cached boolean casts after external updates", () => { + var pn = getInstance( "PhoneNumber" ).findOrFail( 3 ); + expect( pn.isNullAttribute( "confirmed" ) ).toBeTrue(); + + queryExecute( "UPDATE `phone_numbers` SET `confirmed` = 1 WHERE `number` = ?", [ pn.getNumber() ] ); + pn.refresh(); + expect( pn.getConfirmed() ).toBeTrue(); + + queryExecute( "UPDATE `phone_numbers` SET `confirmed` = 0 WHERE `number` = ?", [ pn.getNumber() ] ); + pn.refresh(); + expect( pn.isNullAttribute( "confirmed" ) ).toBeFalse(); + expect( pn.getConfirmed() ).toBeFalse(); + + queryExecute( "UPDATE `phone_numbers` SET `confirmed` = NULL WHERE `number` = ?", [ pn.getNumber() ] ); + pn.refresh(); + expect( pn.isNullAttribute( "confirmed" ) ).toBeTrue(); + } ); + + it( "refreshes cached object casts from raw database values", () => { + var theme = getInstance( "Theme" ).create( { + "slug" : "refresh-cast-cache", + "version" : "1.0.0", + "config" : { "primaryColor" : "orange" } + } ); + expect( theme.getConfig().primaryColor ).toBe( "orange" ); + queryExecute( + "UPDATE `themes` SET `config` = ? WHERE `slug` = ?", + [ + serializeJSON( { "primaryColor" : "blue" } ), + theme.getSlug() + ] + ); + theme.refresh(); + expect( theme.getConfig().primaryColor ).toBe( "blue" ); + } ); + + it( "refreshes cached casts through a stored projection snapshot", () => { + var pn = getInstance( "PhoneNumber" ) + .appendVirtualAttribute( "refreshMarker" ) + .selectRaw( "1 AS refreshMarker" ) + .findOrFail( 3 ); + queryExecute( "UPDATE `phone_numbers` SET `confirmed` = 1 WHERE `number` = ?", [ pn.getNumber() ] ); + pn.refresh(); + expect( pn.getConfirmed() ).toBeTrue(); + queryExecute( "UPDATE `phone_numbers` SET `confirmed` = 0 WHERE `number` = ?", [ pn.getNumber() ] ); + pn.refresh(); + expect( pn.isNullAttribute( "confirmed" ) ).toBeFalse(); + expect( pn.getConfirmed() ).toBeFalse(); + expect( pn.getRefreshMarker() ).toBe( 1 ); + } ); + it( "allows custom casts to handle null database values", () => { var pn = getInstance( "CustomCastPhoneNumber" ).find( 3 );