diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 3e0d798e..b18ffd10 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -89,6 +89,7 @@ component accessors="true" transientCache="false" { function init() { variables._eagerLoad = []; + variables._parallelEagerLoading = false; variables._globalScopesApplied = false; variables._globalScopeExcludeAll = false; variables._asMemento = false; @@ -528,9 +529,11 @@ component accessors="true" transientCache="false" { * @relationName A single relation name or array of relation * names to eager load. * + * @parallel If true, eager loads top-level relationships concurrently. + * * @return QuickBuilder */ - public any function with( required any relationName ) { + public any function with( required any relationName, boolean parallel = false ) { if ( isSimpleValue( arguments.relationName ) && arguments.relationName == "" ) { return this; } @@ -540,6 +543,7 @@ component accessors="true" transientCache="false" { arrayWrap( arguments.relationName ), true ); + variables._parallelEagerLoading = variables._parallelEagerLoading || arguments.parallel; return this; } @@ -575,17 +579,95 @@ component accessors="true" transientCache="false" { } } - structEach( denestEagerLoads( variables._eagerLoad ), function( relationName, nestedEagerLoads ) { - entities = eagerLoadRelation( - relationName, - nestedEagerLoads, - entities - ); - } ); + var eagerLoads = denestEagerLoads( variables._eagerLoad ); + if ( variables._parallelEagerLoading && eagerLoads.count() > 1 && supportsParallelEagerLoading() ) { + eagerLoadRelationsInParallel( eagerLoads, arguments.entities ); + } else { + structEach( eagerLoads, function( relationName, nestedEagerLoads ) { + entities = eagerLoadRelation( + relationName, + nestedEagerLoads, + entities + ); + } ); + } return arguments.entities; } + /** + * Eager loads independent top-level relationships on separate threads. + */ + private void function eagerLoadRelationsInParallel( required struct eagerLoads, required array entities ) { + var threadNames = []; + var threadRelations = {}; + var targetEntities = arguments.entities; + + for ( var relationName in arguments.eagerLoads ) { + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + threadNames.append( threadName ); + threadRelations[ threadName ] = relationName; + cfthread( + action = "run", + name = threadName, + relationName = relationName, + eagerLoadConfig = arguments.eagerLoads[ relationName ], + entities = targetEntities + ) { + thread.entities = eagerLoadRelation( + attributes.relationName, + attributes.eagerLoadConfig, + attributes.entities + ); + } + } + + cfthread( + action = "join", + name = threadNames.toList(), + timeout = 60000 + ); + + threadNames.each( function( threadName ) { + if ( cfthread[ threadName ].status == "TERMINATED" ) { + var threadError = cfthread[ threadName ].error; + throw( + type = "QuickParallelEagerLoadingException", + message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", + extendedInfo = serializeJSON( threadError ) + ); + } + if ( cfthread[ threadName ].status != "COMPLETED" ) { + throw( + type = "QuickParallelEagerLoadingTimeout", + message = "Parallel eager loading did not complete within 60 seconds." + ); + } + + var relationName = threadRelations[ threadName ]; + var eagerLoadedEntities = cfthread[ threadName ].entities; + for ( var i = 1; i <= targetEntities.len(); i++ ) { + if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { + var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( relationName ); + if ( isNull( relationshipValue ) ) { + targetEntities[ i ].assignRelationship( relationName ); + } else { + targetEntities[ i ].assignRelationship( relationName, relationshipValue ); + } + } else if ( eagerLoadedEntities[ i ].keyExists( relationName ) ) { + targetEntities[ i ][ relationName ] = eagerLoadedEntities[ i ][ relationName ]; + } + } + } ); + } + + /** + * Adobe ColdFusion loses CFC private-method resolution inside cfthread. + */ + private boolean function supportsParallelEagerLoading() { + return !findNoCase( "ColdFusion", server.coldfusion.productName ); + } + private struct function denestEagerLoads( required array eagerLoads ) { // this comes in as an array of items which can be: // 1. dot-delimited strings (e.g., "videos.tags") @@ -731,7 +813,7 @@ component accessors="true" transientCache="false" { * @doc_generic quick.models.BaseEntity | struct * @return [quick.models.BaseEntity] | [struct] */ - private array function eagerLoadRelation( + public array function eagerLoadRelation( required string relationName, required struct eagerLoadConfig, required array entities diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index f9024786..73f30106 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -34,6 +34,45 @@ component extends="tests.resources.ModuleIntegrationSpec" { } } ); + it( "can eager load top-level relationships in parallel", function() { + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + var eagerThreads = {}; + var posts = getInstance( "Post" ) + .with( + [ + { + "author" : function( relationship ) { + eagerThreads.author = createObject( "java", "java.lang.Thread" ) + .currentThread() + .getName(); + } + }, + { + "comments" : function( relationship ) { + eagerThreads.comments = createObject( "java", "java.lang.Thread" ) + .currentThread() + .getName(); + } + } + ], + true + ) + .get(); + + expect( posts[ 1 ].getAuthor() ).toBeInstanceOf( "app.models.User" ); + expect( posts[ 1 ].getComments() ).toBeArray(); + expect( eagerThreads ).toHaveKey( "author" ); + expect( eagerThreads ).toHaveKey( "comments" ); + if ( findNoCase( "ColdFusion", server.coldfusion.productName ) ) { + expect( eagerThreads.author ).toBe( callingThread ); + expect( eagerThreads.comments ).toBe( callingThread ); + } else { + expect( eagerThreads.author ).notToBe( callingThread ); + expect( eagerThreads.comments ).notToBe( callingThread ); + expect( eagerThreads.author ).notToBe( eagerThreads.comments ); + } + } ); + it( "can eager load a belongs to relationship using a composite key", function() { var compositeChildren = getInstance( "CompositeChild" ).with( "parent" ).get(); expect( compositeChildren ).toBeArray();