Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions models/BaseEntity.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
225 changes: 217 additions & 8 deletions models/QuickBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -620,6 +623,7 @@ component accessors="true" transientCache="false" {
arrayWrap( arguments.relationName ),
true
);
variables._parallelEagerLoading = variables._parallelEagerLoading || arguments.parallel;

return this;
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down