Skip to content

Commit

Permalink
feat(QueryBuilder): Automatic where scoping with OR clauses in when c…
Browse files Browse the repository at this point in the history
…allbacks

When calling additional QueryBuilder methods inside a `when` callback, it is
easy to forget that those changes, while in a closure, are not in a where group
or scope (meaning parenthesis around the statements).  This commit automatically
scopes where clauses inside a `when` callback, but only if an OR combinator was
used.  This ignores any additional nested queries inside the `when` callback.
This can be turned off using a new fourth parameter, `withoutScoping`.  A
`withScoping` method is publicly available for downstream libraries like
Quick to utilize this scoping feature in additional ways, like Quick scopes
or additional QuickBuilder methods.

BREAKING CHANGE:  Where statements added inside a `when` callback will be
automatically scoped if the callback adds any statements with an OR
combinator.  This change will not likely break any of your code, but it
is technically a breaking change.
  • Loading branch information
elpete committed Jul 22, 2020
1 parent 81d987d commit 0d6292d
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 52 deletions.
76 changes: 35 additions & 41 deletions box.json
Original file line number Diff line number Diff line change
@@ -1,62 +1,56 @@
{
"name":"qb",
"version":"7.10.0",
"author":"Eric Peterson",
"homepage":"https://github.com/coldbox-modules/qb",
"documentation":"https://github.com/coldbox-modules/qb",
"location":"forgeboxStorage",
"scripts":{
"generateAPIDocs":"rm .tmp --recurse --force && docbox generate mapping=qb excludes=test|ModuleConfig strategy-outputDir=.tmp/apidocs strategy-projectTitle=qb",
"commitAPIDocs":"run-script generateAPIDocs && !git add docs/apidocs/* && !git commit -m 'Updated API Docs'",
"format":"cfformat run models/**/*.cfc,tests/resources/**/*.cfc,tests/specs/**/*.cfc --overwrite",
"format:check":"cfformat check models/**/*.cfc,tests/resources/**/*.cfc,tests/specs/**/*.cfc"
"name": "qb",
"version": "7.10.0",
"author": "Eric Peterson",
"homepage": "https://github.com/coldbox-modules/qb",
"documentation": "https://github.com/coldbox-modules/qb",
"location": "forgeboxStorage",
"scripts": {
"generateAPIDocs": "rm .tmp --recurse --force && docbox generate mapping=qb excludes=test|ModuleConfig strategy-outputDir=.tmp/apidocs strategy-projectTitle=qb",
"commitAPIDocs": "run-script generateAPIDocs && !git add docs/apidocs/* && !git commit -m 'Updated API Docs'",
"format": "cfformat run models/**/*.cfc,tests/resources/**/*.cfc,tests/specs/**/*.cfc --overwrite",
"format:check": "cfformat check models/**/*.cfc,tests/resources/**/*.cfc,tests/specs/**/*.cfc"
},
"repository":{
"type":"git",
"URL":"https://github.com/coldbox-modules/qb"
"repository": {
"type": "git",
"URL": "https://github.com/coldbox-modules/qb"
},
"bugs":"https://github.com/coldbox-modules/qb/issues",
"slug":"qb",
"shortDescription":"A query builder for the rest of us",
"type":"modules",
"keywords":[
"bugs": "https://github.com/coldbox-modules/qb/issues",
"slug": "qb",
"shortDescription": "A query builder for the rest of us",
"type": "modules",
"keywords": [
"ORM",
"query",
"SQL"
],
"private":false,
"projectURL":"https://github.com/coldbox-modules/qb",
"license":[
"private": false,
"projectURL": "https://github.com/coldbox-modules/qb",
"license": [
{
"type":"MIT",
"URL":"https://github.com/coldbox-modules/qb/LICENSE"
"type": "MIT",
"URL": "https://github.com/coldbox-modules/qb/LICENSE"
}
],
"dependencies":{
"cbpaginator":"^2.0.0"
"dependencies": {
"cbpaginator": "^2.0.0"
},
"devDependencies":{
"testbox":"^3.0.0"
"devDependencies": {
"testbox": "^3.0.0"
},
"installPaths":{
"testbox":"testbox/",
"cbpaginator":"modules/cbpaginator/"
"installPaths": {
"testbox": "testbox/",
"cbpaginator": "modules/cbpaginator/"
},
"ignore":[
"ignore": [
"**/.*",
"test",
"tests",
"docs/**/*.*",
"server.json"
],
"testbox":{
"runner":"http://localhost:7777/tests/runner.cfm",
"verbose":false
},
"githooks":{
"preCommit":[
"cfformat run `!git diff --name-only --staged` --overwrite",
"!git add `git diff --name-only --staged`"
]
"testbox": {
"runner": "http://localhost:7777/tests/runner.cfm",
"verbose": false
}
}
92 changes: 81 additions & 11 deletions models/Query/QueryBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2126,25 +2126,95 @@ component displayname="QueryBuilder" accessors="true" {
* When is a useful helper method that introduces if / else control flow without breaking chainability.
* When the `condition` is true, the `onTrue` callback is triggered. If the `condition` is false and an `onFalse` callback is passed, it is triggered. Otherwise, the query is returned unmodified.
*
* @condition A boolean condition that if true will trigger the `onTrue` callback. If not true, the `onFalse` callback will trigger if it was passed. Otherwise, the query is returned unmodified.
* @onTrue A closure that will be triggered if the `condition` is true.
* @onFalse A closure that will be triggered if the `condition` is false.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function when( required boolean condition, onTrue, onFalse ) {
* @condition A boolean condition that if true will trigger the `onTrue` callback. If not true, the `onFalse` callback will trigger if it was passed. Otherwise, the query is returned unmodified.
* @onTrue A closure that will be triggered if the `condition` is true.
* @onFalse A closure that will be triggered if the `condition` is false.
* @withoutScoping Flag to turn off the automatic scoping of where clauses during the callback.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function when(
required boolean condition,
required function onTrue,
function onFalse,
boolean withoutScoping = false
) {
var defaultCallback = function( q ) {
return q;
};
onFalse = isNull( onFalse ) ? defaultCallback : onFalse;
if ( condition ) {
onTrue( this );
arguments.onFalse = isNull( arguments.onFalse ) ? defaultCallback : arguments.onFalse;

if ( arguments.withoutScoping ) {
if ( arguments.condition ) {
arguments.onTrue( this );
} else {
arguments.onFalse( this );
}
} else {
onFalse( this );
withScoping( function() {
if ( condition ) {
onTrue( this );
} else {
onFalse( this );
}
} );
}

return this;
}

/**
* Runs a callback then checks if any where clauses should be scoped
*
* @callback The callback to run and then check if where clauses need to be scoped.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function withScoping( required function callback ) {
var originalWhereCount = this.getWheres().len();
arguments.callback();
if ( this.getWheres().len() > originalWhereCount ) {
addNewWheresWithinGroup( originalWhereCount );
}
return this;
}

/**
* Adds a new nested where clause for the wheres added in a scope.
* It only does this when there is an OR combinator inside the scope.
*
* @originalWhereCount The number of where clauses before the scope was added.
*/
private void function addNewWheresWithinGroup( required numeric originalWhereCount ) {
var allWheres = this.getWheres();
this.setWheres( [] );

if ( arguments.originalWhereCount > 0 ) {
groupWhereSliceForScope( arraySlice( allWheres, 1, arguments.originalWhereCount ) );
}

groupWhereSliceForScope( arraySlice( allWheres, arguments.originalWhereCount + 1 ) );
}

/**
* Checks if a where slice needs to be grouped in parenthesis.
* It only does this when there is an OR combinator inside the scope.
*
* @whereSlice The array of where clauses to maybe be grouped.
*/
private void function groupWhereSliceForScope( required array whereSlice ) {
var hasOrCombinator = false;
for ( var where in arguments.whereSlice ) {
if ( compareNoCase( where.combinator, "OR" ) == 0 ) {
this.addNestedWhereQuery( this.forNestedWhere().setWheres( arguments.whereSlice ) );
return;
}
}
var newWheres = this.getWheres();
arrayAppend( newWheres, arguments.whereSlice, true );
this.setWheres( newWheres );
}

/**
* Tap takes a callback and calls that callback with a copy of the current query.
* The results of calling the callback are ignored. The query is returned unmodified.
Expand Down
60 changes: 60 additions & 0 deletions tests/specs/Query/Abstract/ControlFlowSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,66 @@ component extends="testbox.system.BaseSpec" {
.where( "email", "foo" );
}, { sql: "SELECT * FROM ""users"" WHERE ""id"" = ? AND ""email"" = ?", bindings: [ 1, "foo" ] } );
} );

it( "wraps the wheres if an OR combinator is used inside the callback", function() {
testCase(
function( builder ) {
builder
.select( "*" )
.from( "users" )
.where( "email", "foo" )
.when( true, function( query ) {
query.where( "id", "=", 1 ).orWhere( "id", "=", 2 );
} );
},
{
sql: "SELECT * FROM ""users"" WHERE ""email"" = ? AND (""id"" = ? OR ""id"" = ?)",
bindings: [ "foo", 1, 2 ]
}
);
} );

it( "can skip the wrapping of wheres if an OR combinator is used inside the callback", function() {
testCase(
function( builder ) {
builder
.select( "*" )
.from( "users" )
.where( "email", "foo" )
.when(
condition = true,
onTrue = function( query ) {
query.where( "id", "=", 1 ).orWhere( "id", "=", 2 );
},
withoutScoping = true
);
},
{
sql: "SELECT * FROM ""users"" WHERE ""email"" = ? AND ""id"" = ? OR ""id"" = ?",
bindings: [ "foo", 1, 2 ]
}
);
} );

it( "does not double wrap the wheres if an the wheres are already wrapped inside the callback", function() {
testCase(
function( builder ) {
builder
.select( "*" )
.from( "users" )
.where( "email", "foo" )
.when( true, function( query ) {
query.where( function( q2 ) {
q2.where( "id", "=", 1 ).orWhere( "id", "=", 2 );
} );
} );
},
{
sql: "SELECT * FROM ""users"" WHERE ""email"" = ? AND (""id"" = ? OR ""id"" = ?)",
bindings: [ "foo", 1, 2 ]
}
);
} );
} );

describe( "tap", function() {
Expand Down

0 comments on commit 0d6292d

Please sign in to comment.