From 515f1fbf7ebd4b4eca17a8dd257777a4db27cb6b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 15 Oct 2026 15:07:54 -0600 Subject: [PATCH 1/3] fix(refresh): reload cached casts from raw database values --- models/BaseEntity.cfc | 7 +++- .../BaseEntity/AttributeCastsSpec.cfc | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index c1c80398..1fa91e42 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(); + if ( structKeyExists( refreshedEntity, "isQuickBuilder" ) ) { + refreshedEntity.asQuery( false ); + } + var refreshedData = refreshedEntity.first(); + // Cast the new database values on this entity instead of reusing cached values. + variables._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..b1ac1821 100644 --- a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc +++ b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc @@ -106,6 +106,42 @@ 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( "allows custom casts to handle null database values", () => { var pn = getInstance( "CustomCastPhoneNumber" ).find( 3 ); From 7f1f18a602c454955926a4653ccb95dd1b57bafd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 15 Oct 2026 15:09:09 -0600 Subject: [PATCH 2/3] fix(refresh): preserve hydration lifecycle while replacing cast caches --- models/BaseEntity.cfc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 1fa91e42..80e7b195 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1555,12 +1555,12 @@ component accessors="true" { refreshConstraints.where( this.qualifyColumn( entityKeyNames[ i ] ), entityKeyValues[ i ] ); } refreshQB.addNestedWhereQuery( refreshConstraints ); - if ( structKeyExists( refreshedEntity, "isQuickBuilder" ) ) { - refreshedEntity.asQuery( false ); - } var refreshedData = refreshedEntity.first(); - // Cast the new database values on this entity instead of reusing cached values. - variables._castCache = {}; + // 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 From cfd57cd65f9c9d9df5b7a9b9d508547c8fa1c866 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 15 Oct 2026 15:09:42 -0600 Subject: [PATCH 3/3] test(refresh): cover cast caches in projection snapshots --- .../integration/BaseEntity/AttributeCastsSpec.cfc | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc index b1ac1821..6bf1c7ff 100644 --- a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc +++ b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc @@ -142,6 +142,21 @@ component extends="tests.resources.ModuleIntegrationSpec" { 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 );