diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index c900d867..b8c8218d 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1875,6 +1875,15 @@ component accessors="true" { return structKeyExists( variables._relationshipsLoaded, arguments.name ); } + /** + * Returns the names of the currently loaded relationships. + * + * @return The loaded relationship names. + */ + public array function retrieveLoadedRelationshipNames() { + return variables._relationshipsLoaded.keyArray(); + } + /** * Retrieves the result of a loaded relationship. * If there is no data, returns null instead. diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 16f392d6..625a07c4 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -94,6 +94,7 @@ component accessors="true" transientCache="false" { function init() { variables._eagerLoad = []; + variables._parallelEagerLoading = false; variables._globalScopesApplied = false; variables._globalScopeExcludeAll = false; variables._asMemento = false; @@ -608,9 +609,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; } @@ -620,6 +623,7 @@ component accessors="true" transientCache="false" { arrayWrap( arguments.relationName ), true ); + variables._parallelEagerLoading = variables._parallelEagerLoading || arguments.parallel; return this; } @@ -701,17 +705,222 @@ component accessors="true" transientCache="false" { } var eagerLoads = denestEagerLoads( variables._eagerLoad ); - for ( var relationName in eagerLoads ) { - arguments.entities = eagerLoadRelation( - relationName, - eagerLoads[ relationName ], - arguments.entities - ); + if ( variables._parallelEagerLoading && eagerLoads.count() > 1 && supportsParallelEagerLoading() ) { + eagerLoadRelationsInParallel( eagerLoads, arguments.entities ); + } else { + for ( var relationName in eagerLoads ) { + arguments.entities = eagerLoadRelation( + relationName, + eagerLoads[ relationName ], + arguments.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; + var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + + for ( var relationName in arguments.eagerLoads ) { + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + var entityStates = []; + for ( var entity in arguments.entities ) { + entityStates.append( + structKeyExists( entity, "isQuickEntity" ) + ? { + "isQuickEntity" : true, + "mappingName" : entity.mappingName(), + "attributes" : entity.retrieveAttributesData( withNulls = true ) + } + : { + "isQuickEntity" : false, + "value" : duplicate( entity ) + } + ); + } + threadNames.append( threadName ); + threadRelations[ threadName ] = relationName; + cfthread( + action = "run", + name = threadName, + threadName = threadName, + relationName = relationName, + eagerLoadConfig = arguments.eagerLoads[ relationName ], + entityStates = entityStates, + results = threadResults + ) { + var workerEntities = []; + for ( var entityState in attributes.entityStates ) { + workerEntities.append( + entityState.isQuickEntity + ? getEntity().newEntity( entityState.mappingName ).hydrate( entityState.attributes ) + : entityState.value + ); + } + var loadedEntities = eagerLoadRelation( + attributes.relationName, + attributes.eagerLoadConfig, + workerEntities + ); + var relationshipValues = []; + for ( var entity in loadedEntities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + if ( isNull( entity.retrieveRelationship( attributes.relationName ) ) ) { + relationshipValues.append( { "type" : "null" } ); + } else { + relationshipValues.append( + serializeParallelValue( entity.retrieveRelationship( attributes.relationName ) ) + ); + } + } else { + relationshipValues.append( + serializeParallelValue( + entity.keyExists( attributes.relationName ) + ? entity[ attributes.relationName ] + : javacast( "null", "" ) + ) + ); + } + } + attributes.results.put( attributes.threadName, relationshipValues ); + } + } + + 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 relationshipValues = threadResults.get( threadName ); + for ( var i = 1; i <= targetEntities.len(); i++ ) { + var relationshipValue = deserializeParallelValue( relationshipValues[ i ] ); + if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { + if ( isNull( relationshipValue ) ) { + targetEntities[ i ].assignRelationship( relationName ); + } else { + targetEntities[ i ].assignRelationship( relationName, relationshipValue ); + } + } else if ( !isNull( relationshipValue ) ) { + targetEntities[ i ][ relationName ] = relationshipValue; + } + } + } ); + } + + /** + * Converts eager-loaded values to CFC-free state for crossing thread boundaries. + */ + private struct function serializeParallelValue( any value ) { + if ( isNull( arguments.value ) ) { + return { "type" : "null" }; + } + if ( isArray( arguments.value ) ) { + var items = []; + for ( var item in arguments.value ) { + items.append( serializeParallelValue( item ) ); + } + return { "type" : "array", "value" : items }; + } + if ( isStruct( arguments.value ) && structKeyExists( arguments.value, "isQuickEntity" ) ) { + var relationships = {}; + for ( var relationshipName in arguments.value.retrieveLoadedRelationshipNames() ) { + relationships[ relationshipName ] = serializeParallelValue( + arguments.value.retrieveRelationship( relationshipName ) + ); + } + return { + "type" : "entity", + "mappingName" : arguments.value.mappingName(), + "attributes" : arguments.value.retrieveAttributesData( withNulls = true ), + "relationships" : relationships + }; + } + if ( isStruct( arguments.value ) ) { + var values = {}; + for ( var key in arguments.value ) { + values[ key ] = serializeParallelValue( arguments.value[ key ] ); + } + return { "type" : "struct", "value" : values }; + } + return { + "type" : "value", + "value" : arguments.value + }; + } + + /** + * Reconstructs eager-loaded values exported by a worker thread. + */ + private any function deserializeParallelValue( required struct state ) { + switch ( arguments.state.type ) { + case "null": + return javacast( "null", "" ); + case "array": + var items = []; + for ( var item in arguments.state.value ) { + items.append( deserializeParallelValue( item ) ); + } + return items; + case "entity": + var entity = getEntity().newEntity( arguments.state.mappingName ).hydrate( arguments.state.attributes ); + for ( var relationshipName in arguments.state.relationships ) { + var relationshipValue = deserializeParallelValue( + arguments.state.relationships[ relationshipName ] + ); + if ( isNull( relationshipValue ) ) { + entity.assignRelationship( relationshipName ); + } else { + entity.assignRelationship( relationshipName, relationshipValue ); + } + } + return entity; + case "struct": + var values = {}; + for ( var key in arguments.state.value ) { + var value = deserializeParallelValue( arguments.state.value[ key ] ); + if ( !isNull( value ) ) { + values[ key ] = value; + } + } + return values; + default: + return arguments.state.value; + } + } + + /** + * Adobe ColdFusion loses CFC private-method resolution inside cfthread. + */ + private boolean function supportsParallelEagerLoading() { + return !server.keyExists( "coldfusion" ) || !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") @@ -848,7 +1057,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 4d45318a..8aa7033c 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -61,6 +61,45 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( keys ).toHaveLength( 2 ); } ); + 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 ( server.keyExists( "coldfusion" ) && 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();