Skip to content

Commit

Permalink
feat(QueryBuilder): Implement crossApply and outerApply for supported…
Browse files Browse the repository at this point in the history
… Grammars

Supported Grammars include `SqlServerGrammar`, `MySQLGrammar`, `PostgresGrammar`, and `OracleGrammar`.
  • Loading branch information
softwareCobbler authored Mar 4, 2024
1 parent 72a8839 commit 6a818ee
Show file tree
Hide file tree
Showing 11 changed files with 327 additions and 16 deletions.
58 changes: 51 additions & 7 deletions models/Grammars/BaseGrammar.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,57 @@ component displayname="Grammar" accessors="true" singleton {
*/
private string function compileJoins( required QueryBuilder query, required array joins ) {
var joinsArray = [];

if ( arguments.joins.isEmpty() ) {
return "";
}

for ( var join in arguments.joins ) {
var conditions = compileWheres( join, join.getWheres() );
var table = wrapTable( join.getTable() );
joinsArray.append( "#uCase( join.getType() )# JOIN #table# #conditions#" );
var joinFunc = variables[ "compile#replace( join.getType(), " ", "", "all" )#join" ];
joinsArray.append( joinFunc( arguments.query, join ) );
}

return arrayToList( joinsArray, " " );
if ( joinsArray.isEmpty() ) {
return "";
}

return joinsArray.toList( " " );
}

private string function compileInnerJoin( required QueryBuilder query, required JoinClause join ) {
var conditions = compileWheres( arguments.join, arguments.join.getWheres() );
var table = wrapTable( arguments.join.getTable() );
return "INNER JOIN #table# #conditions#";
}

private string function compileLeftJoin( required QueryBuilder query, required JoinClause join ) {
var conditions = compileWheres( arguments.join, arguments.join.getWheres() );
var table = wrapTable( arguments.join.getTable() );
return "LEFT JOIN #table# #conditions#";
}

private string function compileRightJoin( required QueryBuilder query, required JoinClause join ) {
var conditions = compileWheres( arguments.join, arguments.join.getWheres() );
var table = wrapTable( arguments.join.getTable() );
return "RIGHT JOIN #table# #conditions#";
}

private string function compileCrossJoin( required QueryBuilder query, required JoinClause join ) {
var conditions = compileWheres( arguments.join, arguments.join.getWheres() );
var table = wrapTable( arguments.join.getTable() );
return "CROSS JOIN #table# #conditions#";
}

private string function compileOuterApplyJoin( required QueryBuilder query, required JoinClause join ) {
throw( type = "UnsupportedOperation", message = "This grammar does not support OUTER APPLY joins" );
}

private string function compileCrossApplyJoin( required QueryBuilder query, required JoinClause join ) {
throw( type = "UnsupportedOperation", message = "This grammar does not support CROSS APPLY joins" );
}

private string function compileLateralJoin( required QueryBuilder query, required JoinClause join ) {
throw( type = "UnsupportedOperation", message = "This grammar does not support LATERAL joins" );
}

/**
Expand Down Expand Up @@ -1215,23 +1259,23 @@ component displayname="Grammar" accessors="true" singleton {

function compileDropAllObjects() {
throw(
type = "OperationNotSupported",
type = "UnsupportedOperation",
message = "This database grammar does not support this operation",
detail = "compileDropAllObjects"
);
}

function compileEnableForeignKeyConstraints() {
throw(
type = "OperationNotSupported",
type = "UnsupportedOperation",
message = "This database grammar does not support this operation",
detail = "compileEnableForeignKeyConstraints"
);
}

function compileDisableForeignKeyConstraints() {
throw(
type = "OperationNotSupported",
type = "UnsupportedOperation",
message = "This database grammar does not support this operation",
detail = "compileDisableForeignKeyConstraints"
);
Expand Down
33 changes: 33 additions & 0 deletions models/Grammars/SqlServerGrammar.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" {
);
}

private string function compileOuterApplyJoin( required QueryBuilder query, required JoinClause join ) {
// OUTER APPLY ( <some-table-def> ) (AS)? <table-alias>
var tableName = wrapTable( join.getTable() )
if ( !reFindNoCase( "^\s*#trim( getTableAliasOperator() )#", tableName ) ) {
// table alias operator is optional in MSSqlServer, but we'll provide it if it wasn't expanded via wrapTable.
// Will `wrapTable` ever have emitted a table alias operator here?
// n.b. `getTableAliasOperator()` is expected to have a leading and trailing space.
tableName = "#getTableAliasOperator()##tableName#";
}
// `tableName` is expected to have at least a leading space.
return "OUTER APPLY (#join.getLateralRawExpression()#)#tableName#";
}

private string function compileCrossApplyJoin( required QueryBuilder query, required JoinClause join ) {
// CROSS APPLY ( <some-table-def> ) (AS)? <table-alias>
var tableName = wrapTable( join.getTable() )
if ( !reFindNoCase( "^\s*#trim( getTableAliasOperator() )#", tableName ) ) {
// table alias operator is optional in MSSqlServer, but we'll provide it if it wasn't expanded via wrapTable.
// Will `wrapTable` ever have emitted a table alias operator here?
// n.b. `getTableAliasOperator()` is expected to have a leading and trailing space.
tableName = "#getTableAliasOperator()##tableName#";
}
// `tableName` is expected to have at least a leading space.
return "CROSS APPLY (#join.getLateralRawExpression()#)#tableName#";
}

private string function compileLateralJoin( required QueryBuilder query, required JoinClause join ) {
throw(
type = "UnsupportedOperation",
message = "This grammar does not support LATERAL joins. Instead, use either OUTER APPLY or CROSS APPLY joins."
);
}

/**
* Compiles the Common Table Expressions (CTEs).
*
Expand Down
22 changes: 20 additions & 2 deletions models/Query/JoinClause.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que
*/
property name="table" type="any";

/**
* In the {cross,outer}Apply case, the already-toSql'd string of the table expr source.
* e.g. will be a string like "select 1 from foo where x = ?"
*/
property name="lateralRawExpression" type="string";

/**
* Valid join types for join clauses.
*/
Expand All @@ -28,7 +34,10 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que
"left",
"left outer",
"right",
"right outer"
"right outer",
"outer apply",
"cross apply",
"lateral"
];

