From 1bfc56f54a78e619a789350cc461b76c792d69ad Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 05:08:24 -0600 Subject: [PATCH] feat: support virtual attribute defaults (#113) BREAKING CHANGE: appendVirtualAttribute() now treats its second positional argument as defaultValue. Callers passing excludeFromMemento positionally must move that flag to the third argument or use the named excludeFromMemento argument. --- models/BaseEntity.cfc | 32 ++++++++++++++++--- models/QuickBuilder.cfc | 10 ++++-- .../integration/BaseEntity/AttributeSpec.cfc | 17 ++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index a3f7a943..ba6461dd 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2914,22 +2914,43 @@ component accessors="true" { /** * Creates a virtual attribute for the given name. * - * @name The attribute name to create. + * @name The attribute name to create. + * @defaultValue The default value for the virtual attribute. + * @excludeFromMemento Whether to exclude the virtual attribute from mementos. * * @return quick.models.BaseEntity */ - public any function appendVirtualAttribute( required string name, boolean excludeFromMemento = false ) { + public any function appendVirtualAttribute( + required string name, + any defaultValue, + boolean excludeFromMemento = false + ) { if ( !variables._attributes.keyExists( retrieveAliasForColumn( arguments.name ) ) ) { - var attr = paramAttribute( { + var attributeDefinition = { "name" : arguments.name, "virtual" : true, "exclude" : arguments.excludeFromMemento - } ); + }; + if ( arguments.keyExists( "defaultValue" ) ) { + attributeDefinition.default = arguments.defaultValue; + } + var attr = paramAttribute( attributeDefinition ); variables._attributes[ attr.name ] = attr; variables._columns[ attr.column ] = attr; variables._meta.attributes[ arguments.name ] = variables._attributes[ arguments.name ]; variables._meta.originalMetadata.properties.append( variables._attributes[ arguments.name ] ); variables._virtualAttributes.append( arguments.name ); + if ( + !arguments.excludeFromMemento && + structKeyExists( this, "memento" ) && + structKeyExists( this.memento, "defaultIncludes" ) && + !arrayContainsNoCase( this.memento.defaultIncludes, arguments.name ) + ) { + this.memento.defaultIncludes.append( arguments.name ); + } + if ( arguments.keyExists( "defaultValue" ) ) { + forceAssignAttribute( arguments.name, arguments.defaultValue ); + } } return this; } @@ -2993,6 +3014,9 @@ component accessors="true" { var attr = paramAttribute( arguments.attributes[ alias ] ); variables._attributes[ attr.name ] = attr; variables._columns[ attr.column ] = attr; + if ( attr.virtual && attr.keyExists( "default" ) ) { + forceAssignAttribute( attr.name, attr.default ); + } if ( attr.convertToNull ) { variables._nullValues[ alias ] = attr.nullValue; } diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 3e0d798e..66e5f2e6 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -922,11 +922,17 @@ component accessors="true" transientCache="false" { /** * Creates a virtual attribute for the given name. * - * @name The attribute name to create. + * @name The attribute name to create. + * @defaultValue The default value for the virtual attribute. + * @excludeFromMemento Whether to exclude the virtual attribute from mementos. * * @return quick.models.BaseEntity */ - public any function appendVirtualAttribute( required string name, boolean excludeFromMemento = false ) { + public any function appendVirtualAttribute( + required string name, + any defaultValue, + boolean excludeFromMemento = false + ) { getEntity().appendVirtualAttribute( argumentCollection = arguments ); return this; } diff --git a/tests/specs/integration/BaseEntity/AttributeSpec.cfc b/tests/specs/integration/BaseEntity/AttributeSpec.cfc index 5bf9abd3..8c397466 100644 --- a/tests/specs/integration/BaseEntity/AttributeSpec.cfc +++ b/tests/specs/integration/BaseEntity/AttributeSpec.cfc @@ -17,6 +17,23 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( attributesFromFresh ).notToInclude( "latestPostId" ); } ); + it( "can provide a default for a virtual attribute", function() { + var user = getInstance( "User" ).appendVirtualAttribute( "hasPosts", false ); + var newUser = user.newEntity(); + + expect( serializeJSON( user.getHasPosts() ) ).toBe( "false" ); + expect( serializeJSON( user.getMemento().hasPosts ) ).toBe( "false" ); + expect( serializeJSON( newUser.getHasPosts() ) ).toBe( "false" ); + expect( serializeJSON( newUser.getMemento().hasPosts ) ).toBe( "false" ); + } ); + + it( "can exclude a defaulted virtual attribute from mementos", function() { + var user = getInstance( "User" ).appendVirtualAttribute( "internalFlag", false, true ); + + expect( serializeJSON( user.getInternalFlag() ) ).toBe( "false" ); + expect( user.getMemento() ).notToHaveKey( "internalFlag" ); + } ); + it( "can get any attribute using the `getColumnName` magic methods", function() { var user = getInstance( "User" ).find( 1 ); expect( user.getId() ).toBe( 1 );