diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index a3f7a943..28d3ea88 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1504,17 +1504,71 @@ 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. If the relationship has not + * been loaded, initializes and returns its relationship type default 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 ) { + if ( !hasRelationship( arguments.name ) ) { + throwRelationshipNotFound( arguments.name ); + } + if ( variables._relationshipsData.keyExists( arguments.name ) ) { + return variables._relationshipsData[ arguments.name ]; + } + if ( isRelationshipLoaded( arguments.name ) ) { + return javacast( "null", "" ); + } + + var relationship = resolveRelationship( arguments.name ); + if ( arguments.keyExists( "defaultValue" ) ) { + assignRelationship( arguments.name, arguments.defaultValue ); + return arguments.defaultValue; + } + + relationship.initRelation( [ this ], arguments.name ); + return variables._relationshipsData.keyExists( arguments.name ) + ? variables._relationshipsData[ arguments.name ] + : javacast( "null", "" ); + } + + /** + * Resolves and validates a relationship definition by name. + * + * @name The relationship method name to resolve. + * + * @throws RelationshipNotFound + * + * @return quick.models.Relationships.BaseRelationship + */ + private any function resolveRelationship( required string name ) { + var relationshipName = arguments.name; + var relationship = ignoreLoadedGuard( function() { + return invoke( this, relationshipName ); + } ); + if ( !isObject( relationship ) || !structKeyExists( relationship, "relationshipClass" ) ) { + throwRelationshipNotFound( arguments.name ); + } + relationship.setRelationMethodName( arguments.name ); + return relationship; + } + + /** + * 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." ); } @@ -2504,6 +2558,20 @@ component accessors="true" { return; } + if ( !isRelationshipLoaded( relationshipName ) && !isLoaded() ) { + var relationshipArguments = arguments.missingMethodArguments; + var unloadedRelationship = ignoreLoadedGuard( function() { + return invoke( + this, + relationshipName, + relationshipArguments + ); + } ); + unloadedRelationship.setRelationMethodName( relationshipName ); + unloadedRelationship.initRelation( [ this ], relationshipName ); + 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 6850674f..87b41d4b 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 ) { + return arguments.entities.map( function( entity ) { + arguments.entity.assignRelationship( arguments.relation, javacast( "null", "" ) ); + return arguments.entity; + } ); + } + /** * 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 78897ba0..041826c1 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 d4406113..5dfa3b38 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(