/**
Expand All @@ -37,10 +46,16 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que
* @parentQuery A reference to the query to which this join clause belongs.
* @type The join type of this join clause.
* @table The table to join.
* @crossApplySqlStringWithBindParams The already-`toSql`'d table expression for the {cross,outer}Apply case
*
* @return qb.models.Query.JoinClause
*/
public JoinClause function init( required QueryBuilder parentQuery, required string type, required any table ) {
public JoinClause function init(
required QueryBuilder parentQuery,
required string type,
required any table,
string lateralRawExpression
) {
var typeIsValid = false;
for ( var validType in variables.types ) {
if ( validType == arguments.type ) {
Expand All @@ -54,6 +69,9 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que
variables.parentQuery = arguments.parentQuery;
variables.type = arguments.type;
variables.table = arguments.table;
variables.lateralRawExpression = isNull( arguments.lateralRawExpression )
? ""
: arguments.lateralRawExpression;

super.init( parentQuery.getGrammar(), parentQuery.getUtils() );

Expand Down
66 changes: 60 additions & 6 deletions models/Query/QueryBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,63 @@ component displayname="QueryBuilder" accessors="true" {
return joinRaw( argumentCollection = arguments );
}

private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) {
if ( type != "outer apply" && type != "cross apply" && type != "lateral" ) {
throw(
type = "QBInvalidJoinType",
message = "Invalid join type: #arguments.type#. Valid types are [`outer apply`, `cross apply`, or `lateral`]"
);
}

var sourceIsBuilder = getUtils().isBuilder( arguments.tableLikeSource )
var sourceIsFunc = isClosure( arguments.tableLikeSource ) || isCustomFunction( arguments.tableLikeSource )

if ( !sourceIsBuilder && !sourceIsFunc ) {
throw(
type = "QBInvalidJoinSource",
message = "Invalid join source. Valid types are a QueryBuilder instance or a callback function that receives a new QueryBuilder instance."
);
}

if ( sourceIsFunc ) {
var subquery = newQuery();
arguments.tableLikeSource( subquery );
arguments.tableLikeSource = subquery;
}

var join = new qb.models.Query.JoinClause(
parentQuery = this,
type = type,
table = arguments.name,
lateralRawExpression = arguments.tableLikeSource.toSQL()
);

if ( this.getPreventDuplicateJoins() ) {
var hasThisJoin = variables.joins.find( function( existingJoin ) {
return existingJoin.isEqualTo( join );
} );

if ( hasThisJoin ) {
// Do nothing, early return
// We have not mutated `this` in any way.
return this;
}
}

addBindings( tableLikeSource.getBindings(), "join" );
variables.joins.append( join );

return this;
}

public function outerApply( required string name, required any tableDef ) {
return outerOrCrossApply( name = name, type = "outer apply", tableLikeSource = tableDef );
}

public function crossApply( required string name, required any tableDef ) {
return outerOrCrossApply( name = name, type = "cross apply", tableLikeSource = tableDef );
}

/**
* Adds a LEFT JOIN from a derived table to another table.
*
Expand Down Expand Up @@ -3079,10 +3136,9 @@ component displayname="QueryBuilder" accessors="true" {
}

/**
* Adds a single binding or an array of bindings to a query for a given type.
* Adds all of the bindings from another builder instance.
*
* @newBindings A single binding or an array of bindings to add for a given type.
* @type The type of binding to add.
* @qb Another builder instance to copy all of the bindings from.
*
* @return qb.models.Query.QueryBuilder
*/
Expand Down Expand Up @@ -3248,9 +3304,7 @@ component displayname="QueryBuilder" accessors="true" {
* If no records exist, it throws an RecordNotFound exception.
*
* @options Any options to pass to `queryExecute`. Default: {}.
* @errorMessage An optional string error message or callback to produce
* a string error message. If a callback is used, it is
* passed the unloaded entity as the only argument.
* @errorMessage An optional string error message.
*
* @throws RecordNotFound
*
Expand Down
70 changes: 70 additions & 0 deletions tests/resources/AbstractQueryBuilderSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,76 @@ component extends="testbox.system.BaseSpec" {
.where( "A.C", "=", "C" );
}, joinSubBindings() );
} );

it( "can cross apply", function() {
testCase( function( builder ) {
builder
.from( "users as u" )
.crossApply( "childCount", function( qb ) {
qb.selectRaw( "count(*) c" )
.from( "children" )
.whereColumn( "children.parentID", "=", "users.ID" )
.where( "children.someCol", "=", 0 )
} )
.select( [ "u.ID", "childCount.c" ] )
.where( "childCount.c", ">", 1 )
}, crossApply() );
} );

it( "can outer apply", function() {
testCase( function( builder ) {
builder
.from( "users as u" )
.outerApply( "childCount", function( qb ) {
qb.selectRaw( "count(*) c" )
.from( "children" )
.whereColumn( "children.parentID", "=", "users.ID" )
.where( "children.someCol", "=", 0 )
} )
.select( [ "u.ID", "childCount.c" ] )
.where( "childCount.c", ">", 1 )
}, outerApply() );
} );

it( "correctly positions bindings using crossApply", function() {
testCase( function( builder ) {
builder
.from( "A" )
.where( "A.A", "=", "A" )
.crossApply(
"B",
getBuilder()
.from( "x" )
.where( "x.x", "=", "B" )
.whereColumn( "x.b", "=", "a.b" )
)
.where( "A.C", "=", "C" )
.outerApply( "D", ( qb ) => {
qb.from( "y" )
.where( "y.y", "=", "D" )
.whereColumn( "y.d", "=", "a.d" )
} )
}, correctlyPositionsBindingsUsingCrossApply() );
} );

it( "duplicate {cross,outer} applies eliminated", function() {
testCase( function( builder ) {
var gen = ( name ) => ( qb ) => {
qb.from( name ).select( "someColumn" )
}
builder
.setPreventDuplicateJoins( true )
.from( "A" )
.crossApply( "B", gen( "crossapply_B" ) )
.outerApply( "C", gen( "outerapply_C" ) )
.crossApply( "B", gen( "crossapply_B" ) )
.outerApply( "C", gen( "outerapply_C" ) )
.crossApply( "D", gen( "crossapply_D" ) )
.outerApply( "E", gen( "outerapply_E" ) )
.crossApply( "D", gen( "crossapply_D" ) )
.outerApply( "E", gen( "outerapply_E" ) )
}, duplicateCrossAndOuterAppliesEliminated() );
} );
} );

