diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 8900ec48..a7a1a2ce 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1686,17 +1686,86 @@ component accessors="true" { } /** - * Retrieves the result of a loaded relationship. - * If there is no data, returns null instead. + * Retrieves the result of a loaded relationship. For a new entity, an unloaded + * relationship is initialized through its public getter without executing a + * query. An explicit default value can be supplied instead. * - * @name The relationship name to retrieve. + * @name The relationship name to retrieve. + * @defaultValue An optional value to assign and return when the relationship + * has not been loaded. * * @return quick.models.BaseEntity | [quick.models.BaseEntity] */ - public any function retrieveRelationship( required string name ) { - return variables._relationshipsData.keyExists( arguments.name ) ? variables._relationshipsData[ arguments.name ] : javacast( - "null", - "" + public any function retrieveRelationship( + required string name, + any defaultValue = variables._nullValueArgumentSentinel + ) { + if ( variables._relationshipsData.keyExists( arguments.name ) ) { + return variables._relationshipsData[ arguments.name ]; + } + if ( isRelationshipLoaded( arguments.name ) ) { + return javacast( "null", "" ); + } + if ( !hasRelationship( arguments.name ) ) { + throwRelationshipNotFound( arguments.name ); + } + + if ( !variables._nullValueArgumentSentinel.equals( arguments.defaultValue ) ) { + assignRelationship( arguments.name, arguments.defaultValue ); + return arguments.defaultValue; + } + + if ( !isLoaded() ) { + initializeUnloadedRelationship( arguments.name, {} ); + return retrieveRelationship( arguments.name ); + } + return javacast( "null", "" ); + } + + /** + * Resolves and initializes an unloaded relationship by name. + * + * @name The relationship method name to resolve. + * + * @throws RelationshipNotFound + * + */ + private void function initializeUnloadedRelationship( required string name, struct relationshipArguments = {} ) { + var previousIgnoreLoadedGuard = variables._ignoreNotLoadedGuard; + variables._ignoreNotLoadedGuard = true; + var resolvedRelationshipContainer = {}; + try { + resolvedRelationshipContainer.value = invoke( + this, + arguments.name, + arguments.relationshipArguments + ); + } finally { + variables._ignoreNotLoadedGuard = previousIgnoreLoadedGuard; + } + if ( + !resolvedRelationshipContainer.keyExists( "value" ) || + !isObject( resolvedRelationshipContainer.value ) || + !structKeyExists( resolvedRelationshipContainer.value, "relationshipClass" ) + ) { + throwRelationshipNotFound( arguments.name ); + } + var relationship = resolvedRelationshipContainer.value; + relationship.setRelationMethodName( arguments.name ); + relationship.initRelation( [ this ], arguments.name ); + } + + /** + * Throws a consistent exception for an unknown relationship name. + * + * @name The unknown relationship name. + * + * @throws RelationshipNotFound + */ + private void function throwRelationshipNotFound( required string name ) { + throw( + type = "RelationshipNotFound", + message = "The [#arguments.name#] relationship was not found on the [#entityName()#] entity." ); } @@ -1944,7 +2013,6 @@ component accessors="true" { arguments.foreignKey = arrayWrap( arguments.foreignKey ); param arguments.localKey = keyNames(); arguments.localKey = arrayWrap( arguments.localKey ); - return variables._wirebox.getInstance( name = "HasMany@quick", initArguments = { @@ -2032,7 +2100,6 @@ component accessors="true" { param arguments.relatedKey = related.keyNames(); arguments.relatedKey = arrayWrap( arguments.relatedKey ); - return variables._wirebox.getInstance( name = "BelongsToMany@quick", initArguments = { @@ -2346,7 +2413,6 @@ component accessors="true" { arguments.id = arrayWrap( arguments.id ); param arguments.localKey = keyNames(); arguments.localKey = arrayWrap( arguments.localKey ); - return variables._wirebox.getInstance( name = "PolymorphicHasMany@quick", initArguments = { @@ -2462,7 +2528,6 @@ component accessors="true" { if ( !structKeyExists( related, "isBuilder" ) ) { related = related.newQuery(); } - guardAgainstNotLoaded( "This instance is not loaded so it cannot access the [#arguments.relationMethodName#] relationship. Either load the entity from the database using a query executor (like `first`) or base your query off of the [#related.getEntity().entityName()#] entity directly and use the `has` or `whereHas` methods to constrain it based on data in [#entityName()#]." ); @@ -2732,6 +2797,12 @@ component accessors="true" { return retrieveRelationship( relationshipName ); } + if ( !isRelationshipLoaded( relationshipName ) && !isLoaded() ) { + var relationshipArguments = arguments.missingMethodArguments; + initializeUnloadedRelationship( relationshipName, relationshipArguments ); + return retrieveRelationship( relationshipName ); + } + if ( !isRelationshipLoaded( relationshipName ) && variables._preventLazyLoading ) { variables._lazyLoadingViolationCallback( this, relationshipName ); } diff --git a/models/Relationships/BaseRelationship.cfc b/models/Relationships/BaseRelationship.cfc index ae51e560..402ae1fe 100644 --- a/models/Relationships/BaseRelationship.cfc +++ b/models/Relationships/BaseRelationship.cfc @@ -116,6 +116,23 @@ component accessors="true" implements="IRelationship" { return this; } + /** + * Initializes a relationship to its unloaded default value. + * To-one relationships default to null. Collection relationships override + * this method to initialize an empty array. + * + * @entities The entities on which to initialize the relationship. + * @relation The relationship name to initialize. + * + * @return [quick.models.BaseEntity] + */ + public array function initRelation( required array entities, required string relation ) { + for ( var entity in arguments.entities ) { + entity.assignRelationship( arguments.relation, javacast( "null", "" ) ); + } + return arguments.entities; + } + /** * Retrieves the entities for eager loading. * diff --git a/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc index 91dcb06e..12e3691e 100644 --- a/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc @@ -18,6 +18,57 @@ component extends="tests.resources.ModuleIntegrationSpec" { variables.queries = []; } ); + it( "returns empty relationship values for new entities", function() { + var post = getInstance( "Post" ); + var user = getInstance( "User" ); + + expect( post.getAuthor() ).toBeNull(); + expect( user.getLatestPost() ).toBeNull(); + expect( user.getPosts() ).toBeArray().toBeEmpty(); + expect( post.getTags() ).toBeArray().toBeEmpty(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "includes empty relationships in mementos for new entities", function() { + var memento = getInstance( "User" ).getMemento( includes = [ "latestPost", "posts" ] ); + + expect( memento.latestPost ).toBe( "" ); + expect( memento.posts ).toBeArray().toBeEmpty(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "retrieves and caches the relationship type default without querying", function() { + var user = getInstance( "User" ); + var post = getInstance( "Post" ); + + expect( user.retrieveRelationship( "posts" ) ).toBeArray().toBeEmpty(); + expect( post.retrieveRelationship( "author" ) ).toBeNull(); + expect( post.retrieveRelationship( "authorWithEmptyDefault" ) ).toBeInstanceOf( "User" ); + expect( user.isRelationshipLoaded( "posts" ) ).toBeTrue(); + expect( post.isRelationshipLoaded( "author" ) ).toBeTrue(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "accepts a default relationship value", function() { + var user = getInstance( "User" ); + var seededPost = getInstance( "Post" ).fill( { "body" : "seeded" } ); + + var posts = user.retrieveRelationship( "posts", [ seededPost ] ); + + expect( posts ).toHaveLength( 1 ); + expect( posts[ 1 ].getBody() ).toBe( "seeded" ); + expect( user.isRelationshipLoaded( "posts" ) ).toBeTrue(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "throws when retrieving an unknown relationship", function() { + var post = getInstance( "Post" ); + + expect( function() { + post.retrieveRelationship( "missingRelationship" ); + } ).toThrow( "RelationshipNotFound" ); + } ); + describe( "Eager Loading Spec", function() { it( "can load a relationship for an entity", function() { var elpete = getInstance( "User" ).where( "username", "elpete" ).firstOrFail(); diff --git a/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc index 44fa70bf..95c634a9 100644 --- a/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc @@ -2,12 +2,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "WithDefault Spec", function() { - it( "will throw an exception when retrieving a relation on an unloaded entity", function() { - var post = getInstance( "Post" ); + it( "returns a configured default for a relation on an unloaded entity", function() { + var post = getInstance( "Post" ); + var author = post.getAuthorWithEmptyDefault(); - expect( function() { - post.getAuthor(); - } ).toThrow( message = "Retrieving an unloaded entity should throw an exception" ); + expect( author ).toBeInstanceOf( "User" ); + expect( author.isLoaded() ).toBeFalse( "A default model is not loaded" ); + expect( author.retrieveAttributesData() ).toBeEmpty(); } ); it( "can load a entity and return a default entity if there is no owning entity", function() { diff --git a/tests/specs/integration/GoodErrorMessagesSpec.cfc b/tests/specs/integration/GoodErrorMessagesSpec.cfc index b70a0adf..e9c0f69d 100644 --- a/tests/specs/integration/GoodErrorMessagesSpec.cfc +++ b/tests/specs/integration/GoodErrorMessagesSpec.cfc @@ -35,20 +35,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ).toThrow( type = "QuickEntityDefaultedKey" ); } ); - it( "throws a helpful error message when trying to access relationships on unloaded entities", function() { + it( "throws a helpful error message when trying to query relationships on unloaded entities", function() { expect( function() { getInstance( "User" ).posts(); } ).toThrow( type = "QuickEntityNotLoaded", regex = "This instance is not loaded so it cannot access the \[posts\] relationship\. Either load the entity from the database using a query executor \(like \`first\`\) or base your query off of the \[Post\] entity directly and use the \`has\` or \`whereHas\` methods to constrain it based on data in \[User\]\." ); - - expect( function() { - getInstance( "User" ).getPosts(); - } ).toThrow( - type = "QuickEntityNotLoaded", - regex = "This instance is not loaded so it cannot access the \[posts\] relationship\. Either load the entity from the database using a query executor \(like \`first\`\) or base your query off of the \[Post\] entity directly and use the \`has\` or \`whereHas\` methods to constrain it based on data in \[User\]\." - ); } ); it(