Skip to content

Commit

Permalink
feat(Relationships): New hasManyDeep and hasManyDeepBuilder relat…
Browse files Browse the repository at this point in the history
…ionships.

`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.
  • Loading branch information
elpete committed Mar 12, 2024
1 parent 026d7e0 commit 5331b36
Show file tree
Hide file tree
Showing 24 changed files with 1,140 additions and 48 deletions.
91 changes: 91 additions & 0 deletions models/BaseEntity.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ component accessors="true" {
return variables._table;
}

/**
* Returns the table name for this entity.
*
* @return String
*/
public string function tableAlias() {
return listLen( variables._table, " " ) > 1 ? listLast( variables._table, " " ) : variables._table;
}

public any function withAlias( required string alias ) {
variables._table = "#variables._table# #arguments.alias#";
return this;
Expand Down Expand Up @@ -2020,6 +2029,88 @@ component accessors="true" {
);
}

public HasManyDeep function hasManyDeep(
required any relationName,
required array through,
required array foreignKeys,
required array localKeys,
string relationMethodName
) {
param arguments.relationMethodName = lCase( callStackGet()[ 2 ][ "Function" ] );

var related = "";
if ( isClosure( arguments.relationName ) || isCustomFunction( arguments.relationName ) ) {
related = arguments.relationName();
} else {
var parts = arguments.relationName.split( "[Aa][Ss]" );
related = variables._wirebox.getInstance( trim( parts[ 1 ] ) );
if ( arrayLen( parts ) > 1 ) {
related.withAlias( trim( parts[ 2 ] ) );
}
}

if ( !structKeyExists( related, "isBuilder" ) ) {
related = related.newQuery();
}

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 [#related.getEntity().entityName()#] 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 throughEntity = "";
if ( isClosure( throughEntityName ) || isCustomFunction( throughEntityName ) ) {
throughEntity = throughEntityName();
} else {
var parts = throughEntityName.split( "[Aa][Ss]" );
if ( variables._wirebox.containsInstance( trim( parts[ 1 ] ) ) ) {
throughEntity = variables._wirebox.getInstance( trim( parts[ 1 ] ) );
if ( arrayLen( parts ) > 1 ) {
throughEntity.withAlias( trim( parts[ 2 ] ) );
}
} else {
// turn parts into a CFML array
throughEntity = variables._wirebox.getInstance( "PivotTable@quick" )
throughEntity.setTable( trim( parts[ 1 ] ) );
if ( arrayLen( parts ) > 1 ) {
throughEntity.withAlias( trim( parts[ 2 ] ) );
}
}
}

if ( !structKeyExists( throughEntity, "isBuilder" ) ) {
throughEntity = throughEntity.newQuery();
}

return throughEntity;
} );

return variables._wirebox.getInstance(
name = "HasManyDeep@quick",
initArguments = {
"related" : related,
"relationName" : related.getEntity().entityName(),
"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 =
=======================================*/
Expand Down
70 changes: 70 additions & 0 deletions models/QuickBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ component accessors="true" transientCache="false" {
*/
property name="_withAliases";

/**
* A map of aliases to entities to use when qualifying aliased columns.
*/
property name="aliasMap";

/**
* The WireBox injector. Used to inject other entities.
*/
Expand Down Expand Up @@ -77,6 +82,7 @@ component accessors="true" transientCache="false" {
variables._withAliases = false;
variables._asMementoSettings = {};
variables._globalScopeExclusions = [];
variables.aliasMap = {};
return this;
}

Expand All @@ -90,6 +96,7 @@ component accessors="true" transientCache="false" {
public QuickBuilder function setEntity( required any newEntity ) {
variables.entity = arguments.newEntity;
variables.qb.setEntity( arguments.newEntity );
variables.aliasMap[ arguments.newEntity.tableAlias() ] = arguments.newEntity;
return this;
}

Expand All @@ -101,6 +108,7 @@ component accessors="true" transientCache="false" {
* @return quick.models.BaseEntity
*/
public QuickBuilder function withAlias( required string alias ) {
variables.aliasMap.delete( getEntity().tableAlias() );
getEntity().withAlias( arguments.alias );
variables.qb.from( getEntity().tableName() );
return this;
Expand Down Expand Up @@ -187,6 +195,39 @@ component accessors="true" transientCache="false" {
return this;
}

/**
* Adds a JOIN to another table.
*
* For simple joins, this specifies a column on which to join the two tables.
* For complex joins, a closure can be passed to `first`.
* This allows multiple `on` and `where` conditions to be applied to the join.
*
* @table The table/expression to join to the query.
* @first The first column in the join's `on` statement. This alternatively can be a closure that will be passed a JoinClause for complex joins. Passing a closure ignores all subsequent parameters.
* @operator The boolean operator for the join clause. Default: "=".
* @second The second column in the join's `on` statement.
* @type The type of the join. Default: "inner". Passing this as an argument is discouraged for readability. Use the dedicated methods like `leftJoin` and `rightJoin` where possible.
* @where Sets if the value of `second` should be interpreted as a column or a value. Passing this as an argument is discouraged. Use the dedicated `joinWhere` or a join closure where possible.
* @preventDuplicateJoins Introspects the builder for a join matching the join we're trying to add. If a match is found, disregards this request. Defaults to moduleSetting or qb setting
*
* @return qb.models.Query.QueryBuilder
*/
public QuickBuilder function join(
required any table,
any first,
string operator = "=",
string second,
string type = "inner",
boolean where = false,
boolean preventDuplicateJoins = this.getPreventDuplicateJoins()
) {
variables.qb = variables.qb.join( argumentCollection = arguments );
var latestJoin = variables.qb.getJoins().last();
var latestJoinBuilder = latestJoin.getParentQuery().getQuickBuilder();
variables.aliasMap[ latestJoinBuilder.tableAlias() ] = latestJoinBuilder.getEntity();
return this;
}

/**
* Adds a count of related entities as a subselect property.
* Relationships can be constrained at runtime by passing a
Expand Down Expand Up @@ -610,9 +651,38 @@ component accessors="true" transientCache="false" {
* @return string
*/
public string function qualifyColumn( required string column, string tableName ) {
if ( findNoCase( ".", column ) != 0 ) {
var columnAlias = listFirst( column, "." );
for ( var tableAlias in variables.aliasMap ) {
if ( compareNoCase( columnAlias, tableAlias ) == 0 ) {
return variables.aliasMap[ tableAlias ].qualifyColumn( listLast( column, "." ), columnAlias );
}
}
}
return getEntity().qualifyColumn( argumentCollection = arguments );
}

/**
* Returns the table name of the underlying entity
*/
public string function tableName() {
return getEntity().tableName();
}

/**
* Returns the table alias of the underlying entity
*/
public string function tableAlias() {
return getEntity().tableAlias();
}

/**
* Returns the mapping name of the underlying entity
*/
public string function mappingName() {
return getEntity().mappingName();
}

/**
* Creates a new query using the same Grammar and QueryUtils.
*
Expand Down
59 changes: 43 additions & 16 deletions models/Relationships/BaseRelationship.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* @doc_abstract true
*/
component accessors="true" {
component accessors="true" implements="IRelationship" {

/**
* The WireBox Injector.
Expand Down Expand Up @@ -74,16 +74,21 @@ component accessors="true" {
required string relationName,
required string relationMethodName,
required any parent,
boolean withConstraints = true
boolean withConstraints = true,
QuickBuilder relationshipBuilder
) {
variables.returnDefaultEntity = false;
variables.defaultAttributes = {};

variables.related = arguments.related;
variables.relationshipBuilder = arguments.related.newQuery();
variables.relationName = arguments.relationName;
variables.relationMethodName = arguments.relationMethodName;
variables.parent = arguments.parent;
variables.related = arguments.related;
if ( !isNull( arguments.relationshipBuilder ) ) {
variables.relationshipBuilder = arguments.relationshipBuilder;
} else {
variables.relationshipBuilder = arguments.related.newQuery();
}
variables.relationName = arguments.relationName;
variables.relationMethodName = arguments.relationMethodName;
variables.parent = arguments.parent;

if ( arguments.withConstraints ) {
variables.addConstraints();
Expand All @@ -92,6 +97,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.
*
Expand Down Expand Up @@ -187,6 +199,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.
*
Expand Down Expand Up @@ -242,14 +268,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 );
Expand All @@ -270,7 +296,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();
}

Expand All @@ -280,8 +306,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 );
}

/**
Expand All @@ -290,8 +316,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 );
}

/**
Expand Down Expand Up @@ -329,7 +355,7 @@ component accessors="true" {
*
* @return qb.models.Query.QueryBuilder
*/
public any function retrieveQuery() {
public QueryBuilder function retrieveQuery() {
return variables.relationshipBuilder.retrieveQuery();
}

Expand Down Expand Up @@ -435,6 +461,7 @@ component accessors="true" {
var lengths = arguments.arrays.map( function( arr ) {
return arr.len();
} );

if ( unique( lengths ).len() > 1 ) {
throw(
type = "ArrayZipLengthMismatch",
Expand Down
4 changes: 2 additions & 2 deletions models/Relationships/BelongsTo.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
} );
Expand All @@ -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 );
} );
Expand Down
6 changes: 3 additions & 3 deletions models/Relationships/BelongsToMany.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 );
}

/**
Expand Down
Loading

0 comments on commit 5331b36

Please sign in to comment.