describe( "group bys", function() {
Expand Down
2 changes: 1 addition & 1 deletion tests/specs/Query/Abstract/JoinClauseSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ component extends="testbox.system.BaseSpec" {
describe( "getMementoForComparison", function() {
beforeEach( function() {
variables.qb = new qb.models.Query.QueryBuilder( preventDuplicateJoins = true ).from(
new qb.models.Query.QueryBuilder( preventDuplicateJOins = true )
new qb.models.Query.QueryBuilder( preventDuplicateJoins = true )
.select( "FK_otherTable" )
.from( "second_table" )
);
Expand Down
16 changes: 16 additions & 0 deletions tests/specs/Query/MySQLQueryBuilderSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1000,4 +1000,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" {
return builder;
}

function crossApply() {
return { exception: "UnsupportedOperation" }
}

function outerApply() {
return { exception: "UnsupportedOperation" }
}

function correctlyPositionsBindingsUsingCrossApply() {
return { exception: "UnsupportedOperation" }
}

function duplicateCrossAndOuterAppliesEliminated() {
return { exception: "UnsupportedOperation" }
}

}
16 changes: 16 additions & 0 deletions tests/specs/Query/OracleQueryBuilderSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1016,4 +1016,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" {
return builder;
}

function crossApply() {
return { exception: "UnsupportedOperation" }
}

function outerApply() {
return { exception: "UnsupportedOperation" }
}

function correctlyPositionsBindingsUsingCrossApply() {
return { exception: "UnsupportedOperation" }
}

function duplicateCrossAndOuterAppliesEliminated() {
return { exception: "UnsupportedOperation" }
}

}
Loading

0 comments on commit 6a818ee

Please sign in to comment.