From 9945f91bc4a8c31971e6db2d746519d67c589974 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 03:07:30 -0600 Subject: [PATCH 1/4] feat: add whereBelongsTo relationship constraints (#155) --- models/QuickQB.cfc | 92 +++++++++++++++++++ .../QueryingRelationshipsSpec.cfc | 54 +++++++++++ 2 files changed, 146 insertions(+) diff --git a/models/QuickQB.cfc b/models/QuickQB.cfc index 44561a3..6c2ea4d 100644 --- a/models/QuickQB.cfc +++ b/models/QuickQB.cfc @@ -403,6 +403,98 @@ 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. + * + * @related A related Quick entity, an array of entities, or a collection of entities. + * @relationshipName The belongsTo relationship to use. + * @combinator The boolean combinator for the clause. Default: "and". + * + * @return quick.models.QuickQB + */ + public QuickQB function whereBelongsTo( + required any related, + string relationshipName, + string combinator = "and" + ) { + 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 ( 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. + * + * @related A related Quick entity, an array of entities, or a collection of entities. + * @relationshipName The belongsTo relationship to use. + * + * @return quick.models.QuickQB + */ + public QuickQB function orWhereBelongsTo( required any related, string relationshipName ) { + 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..2bf6f1a 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( authors, "author" ).get(); + + expect( posts ).toHaveLength( 3 ); + expectAll( posts ).toSatisfy( function( post ) { + return [ 1, 4 ].contains( post.getUser_Id() ); + } ); + } ); + + 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() { From 3684eccb9e507c1a624bc0d7f91acb5d7640a054 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 08:02:12 -0600 Subject: [PATCH 2/4] test: use arrayFind for Adobe 2021 compatibility --- .../BaseEntity/Relationships/QueryingRelationshipsSpec.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc index 2bf6f1a..c55775a 100644 --- a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc @@ -29,7 +29,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts ).toHaveLength( 3 ); expectAll( posts ).toSatisfy( function( post ) { - return [ 1, 4 ].contains( post.getUser_Id() ); + return arrayFind( [ 1, 4 ], post.getUser_Id() ) > 0; } ); } ); From bf80c4f1256b3b1900ba4284cb942907c6ff07e0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:43:20 -0600 Subject: [PATCH 3/4] test: put relationship name first in whereBelongsTo --- .../Relationships/QueryingRelationshipsSpec.cfc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc index c55775a..b497a19 100644 --- a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc @@ -5,7 +5,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { 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(); + var posts = getInstance( "Post" ).whereBelongsTo( "author", author ).get(); expect( posts ).toHaveLength( 2 ); expectAll( posts ).toSatisfy( function( post ) { @@ -25,7 +25,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { 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( authors, "author" ).get(); + var posts = getInstance( "Post" ).whereBelongsTo( "author", authors ).get(); expect( posts ).toHaveLength( 3 ); expectAll( posts ).toSatisfy( function( post ) { @@ -38,7 +38,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { .where( "a", 1 ) .where( "b", 2 ) .firstOrFail(); - var children = getInstance( "CompositeChild" ).whereBelongsTo( parent, "parent" ).get(); + var children = getInstance( "CompositeChild" ).whereBelongsTo( "parent", parent ).get(); expect( children ).toHaveLength( 1 ); expect( children[ 1 ].getComposite_A() ).toBe( 1 ); @@ -49,7 +49,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { var author = getInstance( "User" ).findOrFail( 1 ); var posts = getInstance( "Post" ) .where( "post_pk", 7777 ) - .orWhereBelongsTo( author, "author" ) + .orWhereBelongsTo( "author", author ) .get(); expect( posts ).toHaveLength( 3 ); From 49f42cc511d1e7b61a1bbc16ead77e99bb1f2dee Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:45:16 -0600 Subject: [PATCH 4/4] feat: put relationship name first in whereBelongsTo --- models/QuickQB.cfc | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/models/QuickQB.cfc b/models/QuickQB.cfc index 6c2ea4d..9f362d1 100644 --- a/models/QuickQB.cfc +++ b/models/QuickQB.cfc @@ -407,17 +407,23 @@ component * Constrains the query to entities belonging to one or more related entities. * The relationship name defaults to the lower-camel-cased related entity name. * - * @related A related Quick entity, an array of entities, or a collection of entities. * @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 related, - string relationshipName, + 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 : ( @@ -433,7 +439,7 @@ component ); } - if ( isNull( arguments.relationshipName ) ) { + if ( !arguments.keyExists( "relationshipName" ) || isNull( arguments.relationshipName ) ) { var relatedEntityName = relatedEntities[ 1 ].entityName(); arguments.relationshipName = lCase( left( relatedEntityName, 1 ) ) & removeChars( relatedEntityName, 1, 1 ); } @@ -485,12 +491,13 @@ component /** * Adds a whereBelongsTo constraint using an OR combinator. * - * @related A related Quick entity, an array of entities, or a collection of entities. * @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 related, string relationshipName ) { + public QuickQB function orWhereBelongsTo( required any relationshipName, any related ) { arguments.combinator = "or"; return whereBelongsTo( argumentCollection = arguments ); }