From be30eabab22749e9ad1b2c955d3ccdbf9132beda Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 12 Mar 2024 10:20:08 -0600 Subject: [PATCH] New `hasManyDeep` and `hasManyDeepBuilder` relationships. `hasManyThrough` needs some work to be more performant and produce expected queries. `hasManyDeep` is a ground up rewrite alongside `hasManyThrough`. Eventually we will add a `belongsToDeep` and hopefully even patch `hasManyThrough` and `belongsToThrough` through the new relationships. --- models/BaseEntity.cfc | 65 ++++++ models/Relationships/BaseRelationship.cfc | 42 +++- models/Relationships/BelongsTo.cfc | 4 +- models/Relationships/BelongsToMany.cfc | 6 +- models/Relationships/BelongsToThrough.cfc | 2 +- .../Builders/HasManyDeepBuilder.cfc | 88 +++++++ models/Relationships/HasManyDeep.cfc | 215 ++++++++++++++++++ models/Relationships/HasOneOrMany.cfc | 6 +- models/Relationships/HasOneOrManyThrough.cfc | 2 +- models/Relationships/IRelationship.cfc | 37 +-- models/Relationships/PivotTable.cfc | 23 ++ .../Relationships/PolymorphicHasOneOrMany.cfc | 2 +- tests/resources/app/models/Country.cfc | 86 +++++++ tests/resources/app/models/Tag.cfc | 17 ++ tests/resources/app/models/User.cfc | 17 ++ ...8_11_102625_create_my_posts_tags_table.cfc | 3 +- .../Relationships/AsQueryEagerLoadingSpec.cfc | 2 +- .../Relationships/EagerLoadingSpec.cfc | 2 +- .../Relationships/HasManyDeepBuilderSpec.cfc | 121 ++++++++++ .../Relationships/HasManyDeepSpec.cfc | 172 ++++++++++++++ .../QueryingRelationshipsSpec.cfc | 4 +- .../RelationshipsAggregatesSpec.cfc | 40 +++- 22 files changed, 914 insertions(+), 42 deletions(-) create mode 100644 models/Relationships/Builders/HasManyDeepBuilder.cfc create mode 100644 models/Relationships/HasManyDeep.cfc create mode 100644 models/Relationships/PivotTable.cfc create mode 100644 tests/specs/integration/BaseEntity/Relationships/HasManyDeepBuilderSpec.cfc create mode 100644 tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index f6e15f4f..1b1b1b0b 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2020,6 +2020,71 @@ component accessors="true" { ); } + public HasManyDeep function hasManyDeep( + required string relationName, + required array through, + required array foreignKeys, + required array localKeys, + string relationMethodName + ) { + param arguments.relationMethodName = lCase( callStackGet()[ 2 ][ "Function" ] ); + + guardAgainstNotLoaded( + "This instance is not loaded so it cannot access the [#arguments.relationMethodName#] relationship. Either load the entity from the database using a query executor (like `first`) or base your query off of the [#arguments.relationName#] entity directly and use the `has` or `whereHas` methods to constrain it based on data in [#entityName()#]." + ); + + var throughParents = arguments.through.map( function( throughEntityName ) { + var parts = throughEntityName.split( "[Aa][Ss]" ); + if ( variables._wirebox.containsInstance( parts[ 1 ] ) ) { + var entity = variables._wirebox.getInstance( parts[ 1 ] ); + if ( arrayLen( parts ) > 1 ) { + entity.withAlias( parts[ 2 ] ); + } + return entity; + } else { + // turn parts into a CFML array + var pivotTable = variables._wirebox.getInstance( "PivotTable@quick" ) + pivotTable.setTable( parts[ 1 ] ); + if ( arrayLen( parts ) > 1 ) { + pivotTable.withAlias( parts[ 2 ] ); + } + return pivotTable; + } + } ); + + + var parts = arguments.relationName.split( "[Aa][Ss]" ); + var related = variables._wirebox.getInstance( trim( parts[ 1 ] ) ); + if ( arrayLen( parts ) > 1 ) { + related.withAlias( trim( parts[ 2 ] ) ); + } + + return variables._wirebox.getInstance( + name = "HasManyDeep@quick", + initArguments = { + "related" : related, + "relationName" : arguments.relationName, + "relationMethodName" : arguments.relationMethodName, + "parent" : this, + "throughParents" : throughParents, + "foreignKeys" : arguments.foreignKeys, + "localKeys" : arguments.localKeys, + "withConstraints" : !variables._withoutRelationshipConstraints + } + ); + } + + private HasManyDeepBuilder function newHasManyDeepBuilder( string relationMethodName ) { + param arguments.relationMethodName = lCase( callStackGet()[ 2 ][ "Function" ] ); + return variables._wirebox.getInstance( + "HasManyDeepBuilder@quick", + { + "parent" : this, + "relationMethodName" : arguments.relationMethodName + } + ); + } + /*======================================= = QB Utilities = =======================================*/ diff --git a/models/Relationships/BaseRelationship.cfc b/models/Relationships/BaseRelationship.cfc index ddebb051..4d5d5516 100644 --- a/models/Relationships/BaseRelationship.cfc +++ b/models/Relationships/BaseRelationship.cfc @@ -4,7 +4,7 @@ * * @doc_abstract true */ -component accessors="true" { +component accessors="true" implements="IRelationship" { /** * The WireBox Injector. @@ -92,6 +92,13 @@ component accessors="true" { return this; } + public void function addConstraints() { + throw( + type = "NotImplemented", + message = "The `addConstraints` method must be implemented in the concrete relationship." + ); + } + /** * Sets the relation method name for this relationship. * @@ -187,6 +194,20 @@ component accessors="true" { return variables.getResults(); } + public any function getResults() { + throw( + type = "NotImplemented", + message = "The `getResults` method must be implemented in the concrete relationship." + ); + } + + public array function getQualifiedForeignKeyNames( any builder = variables.relationshipBuilder ) { + throw( + type = "NotImplemented", + message = "The `getQualifiedForeignKeyNames` method must be implemented in the concrete relationship." + ); + } + /** * Retrieves the values of the key from each entity passed. * @@ -242,14 +263,14 @@ component accessors="true" { * * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ - public any function addCompareConstraints( any base = variables.relationshipBuilder ) { + public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { return arguments.base .select( variables.relationshipBuilder.raw( 1 ) ) .where( function( q ) { arrayZipEach( [ - getExistanceLocalKeys(), - getExistenceCompareKeys() + getExistenceLocalKeys( base ), + getExistenceCompareKeys( base ) ], function( qualifiedLocalKey, existenceCompareKey ) { q.whereColumn( qualifiedLocalKey, existenceCompareKey ); @@ -270,7 +291,7 @@ component accessors="true" { * @doc_generic String * @return [String] */ - public array function getQualifiedLocalKeys() { + public array function getQualifiedLocalKeys( any builder = variables.relationshipBuilder ) { return variables.parent.retrieveQualifiedKeyNames(); } @@ -280,8 +301,8 @@ component accessors="true" { * @doc_generic String * @return [String] */ - public array function getExistanceLocalKeys() { - return getQualifiedLocalKeys(); + public array function getExistenceLocalKeys( any builder = variables.relationshipBuilder ) { + return getQualifiedLocalKeys( arguments.builder ); } /** @@ -290,8 +311,8 @@ component accessors="true" { * @doc_generic String * @return [String] */ - public array function getExistenceCompareKeys() { - return getQualifiedForeignKeyNames(); + public array function getExistenceCompareKeys( any builder = variables.relationshipBuilder ) { + return getQualifiedForeignKeyNames( arguments.builder ); } /** @@ -329,7 +350,7 @@ component accessors="true" { * * @return qb.models.Query.QueryBuilder */ - public any function retrieveQuery() { + public QueryBuilder function retrieveQuery() { return variables.relationshipBuilder.retrieveQuery(); } @@ -435,6 +456,7 @@ component accessors="true" { var lengths = arguments.arrays.map( function( arr ) { return arr.len(); } ); + if ( unique( lengths ).len() > 1 ) { throw( type = "ArrayZipLengthMismatch", diff --git a/models/Relationships/BelongsTo.cfc b/models/Relationships/BelongsTo.cfc index 5a8f6653..610c17ff 100644 --- a/models/Relationships/BelongsTo.cfc +++ b/models/Relationships/BelongsTo.cfc @@ -348,7 +348,7 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * @doc_generic String * @return [String] */ - public array function getQualifiedLocalKeys() { + public array function getQualifiedLocalKeys( any builder = variables.relationshipBuilder ) { return variables.localKeys.map( function( localKey ) { return variables.related.qualifyColumn( localKey ); } ); @@ -360,7 +360,7 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * @doc_generic String * @return [String] */ - public array function getExistenceCompareKeys() { + public array function getExistenceCompareKeys( any builder = variables.relationshipBuilder ) { return variables.foreignKeys.map( function( foreignKey ) { return variables.child.qualifyColumn( foreignKey ); } ); diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index 77e6a799..62d9551f 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -556,7 +556,7 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" } ); } - function nestCompareConstraints( base, nested ) { + function nestCompareConstraints( required any base, required any nested ) { return structKeyExists( arguments.nested, "retrieveQuery" ) ? arguments.nested.retrieveQuery() : arguments.nested; } @@ -566,8 +566,8 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * @doc_generic String * @return [String] */ - public array function getQualifiedForeignKeyNames() { - return getQualifiedForeignPivotKeyNames(); + public array function getQualifiedForeignKeyNames( any builder = variables.relationshipBuilder ) { + return getQualifiedForeignPivotKeyNames( arguments.builder ); } /** diff --git a/models/Relationships/BelongsToThrough.cfc b/models/Relationships/BelongsToThrough.cfc index 75f0fcbd..3f74dd2e 100644 --- a/models/Relationships/BelongsToThrough.cfc +++ b/models/Relationships/BelongsToThrough.cfc @@ -181,7 +181,7 @@ component extends="quick.models.Relationships.BaseRelationship" { * * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ - public any function addCompareConstraints( any base = variables.related ) { + public any function addCompareConstraints( any base = variables.related, any nested ) { return tap( arguments.base.select(), function( q ) { performJoin( q ); q.where( function( q2 ) { diff --git a/models/Relationships/Builders/HasManyDeepBuilder.cfc b/models/Relationships/Builders/HasManyDeepBuilder.cfc new file mode 100644 index 00000000..c3d89a21 --- /dev/null +++ b/models/Relationships/Builders/HasManyDeepBuilder.cfc @@ -0,0 +1,88 @@ +component accessors="true" { + + property name="relationName" type="string"; + property name="through" type="array"; + property name="foreignKeys" type="array"; + property name="localKeys" type="array"; + + public HasManyDeepBuilder function init( required any parent, required string relationMethodName ) { + variables.parent = arguments.parent; + variables.relationMethodName = arguments.relationMethodName; + + variables.through = []; + variables.foreignKeys = []; + variables.localKeys = []; + + return this; + } + + public HasManyDeepBuilder function throughEntity( + required string entityName, + required any foreignKey, + required any localKey + ) { + variables.through.append( arguments.entityName ); + variables.foreignKeys.append( arguments.foreignKey ); + variables.localKeys.append( arguments.localKey ); + return this; + } + + public HasManyDeepBuilder function throughPivotTable( + required string tableName, + required any foreignKey, + required any localKey + ) { + variables.through.append( arguments.tableName ); + variables.foreignKeys.append( arguments.foreignKey ); + variables.localKeys.append( arguments.localKey ); + return this; + } + + public HasManyDeepBuilder function throughPolymorphicEntity( + required string entityName, + required string type, + required any foreignKey, + required any localKey + ) { + variables.through.append( arguments.entityName ); + variables.foreignKeys.append( [ arguments.type, arguments.foreignKey ] ); + variables.localKeys.append( arguments.localKey ); + return this; + } + + public HasManyDeep function toRelated( + required string relationName, + required any foreignKey, + required any localKey + ) { + variables.foreignKeys.append( arguments.foreignKey ); + variables.localKeys.append( arguments.localKey ); + + return variables.parent.hasManyDeep( + relationName = arguments.relationName, + through = variables.through, + foreignKeys = variables.foreignKeys, + localKeys = variables.localKeys, + relationMethodName = variables.relationMethodName + ); + } + + public HasManyDeep function toPolymorphicRelated( + required string relationName, + required string type, + required any foreignKey, + required any localKey + ) { + variables.foreignKeys.append( [ arguments.type, arguments.foreignKey ] ); + variables.localKeys.append( arguments.localKey ); + + return variables.parent.hasManyDeep( + relationName = arguments.relationName, + through = variables.through, + foreignKeys = variables.foreignKeys, + localKeys = variables.localKeys, + relationMethodName = variables.relationMethodName + ); + } + +} diff --git a/models/Relationships/HasManyDeep.cfc b/models/Relationships/HasManyDeep.cfc new file mode 100644 index 00000000..8a2bf9ed --- /dev/null +++ b/models/Relationships/HasManyDeep.cfc @@ -0,0 +1,215 @@ +/** + * Represents a hasManyDeep relationship. + */ +component extends="quick.models.Relationships.BaseRelationship" accessors="true" { + + /** + * The foreign keys traversing the related entities. + */ + property name="foreignKeys" type="array"; + + /** + * The local keys traversing the related entities. + */ + property name="localKeys" type="array"; + + /** + * Used to check for the type of relationship more quickly than using isInstanceOf. + */ + this.relationshipClass = "HasManyDeep"; + + /** + * Creates a HasManyDeep relationship. + * + * @related The related entity instance. + * @relationName The WireBox mapping for the related entity. + * @relationMethodName The method name called to retrieve this relationship. + * @parent The parent entity instance for the relationship. + * @through The entities to traverse from the parent entity to get to the related entity. + * @foreignKeys The foreign keys traversing the related entities. + * @localKeys The local keys traversing the related entities. + * + * @return quick.models.Relationships.HasManyDeep + */ + public HasManyDeep function init( + required any related, + required string relationName, + required string relationMethodName, + required any parent, + required array throughParents, + required array foreignKeys, + required array localKeys, + boolean withConstraints = true + ) { + variables.throughParents = arguments.throughParents; + variables.localKeys = arguments.localKeys; + variables.foreignKeys = arguments.foreignKeys; + + return super.init( + related = arguments.related, + relationName = arguments.relationName, + relationMethodName = arguments.relationMethodName, + parent = arguments.parent, + withConstraints = arguments.withConstraints + ); + } + + public void function addConstraints() { + performJoin(); + + var qualifiedFirstForeignKey = throughParents[ 1 ].qualifyColumn( variables.foreignKeys[ 1 ] ); + var firstLocalValue = variables.parent.retrieveAttribute( variables.localKeys[ 1 ] ); + variables.relationshipBuilder.where( qualifiedFirstForeignKey, firstLocalValue ); + } + + public void function performJoin( any builder = variables.relationshipBuilder ) { + var segments = arguments.builder + .getQB() + .getFrom() + .split( "[Aa][Ss]" ); + var alias = segments[ 2 ] ?: ""; + + var chainLength = variables.throughParents.len(); + for ( var i = chainLength; i > 0; i-- ) { + var throughParent = variables.throughParents[ i ]; + var predecessor = i < chainLength ? variables.throughParents[ i + 1 ] : variables.related; + var prefix = i == chainLength && alias != "" ? alias & "." : ""; + joinThroughParent( + builder, + throughParent, + predecessor, + variables.foreignKeys[ i + 1 ], + variables.localKeys[ i + 1 ], + prefix + ); + } + } + + private void function joinThroughParent( + required any builder, + required any throughParent, + required any predecessor, + required any foreignKey, + required any localKey, + string prefix = "" + ) { + var joins = throughParentJoins( + arguments.builder, + arguments.throughParent, + arguments.predecessor, + arguments.foreignKey, + arguments.localKey + ); + var qualifiedJoins = []; + for ( var i = 1; i <= joins.len(); i++ ) { + var first = joins[ i ][ 1 ]; + var second = joins[ i ][ 2 ]; + qualifiedJoins.append( [ + arguments.throughParent.qualifyColumn( first ), + arguments.predecessor.qualifyColumn( prefix & second ) + ] ); + } + + arguments.builder.join( arguments.throughParent.tableName(), function( j ) { + for ( var join in qualifiedJoins ) { + j.on( join[ 1 ], "=", join[ 2 ] ); + } + } ); + } + + public array function throughParentJoins( + required any builder, + required any throughParent, + required any predecessor, + required any foreignKey, + required any localKey + ) { + var joins = []; + if ( isArray( arguments.localKey ) ) { + arguments.builder.where( + arguments.throughParent.qualifyColumn( arguments.localKey[ 1 ] ), + arguments.predecessor.mappingName() + ); + + arguments.localKey = arguments.localKey[ 2 ]; + } + + if ( isArray( arguments.foreignKey ) ) { + arguments.builder.where( + arguments.predecessor.qualifyColumn( arguments.foreignKey[ 1 ] ), + arguments.throughParent.mappingName() + ); + + arguments.foreignKey = arguments.foreignKey[ 2 ]; + } + + joins.append( [ + arguments.throughParent.qualifyColumn( arguments.localKey ), + arguments.predecessor.qualifyColumn( arguments.foreignKey ) + ] ); + + return joins; + } + + public any function getResults() { + if ( variables.parent.isNullValue( variables.localKeys[ 1 ] ) ) { + return variables.related.newCollection(); + } else { + return variables.relationshipBuilder.get(); + } + } + + public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { + performJoin( base ); + return arguments.base + .select( variables.relationshipBuilder.raw( 1 ) ) + .where( function( q ) { + arrayZipEach( + [ + getExistenceLocalKeys( base ), + getExistenceCompareKeys( base ) + ], + function( qualifiedLocalKey, existenceCompareKey ) { + q.whereColumn( qualifiedLocalKey, existenceCompareKey ); + } + ); + } ); + } + + public array function getQualifiedForeignKeyNames( any builder = variables.relationshipBuilder ) { + var segments = arguments.builder + .getQB() + .getFrom() + .split( "[Aa][Ss]" ); + var alias = segments[ 2 ] ?: ""; + + var foreignKeys = []; + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + if ( i > variables.throughParents.len() ) { + foreignKeys.append( variables.related.qualifyColumn( variables.foreignKeys[ i ] ) ); + } else { + foreignKeys.append( variables.throughParents[ i ].qualifyColumn( variables.foreignKeys[ i ] ) ); + } + } + return foreignKeys; + } + + public array function getQualifiedLocalKeys( any builder = variables.relationshipBuilder ) { + var segments = arguments.builder + .getQB() + .getFrom() + .split( "[Aa][Ss]" ); + var alias = segments[ 2 ] ?: ""; + + var localKeys = []; + for ( var i = 1; i <= variables.localKeys.len(); i++ ) { + if ( i == 1 ) { + localKeys.append( variables.parent.qualifyColumn( variables.localKeys[ i ] ) ); + } else { + localKeys.append( variables.throughParents[ i - 1 ].qualifyColumn( variables.localKeys[ i ] ) ); + } + } + return localKeys; + } + +} diff --git a/models/Relationships/HasOneOrMany.cfc b/models/Relationships/HasOneOrMany.cfc index 4ab70fc2..4a834c66 100644 --- a/models/Relationships/HasOneOrMany.cfc +++ b/models/Relationships/HasOneOrMany.cfc @@ -350,7 +350,7 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * @doc_generic String * @return [String] */ - public array function getQualifiedLocalKeys() { + public array function getQualifiedLocalKeys( any builder = variables.relationshipBuilder ) { return variables.localKeys.map( function( localKey ) { return variables.parent.qualifyColumn( localKey ); } ); @@ -362,9 +362,9 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * @doc_generic String * @return [String] */ - public array function getQualifiedForeignKeyNames() { + public array function getQualifiedForeignKeyNames( any builder = variables.relationshipBuilder ) { return variables.foreignKeys.map( function( foreignKey ) { - return variables.relationshipBuilder.qualifyColumn( foreignKey ); + return builder.qualifyColumn( foreignKey ); } ); } diff --git a/models/Relationships/HasOneOrManyThrough.cfc b/models/Relationships/HasOneOrManyThrough.cfc index b4fd4187..ebc7e2e1 100644 --- a/models/Relationships/HasOneOrManyThrough.cfc +++ b/models/Relationships/HasOneOrManyThrough.cfc @@ -232,7 +232,7 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ - public any function addCompareConstraints( any base = variables.relationshipBuilder ) { + public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { if ( variables.closestToParent.relationshipClass == "HasOneOrManyThrough" || variables.closestToParent.relationshipClass == "BelongsToThrough" diff --git a/models/Relationships/IRelationship.cfc b/models/Relationships/IRelationship.cfc index 6dec03d9..4e435867 100644 --- a/models/Relationships/IRelationship.cfc +++ b/models/Relationships/IRelationship.cfc @@ -79,11 +79,16 @@ interface displayname="IRelationship" { * * @entities An array of entities to retrieve keys. * @key The key to retrieve from each entity. + * @key The entity the keys are associated with. Used to check for null attributes. * * @doc_generic any * @return [any] */ - public array function getKeys( required array entities, required array keys ); + public array function getKeys( + required array entities, + required array keys, + required any baseEntity + ); /** * Checks if all of the keys (usually foreign keys) on the specified entity are null. Used to determine whether we should even run a relationship query or just return null. @@ -101,7 +106,7 @@ interface displayname="IRelationship" { * * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ - public any function addCompareConstraints( any base ); + public any function addCompareConstraints( any base, any nested ); public any function nestCompareConstraints( required any base, required any nested ); @@ -111,15 +116,7 @@ interface displayname="IRelationship" { * @doc_generic String * @return [String] */ - public array function getQualifiedLocalKeys(); - - /** - * Returns the fully-qualified local key. - * - * @doc_generic String - * @return [String] - */ - public array function getExistanceLocalKeys(); + public array function getExistenceLocalKeys( any builder ); /** * Get the key to compare in the existence query. @@ -127,7 +124,7 @@ interface displayname="IRelationship" { * @doc_generic String * @return [String] */ - public array function getExistenceCompareKeys(); + public array function getExistenceCompareKeys( any builder ); /** * Returns the related entity for the relationship. @@ -150,11 +147,23 @@ interface displayname="IRelationship" { */ public any function applyAliasSuffix( required string suffix ); + /* + * TODO: pull this into a separate interface + */ + public array function getQualifiedForeignKeyNames( any builder ); + + /* + * TODO: pull this into a separate interface + */ + public array function getQualifiedLocalKeys( any builder ); + + + /** * Retrieves the current query builder instance. * - * @return quick.models.QuickBuilder + * @return qb.models.QueryBuilder */ - public QuickBuilder function retrieveQuery(); + public QueryBuilder function retrieveQuery(); } diff --git a/models/Relationships/PivotTable.cfc b/models/Relationships/PivotTable.cfc new file mode 100644 index 00000000..9f6548b6 --- /dev/null +++ b/models/Relationships/PivotTable.cfc @@ -0,0 +1,23 @@ +component accessors="true" { + + property name="table" type="string"; + property name="alias" type="string"; + + public string function tableName() { + return variables.table; + } + + public PivotTable function withAlias( required string alias ) { + variables.alias = arguments.alias; + return this; + } + + public string function qualifyColumn( required string columnName ) { + if ( findNoCase( ".", columnName ) > 0 ) { + return columnName; + } + + return isNull( variables.alias ) ? "#variables.table#.#arguments.columnName#" : "#variables.alias#.#arguments.columnName#"; + } + +} diff --git a/models/Relationships/PolymorphicHasOneOrMany.cfc b/models/Relationships/PolymorphicHasOneOrMany.cfc index 9dc40079..305dda03 100644 --- a/models/Relationships/PolymorphicHasOneOrMany.cfc +++ b/models/Relationships/PolymorphicHasOneOrMany.cfc @@ -97,7 +97,7 @@ component extends="quick.models.Relationships.HasOneOrMany" accessors="true" { * * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ - public any function addCompareConstraints( any base = variables.relationshipBuilder ) { + public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { return tap( super.addCompareConstraints( arguments.base ), function( q ) { q.where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); } ); diff --git a/tests/resources/app/models/Country.cfc b/tests/resources/app/models/Country.cfc index 67117540..c692a157 100644 --- a/tests/resources/app/models/Country.cfc +++ b/tests/resources/app/models/Country.cfc @@ -13,6 +13,92 @@ component extends="quick.models.BaseEntity" accessors="true" { return hasManyThrough( [ "users", "posts" ] ); } + function postsDeep() { + return hasManyDeep( + relationName = "Post", + through = [ "User" ], + foreignKeys = [ "country_id", "user_id" ], + localKeys = [ "id", "id" ] + ); + } + + function postsDeepBuilder() { + return newHasManyDeepBuilder() + .throughEntity( + entityName = "User", + foreignKey = "country_id", + localKey = "id" + ) + .toRelated( + relationName = "Post", + foreignKey = "user_id", + localKey = "id" + ); + } + + function postCommentsDeep() { + return hasManyDeep( + relationName = "Comment", + through = [ "User", "Post" ], + foreignKeys = [ "country_id", "user_id", [ "commentable_type", "commentable_id" ] ], + localKeys = [ "id", "id", "post_pk" ] + ); + } + + function postCommentsDeepBuilder() { + return newHasManyDeepBuilder() + .throughEntity( "User", "country_id", "id" ) + .throughEntity( "Post", "user_id", "id" ) + .toPolymorphicRelated( + relationName = "Comment", + type = "commentable_type", + foreignKey = "commentable_id", + localKey = "post_pk" + ); + } + + function postPublicCommentsDeep() { + return hasManyDeep( + relationName = "Comment", + through = [ "User", "Post" ], + foreignKeys = [ "country_id", "user_id", [ "commentable_type", "commentable_id" ] ], + localKeys = [ "id", "id", "post_pk" ] + ).where( "comments.designation", "public" ); + } + + function postPublicCommentsDeepBuilder() { + return newHasManyDeepBuilder() + .throughEntity( "User", "country_id", "id" ) + .throughEntity( "Post", "user_id", "id" ) + .toPolymorphicRelated( + relationName = "Comment", + type = "commentable_type", + foreignKey = "commentable_id", + localKey = "post_pk" + ).where( "comments.designation", "public" ); + } + + function postPublicCommentsDeepAliased() { + return hasManyDeep( + relationName = "Comment AS c", + through = [ "User", "Post" ], + foreignKeys = [ "country_id", "user_id", [ "commentable_type", "commentable_id" ] ], + localKeys = [ "id", "id", "post_pk" ] + ).where( "c.designation", "public" ); + } + + function postPublicCommentsDeepAliasedBuilder() { + return newHasManyDeepBuilder() + .throughEntity( "User", "country_id", "id" ) + .throughEntity( "Post", "user_id", "id" ) + .toPolymorphicRelated( + relationName = "Comment AS c", + type = "commentable_type", + foreignKey = "commentable_id", + localKey = "post_pk" + ).where( "c.designation", "public" ); + } + function latestPost() { return hasOneThrough( [ "users", "posts" ] ).latest(); } diff --git a/tests/resources/app/models/Tag.cfc b/tests/resources/app/models/Tag.cfc index 72b2f89a..0508895c 100644 --- a/tests/resources/app/models/Tag.cfc +++ b/tests/resources/app/models/Tag.cfc @@ -7,4 +7,21 @@ component extends="quick.models.BaseEntity" accessors="true" { return belongsToMany( "Post", "my_posts_tags", "tag_id", "custom_post_pk" ); } + function users() { + return hasManyDeep( + relationName = "User AS u", + through = [ "my_posts_tags", "Post" ], + foreignKeys = [ "tag_id", "post_pk", "id" ], + localKeys = [ "id", "custom_post_pk", "user_id" ] + ).distinct().orderBy( "u.id" ); + } + + function usersBuilder() { + return newHasManyDeepBuilder() + .throughPivotTable( "my_posts_tags", "tag_id", "id" ) + .throughEntity( "Post", "post_pk", "custom_post_pk" ) + .toRelated( "User as u", "id", "user_id" ) + .distinct().orderBy( "u.id" ); + } + } diff --git a/tests/resources/app/models/User.cfc b/tests/resources/app/models/User.cfc index 636c3028..3a6cca2a 100644 --- a/tests/resources/app/models/User.cfc +++ b/tests/resources/app/models/User.cfc @@ -148,6 +148,23 @@ component extends="quick.models.BaseEntity" accessors="true" { return hasManyThrough( [ "roles", "permissions" ] ); } + function permissionsDeep() { + return hasManyDeep( + relationName = "Permission", + through = [ "roles_users", "Role", "permissions_roles" ], + foreignKeys = [ "userId", "id", "roleId", "id" ], + localKeys = [ "id", "roleId", "id", "permissionId" ] + ); + } + + function permissionsDeepBuilder() { + return newHasManyDeepBuilder() + .throughPivotTable( "roles_users", "userId", "id" ) + .throughEntity( "Role", "id", "roleId" ) + .throughPivotTable( "permissions_roles", "roleId", "id" ) + .toRelated( "Permission", "id", "permissionId" ); + } + function posts() { return hasMany( "Post", "user_id" ).latest(); } diff --git a/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc b/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc index f074e118..041c8246 100755 --- a/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc @@ -11,7 +11,8 @@ component { { "custom_post_pk": 1245, "tag_id": 1 }, { "custom_post_pk": 1245, "tag_id": 2 }, { "custom_post_pk": 523526, "tag_id": 1 }, - { "custom_post_pk": 523526, "tag_id": 2 } + { "custom_post_pk": 523526, "tag_id": 2 }, + { "custom_post_pk": 321, "tag_id": 2 } ] ); } diff --git a/tests/specs/integration/BaseEntity/Relationships/AsQueryEagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/AsQueryEagerLoadingSpec.cfc index b136544f..37658e4b 100644 --- a/tests/specs/integration/BaseEntity/Relationships/AsQueryEagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/AsQueryEagerLoadingSpec.cfc @@ -241,7 +241,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts ).toHaveLength( 4 ); expect( posts[ 1 ][ "tags" ] ).toBeArray(); - expect( posts[ 1 ][ "tags" ] ).toHaveLength( 0 ); + expect( posts[ 1 ][ "tags" ] ).toHaveLength( 1 ); expect( posts[ 2 ][ "tags" ] ).toBeArray(); expect( posts[ 2 ][ "tags" ] ).toHaveLength( 2 ); diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index a25d7f15..994801a2 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -224,7 +224,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts ).toHaveLength( 4 ); expect( posts[ 1 ].getTags() ).toBeArray(); - expect( posts[ 1 ].getTags() ).toHaveLength( 0 ); + expect( posts[ 1 ].getTags() ).toHaveLength( 1 ); expect( posts[ 2 ].getTags() ).toBeArray(); expect( posts[ 2 ].getTags() ).toHaveLength( 2 ); diff --git a/tests/specs/integration/BaseEntity/Relationships/HasManyDeepBuilderSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/HasManyDeepBuilderSpec.cfc new file mode 100644 index 00000000..b31595f5 --- /dev/null +++ b/tests/specs/integration/BaseEntity/Relationships/HasManyDeepBuilderSpec.cfc @@ -0,0 +1,121 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "HasManyDeepBuilderSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "HasManyDeepBuilderSpec" ); + super.afterAll(); + } + + function run() { + describe( "Has Many Deep Spec", function() { + beforeEach( function() { + variables.queries = []; + } ); + + it( "can get the related entities through another entity", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var countryA = getInstance( "Country@something" ).find( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + expect( arrayLen( countryA.getPostsDeepBuilder() ) ).toBe( 2 ); + expect( countryA.getPostsDeepBuilder()[ 1 ].getPost_Pk() ).toBe( 1245 ); + expect( countryA.getPostsDeepBuilder()[ 1 ].getBody() ).toBe( "My awesome post body" ); + expect( countryA.getPostsDeepBuilder()[ 2 ].getPost_Pk() ).toBe( 523526 ); + expect( countryA.getPostsDeepBuilder()[ 2 ].getBody() ).toBe( "My second awesome post body" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + variables.queries = []; + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var countryB = getInstance( "Country" ).where( "name", "Argentina" ).firstOrFail(); + expect( countryB.getPostsDeepBuilder() ).toHaveLength( 1 ); + expect( countryB.getPostsDeepBuilder()[ 1 ].getPost_Pk() ).toBe( 321 ); + expect( countryB.getPostsDeepBuilder()[ 1 ].getBody() ).toBe( "My post with a different author" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can get the related entities through any number of intermediate entities including a belongsToMany relationship", function() { + var user = getInstance( "User" ).where( "username", "elpete" ).firstOrFail(); + var permissions = user.getPermissionsDeepBuilder(); + expect( permissions ).toBeArray(); + expect( permissions ).toHaveLength( 2 ); + expect( permissions[ 1 ].getId() ).toBe( 1 ); + expect( permissions[ 1 ].getName() ).toBe( "MANAGE_USERS" ); + expect( permissions[ 2 ].getId() ).toBe( 2 ); + expect( permissions[ 2 ].getName() ).toBe( "APPROVE_POSTS" ); + } ); + + it( "can get the related entities through any number of intermediate entities including a polymorphicHasMany relationship", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var country = getInstance( "Country" ).where( "name", "United States" ).firstOrFail(); + var comments = country.getPostCommentsDeepBuilder(); + expect( comments ).toBeArray(); + expect( comments ).toHaveLength( 2 ); + expect( comments[ 1 ].getId() ).toBe( 1 ); + expect( comments[ 1 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 1 ].getBody() ).toBe( "I thought this post was great" ); + + expect( comments[ 2 ].getId() ).toBe( 4 ); + expect( comments[ 2 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 2 ].getBody() ).toBe( "This is an internal comment. It is very, very private." ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can restrict intermediate relationships", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var country = getInstance( "Country" ).where( "name", "United States" ).firstOrFail(); + var comments = country.getPostPublicCommentsDeepBuilder(); + expect( comments ).toBeArray(); + expect( comments ).toHaveLength( 1 ); + expect( comments[ 1 ].getId() ).toBe( 1 ); + expect( comments[ 1 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 1 ].getBody() ).toBe( "I thought this post was great" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can apply and target table aliases", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var country = getInstance( "Country" ).where( "name", "United States" ).firstOrFail(); + var comments = country.getPostPublicCommentsDeepAliasedBuilder(); + expect( comments ).toBeArray(); + expect( comments ).toHaveLength( 1 ); + expect( comments[ 1 ].getId() ).toBe( 1 ); + expect( comments[ 1 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 1 ].getBody() ).toBe( "I thought this post was great" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can go up and down belongsTo and hasMany relationships", function() { + var tag = getInstance( "Tag" ).where( "name", "music" ).firstOrFail(); + var users = tag.getUsersBuilder(); + expect( users ).toBeArray(); + expect( users ).toHaveLength( 2 ); + expect( users[ 1 ].getId() ).toBe( 1 ); + expect( users[ 2 ].getId() ).toBe( 4 ); + } ); + } ); + } + + function preQBExecute( + event, + interceptData, + buffer, + rc, + prc + ) { + arrayAppend( variables.queries, interceptData ); + } + +} diff --git a/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc new file mode 100644 index 00000000..c261d419 --- /dev/null +++ b/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc @@ -0,0 +1,172 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "HasManyDeepSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "HasManyDeepSpec" ); + super.afterAll(); + } + + function run() { + describe( "Has Many Deep Spec", function() { + beforeEach( function() { + variables.queries = []; + } ); + + it( "can get the related entities through another entity", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var countryA = getInstance( "Country@something" ).find( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + expect( arrayLen( countryA.getPostsDeep() ) ).toBe( 2 ); + expect( countryA.getPostsDeep()[ 1 ].getPost_Pk() ).toBe( 1245 ); + expect( countryA.getPostsDeep()[ 1 ].getBody() ).toBe( "My awesome post body" ); + expect( countryA.getPostsDeep()[ 2 ].getPost_Pk() ).toBe( 523526 ); + expect( countryA.getPostsDeep()[ 2 ].getBody() ).toBe( "My second awesome post body" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + variables.queries = []; + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var countryB = getInstance( "Country" ).where( "name", "Argentina" ).firstOrFail(); + expect( countryB.getPostsDeep() ).toHaveLength( 1 ); + expect( countryB.getPostsDeep()[ 1 ].getPost_Pk() ).toBe( 321 ); + expect( countryB.getPostsDeep()[ 1 ].getBody() ).toBe( "My post with a different author" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can get the related entities through any number of intermediate entities including a belongsToMany relationship", function() { + var user = getInstance( "User" ).where( "username", "elpete" ).firstOrFail(); + var permissions = user.getPermissionsDeep(); + expect( permissions ).toBeArray(); + expect( permissions ).toHaveLength( 2 ); + expect( permissions[ 1 ].getId() ).toBe( 1 ); + expect( permissions[ 1 ].getName() ).toBe( "MANAGE_USERS" ); + expect( permissions[ 2 ].getId() ).toBe( 2 ); + expect( permissions[ 2 ].getName() ).toBe( "APPROVE_POSTS" ); + } ); + + xit( "can get the related entities starting with a belongsToMany relationship", function() { + var user = getInstance( "User" ).where( "username", "elpete" ).firstOrFail(); + var permissions = user.getPermissions(); + expect( permissions ).toBeArray(); + expect( permissions ).toHaveLength( 2 ); + } ); + + xit( "can get the related entities starting with a hasManyThrough relationship", function() { + var country = getInstance( "Country" ).where( "name", "Argentina" ).firstOrFail(); + var permissions = country.getPermissions(); + expect( permissions ).toBeArray(); + expect( permissions ).toHaveLength( 1 ); + expect( permissions[ 1 ].getId() ).toBe( 2 ); + } ); + + xit( "can get the related entities starting with a polymorphicHasMany relationship", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + var commentingUsers = post.getCommentingUsers(); + expect( commentingUsers ).toBeArray(); + expect( commentingUsers ).toHaveLength( 1 ); + expect( commentingUsers[ 1 ].getId() ).toBe( 1 ); + expect( commentingUsers[ 1 ].getUsername() ).toBe( "elpete" ); + } ); + + xit( "can get the related entities through a polymorphicBelongsTo relationship", function() { + var comment = getInstance( "Comment" ).findOrFail( 1 ); + var tags = comment.getTags(); + expect( tags ).toBeArray(); + expect( tags ).toHaveLength( 2 ); + } ); + + it( "can get the related entities through any number of intermediate entities including a polymorphicHasMany relationship", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var country = getInstance( "Country" ).where( "name", "United States" ).firstOrFail(); + var comments = country.getPostCommentsDeep(); + expect( comments ).toBeArray(); + expect( comments ).toHaveLength( 2 ); + expect( comments[ 1 ].getId() ).toBe( 1 ); + expect( comments[ 1 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 1 ].getBody() ).toBe( "I thought this post was great" ); + + expect( comments[ 2 ].getId() ).toBe( 4 ); + expect( comments[ 2 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 2 ].getBody() ).toBe( "This is an internal comment. It is very, very private." ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can restrict intermediate relationships", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var country = getInstance( "Country" ).where( "name", "United States" ).firstOrFail(); + var comments = country.getPostPublicCommentsDeep(); + expect( comments ).toBeArray(); + expect( comments ).toHaveLength( 1 ); + expect( comments[ 1 ].getId() ).toBe( 1 ); + expect( comments[ 1 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 1 ].getBody() ).toBe( "I thought this post was great" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can apply and target table aliases", function() { + expect( variables.queries ).toHaveLength( 0, "No queries should have been executed yet." ); + + var country = getInstance( "Country" ).where( "name", "United States" ).firstOrFail(); + var comments = country.getPostPublicCommentsDeepAliased(); + expect( comments ).toBeArray(); + expect( comments ).toHaveLength( 1 ); + expect( comments[ 1 ].getId() ).toBe( 1 ); + expect( comments[ 1 ].getCommentableType() ).toBe( "Post" ); + expect( comments[ 1 ].getBody() ).toBe( "I thought this post was great" ); + + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + + it( "can go up and down belongsTo and hasMany relationships", function() { + var tag = getInstance( "Tag" ).where( "name", "music" ).firstOrFail(); + var users = tag.getUsers(); + expect( users ).toBeArray(); + expect( users ).toHaveLength( 2 ); + expect( users[ 1 ].getId() ).toBe( 1 ); + expect( users[ 2 ].getId() ).toBe( 4 ); + } ); + + xit( "can go up and down many belongsTo and hasMany relationships", function() { + var user = getInstance( "User" ).where( "username", "johndoe" ).firstOrFail(); + var officemates = user.getOfficemates(); + expect( officemates ).toBeArray(); + expect( officemates ).toHaveLength( 3 ); + expect( officemates[ 1 ].getId() ).toBe( 1 ); + expect( officemates[ 2 ].getId() ).toBe( 3 ); + expect( officemates[ 3 ].getId() ).toBe( 4 ); + } ); + + xit( "can go up and down many belongsTo and hasMany even hasManyThrough relationships", function() { + var user = getInstance( "User" ).where( "username", "johndoe" ).firstOrFail(); + var officemates = user.getOfficematesAlternate(); + expect( officemates ).toBeArray(); + expect( officemates ).toHaveLength( 3 ); + expect( officemates[ 1 ].getId() ).toBe( 1 ); + expect( officemates[ 2 ].getId() ).toBe( 3 ); + expect( officemates[ 3 ].getId() ).toBe( 4 ); + } ); + } ); + } + + function preQBExecute( + event, + interceptData, + buffer, + rc, + prc + ) { + arrayAppend( variables.queries, interceptData ); + } + +} diff --git a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc index 49915e34..e182e405 100644 --- a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc @@ -166,7 +166,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "can find only entities that have a related belongsToMany entity", function() { var posts = getInstance( "Post" ).has( "tags" ).get(); expect( posts ).toBeArray(); - expect( posts ).toHaveLength( 2 ); + expect( posts ).toHaveLength( 3 ); } ); it( "can use whereHas on a belongsToMany relationship", function() { @@ -341,7 +341,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "can find only entities that do not have a related belongsToMany entity", function() { var posts = getInstance( "Post" ).doesntHave( "tags" ).get(); expect( posts ).toBeArray(); - expect( posts ).toHaveLength( 2 ); + expect( posts ).toHaveLength( 1 ); } ); } ); diff --git a/tests/specs/integration/BaseEntity/Relationships/RelationshipsAggregatesSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/RelationshipsAggregatesSpec.cfc index 69163404..b0c86afa 100644 --- a/tests/specs/integration/BaseEntity/Relationships/RelationshipsAggregatesSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/RelationshipsAggregatesSpec.cfc @@ -112,7 +112,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts[ 4 ].hasAttribute( "tagsCount" ) ).toBeTrue( "Post #posts[ 4 ].getPost_Pk()# should have an attribute named `tagsCount`." ); - expect( posts[ 4 ].getTagsCount() ).toBe( 0 ); + expect( posts[ 4 ].getTagsCount() ).toBe( 1 ); } ); it( "can constrain counts at runtime", function() { @@ -233,7 +233,25 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts[ 4 ].hasAttribute( "tagsCount" ) ).toBeTrue( "Post #posts[ 4 ].getPost_Pk()# should have an attribute named `tagsCount`." ); - expect( posts[ 4 ].getTagsCount() ).toBe( 0 ); + expect( posts[ 4 ].getTagsCount() ).toBe( 1 ); + } ); + + it( "can add a count for a hasManyDeep relationship", function() { + var countries = getInstance( "Country" ) + .withCount( "postsDeep" ) + .orderBy( "createdDate" ) + .get(); + + expect( countries ).toBeArray(); + expect( countries ).toHaveLength( 2 ); + + expect( countries[ 1 ].getId() ).toBe( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + expect( countries[ 1 ].getName() ).toBe( "United States" ); + expect( countries[ 1 ].getPostsDeepCount() ).toBe( 2 ); + + expect( countries[ 2 ].getId() ).toBe( "02BA2DB0-EB1E-3F85-5F283AB5E45608C6" ); + expect( countries[ 2 ].getName() ).toBe( "Argentina" ); + expect( countries[ 2 ].getPostsDeepCount() ).toBe( 1 ); } ); it( "can return the QuickBuilder instance generated for the count", function() { @@ -382,6 +400,24 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users[ 5 ].getTotalPrice() ).toBe( 50 ); } ); + it( "can add a sum for a hasManyDeep relationship", function() { + var countries = getInstance( "Country" ) + .withSum( "postsDeep.post_pk AS postIdTotal" ) + .orderBy( "createdDate" ) + .get(); + + expect( countries ).toBeArray(); + expect( countries ).toHaveLength( 2 ); + + expect( countries[ 1 ].getId() ).toBe( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + expect( countries[ 1 ].getName() ).toBe( "United States" ); + expect( countries[ 1 ].getPostIdTotal() ).toBe( 524771 ); + + expect( countries[ 2 ].getId() ).toBe( "02BA2DB0-EB1E-3F85-5F283AB5E45608C6" ); + expect( countries[ 2 ].getName() ).toBe( "Argentina" ); + expect( countries[ 2 ].getPostIdTotal() ).toBe( 321 ); + } ); + it( "can return the QuickBuilder instance generated for the sum", function() { var totalPriceBuilder = getInstance( "User" ).withSum( relationMapping = "purchases.price",