diff --git a/models/QuickQB.cfc b/models/QuickQB.cfc index 44561a3..9f362d1 100644 --- a/models/QuickQB.cfc +++ b/models/QuickQB.cfc @@ -403,6 +403,105 @@ component return super.whereExists( argumentCollection = arguments ); } + /** + * Constrains the query to entities belonging to one or more related entities. + * The relationship name defaults to the lower-camel-cased related entity name. + * + * @relationshipName The belongsTo relationship to use. + * A related entity can be passed here as a shortcut that infers the relationship name. + * @related A related Quick entity, an array of entities, or a collection of entities. + * @combinator The boolean combinator for the clause. Default: "and". + * + * @return quick.models.QuickQB + */ + public QuickQB function whereBelongsTo( + required any relationshipName, + any related, + string combinator = "and" + ) { + if ( isNull( arguments.related ) ) { + arguments.related = arguments.relationshipName; + arguments.delete( "relationshipName" ); + } + + var relatedEntities = isArray( arguments.related ) + ? arguments.related + : ( + isStruct( arguments.related ) && structKeyExists( arguments.related, "isQuickEntity" ) + ? [ arguments.related ] + : arguments.related.get() + ); + + if ( relatedEntities.isEmpty() ) { + throw( + type = "QuickInvalidWhereBelongsTo", + message = "whereBelongsTo requires at least one related entity." + ); + } + + if ( !arguments.keyExists( "relationshipName" ) || isNull( arguments.relationshipName ) ) { + var relatedEntityName = relatedEntities[ 1 ].entityName(); + arguments.relationshipName = lCase( left( relatedEntityName, 1 ) ) & removeChars( relatedEntityName, 1, 1 ); + } + var resolvedRelationshipName = arguments.relationshipName; + + var relation = getEntity().ignoreLoadedGuard( function() { + return getEntity().withoutRelationshipConstraints( resolvedRelationshipName, function() { + return invoke( getEntity(), resolvedRelationshipName ); + } ); + } ); + + if ( relation.relationshipClass != "BelongsTo" ) { + throw( + type = "QuickInvalidWhereBelongsTo", + message = "Relationship [#resolvedRelationshipName#] must be a belongsTo relationship." + ); + } + + var relatedMapping = relation.getRelated().mappingName(); + var foreignKeys = relation.getForeignKeys(); + var localKeys = relation.getLocalKeys(); + relatedEntities.each( function( relatedEntity ) { + if ( + !isStruct( relatedEntity ) || + !structKeyExists( relatedEntity, "isQuickEntity" ) || + relatedEntity.mappingName() != relatedMapping + ) { + throw( + type = "QuickInvalidWhereBelongsTo", + message = "All whereBelongsTo entities must match [#relatedMapping#]." + ); + } + } ); + + return where( + column = function( q ) { + relatedEntities.each( function( relatedEntity ) { + q.orWhere( function( q2 ) { + for ( var i = 1; i <= foreignKeys.len(); i++ ) { + q2.where( foreignKeys[ i ], relatedEntity.retrieveAttribute( localKeys[ i ] ) ); + } + } ); + } ); + }, + combinator = arguments.combinator + ); + } + + /** + * Adds a whereBelongsTo constraint using an OR combinator. + * + * @relationshipName The belongsTo relationship to use. + * A related entity can be passed here as a shortcut that infers the relationship name. + * @related A related Quick entity, an array of entities, or a collection of entities. + * + * @return quick.models.QuickQB + */ + public QuickQB function orWhereBelongsTo( required any relationshipName, any related ) { + arguments.combinator = "or"; + return whereBelongsTo( argumentCollection = arguments ); + } + /** * Checks for the existence of a relationship when executing the query. * diff --git a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc index 97fdaae..b497a19 100644 --- a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc @@ -2,6 +2,60 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Querying Relationships Spec", function() { + describe( "whereBelongsTo", function() { + it( "constrains a query using a named belongsTo relationship", function() { + var author = getInstance( "User" ).findOrFail( 1 ); + var posts = getInstance( "Post" ).whereBelongsTo( "author", author ).get(); + + expect( posts ).toHaveLength( 2 ); + expectAll( posts ).toSatisfy( function( post ) { + return post.getUser_Id() == author.getId(); + } ); + } ); + + it( "infers a conventional belongsTo relationship name", function() { + var country = getInstance( "Country" ).findOrFail( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + var users = getInstance( "User" ).whereBelongsTo( country ).get(); + + expect( users ).toHaveLength( 2 ); + expectAll( users ).toSatisfy( function( user ) { + return user.getCountry_Id() == country.getId(); + } ); + } ); + + it( "constrains a query to an array of related entities", function() { + var authors = getInstance( "User" ).whereIn( "id", [ 1, 4 ] ).get(); + var posts = getInstance( "Post" ).whereBelongsTo( "author", authors ).get(); + + expect( posts ).toHaveLength( 3 ); + expectAll( posts ).toSatisfy( function( post ) { + return arrayFind( [ 1, 4 ], post.getUser_Id() ) > 0; + } ); + } ); + + it( "supports composite belongsTo relationships", function() { + var parent = getInstance( "Composite" ) + .where( "a", 1 ) + .where( "b", 2 ) + .firstOrFail(); + var children = getInstance( "CompositeChild" ).whereBelongsTo( "parent", parent ).get(); + + expect( children ).toHaveLength( 1 ); + expect( children[ 1 ].getComposite_A() ).toBe( 1 ); + expect( children[ 1 ].getComposite_B() ).toBe( 2 ); + } ); + + it( "supports an OR combinator", function() { + var author = getInstance( "User" ).findOrFail( 1 ); + var posts = getInstance( "Post" ) + .where( "post_pk", 7777 ) + .orWhereBelongsTo( "author", author ) + .get(); + + expect( posts ).toHaveLength( 3 ); + } ); + } ); + describe( "has", function() { describe( "hasMany", function() { it( "can find only entities that have one or more related entities", function() {