Skip to content

Commit

Permalink
feat(QueryBuilder): Add pagination collectors to qb
Browse files Browse the repository at this point in the history
Pagination collectors allow qb to help with pagination by
abstracting `limit` and `offset` to a concept of a page and
by collecting relevant pagination data into a struct
returned with the results of the query:
```
{
    "totalRecords": 45,
    "maxRows": 25,
    "totalPages": 2,
    "page": 2,
    "offset": 25,
    "results": [ /* ... */ ]
}
```

BREAKING CHANGE: Changes to argument names of
`forPage` to `page` and `maxRows`.
  • Loading branch information
elpete committed Dec 20, 2019
1 parent bccbc40 commit 4b2d85f
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 8 deletions.
6 changes: 5 additions & 1 deletion box.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@
"URL":"https://github.com/coldbox-modules/qb/LICENSE"
}
],
"dependencies":{
"cbpaginator":"^1.3.0"
},
"devDependencies":{
"testbox":"^3.0.0"
},
"installPaths":{
"testbox":"testbox/"
"testbox":"testbox/",
"cbpaginator":"modules/cbpaginator/"
},
"ignore":[
"**/.*",
Expand Down
80 changes: 73 additions & 7 deletions models/Query/QueryBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ component displayname="QueryBuilder" accessors="true" {
*/
property name="returnFormat";

/**
* paginationCollector
* A component or struct with a `generateWithResults` method.
* The `generate` method will recieve the following arguments:
* - `totalRecords`
* - `results`
* - `page`
* - `maxRows`
* @default cbpaginator.models.Pagination
*/
property name="paginationCollector";

/**
* columnFormatter callback
* If provided, each column is passed to it before being added to the query.
Expand Down Expand Up @@ -177,14 +189,17 @@ component displayname="QueryBuilder" accessors="true" {
* @grammar The grammar to use when compiling queries. Default: qb.models.Grammars.BaseGrammar
* @utils A collection of query utilities. Default: qb.models.Query.QueryUtils
* @returnFormat the closure (or string format shortcut) that modifies the query and is eventually returned to the caller. Default: 'array'
* @paginationCollector the closure that processes the pagination result. Default: cbpaginator.models.Pagination
* @columnFormatter the closure that modifies each column before being added to the query. Default: Identity
* @defaultOptions the default queryExecute options to use for this builder. This will be merged in each execution.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function init(
grammar = new qb.models.Grammars.BaseGrammar(),
utils = new qb.models.Query.QueryUtils(),
returnFormat = "array",
paginationCollector = new modules.cbpaginator.models.Pagination(),
columnFormatter,
defaultOptions = {}
) {
Expand All @@ -197,6 +212,7 @@ component displayname="QueryBuilder" accessors="true" {
return column;
};
}
setPaginationCollector( arguments.paginationCollector );
setColumnFormatter( arguments.columnFormatter );
setDefaultOptions( arguments.defaultOptions );

Expand Down Expand Up @@ -1416,6 +1432,42 @@ component displayname="QueryBuilder" accessors="true" {
return this;
}

/**
* Add a and having clause to a query.
*
* @column The column with which to constrain the having clause. An expression (`builder.raw()`) can be passed as well.
* @operator The operator to use for the constraint (i.e. "=", "<", ">=", etc.). A value can be passed as the `operator` and the `value` left null as a shortcut for equals (e.g. where( "column", 1 ) == where( "column", "=", 1 ) ).
* @value The value with which to constrain the column. An expression (`builder.raw()`) can be passed as well.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function andHaving(
column,
operator,
value
) {
arguments.combinator = "and";
return having( argumentCollection = arguments );
}

/**
* Add a or having clause to a query.
*
* @column The column with which to constrain the having clause. An expression (`builder.raw()`) can be passed as well.
* @operator The operator to use for the constraint (i.e. "=", "<", ">=", etc.). A value can be passed as the `operator` and the `value` left null as a shortcut for equals (e.g. where( "column", 1 ) == where( "column", "=", 1 ) ).
* @value The value with which to constrain the column. An expression (`builder.raw()`) can be passed as well.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function orHaving(
column,
operator,
value
) {
arguments.combinator = "or";
return having( argumentCollection = arguments );
}

/**
* Add an order by clause to the query. To order by multiple columns, call `orderBy` multiple times.
* The order in which `orderBy` is called matters and is the order it appears in the SQL.
Expand Down Expand Up @@ -1682,21 +1734,35 @@ component displayname="QueryBuilder" accessors="true" {
/**
* Helper method to calculate the limit and offset given a page number and count per page.
*
* @pageNumber The page number to retrieve
* @pageCount The number of records per page.
* @page The page number to retrieve
* @maxRows The number of records per page.
*
* @return qb.models.Query.QueryBuilder
*/
public QueryBuilder function forPage(
required numeric pageNumber,
required numeric pageCount
required numeric page,
required numeric maxRows
) {
arguments.pageCount = arguments.pageCount > 0 ? arguments.pageCount : 0;
offset( arguments.pageNumber * arguments.pageCount - arguments.pageCount );
limit( arguments.pageCount );
arguments.maxRows = arguments.maxRows > 0 ? arguments.maxRows : 0;
offset( arguments.page * arguments.maxRows - arguments.maxRows );
limit( arguments.maxRows );
return this;
}

public any function paginate(
numeric page = 1,
numeric maxRows = 25
) {
var totalRecords = count();
var results = forPage( page, maxRows ).get();
return getPaginationCollector().generateWithResults(
totalRecords = totalRecords,
results = results,
page = arguments.page,
maxRows = arguments.maxRows
);
}

/*******************************************************************************\
| control flow functions |
\*******************************************************************************/
Expand Down
124 changes: 124 additions & 0 deletions tests/specs/Query/Abstract/PaginationSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
component extends="testbox.system.BaseSpec" {

function run() {
describe( "pagination", function() {
it( "returns the default pagination object", function() {
var builder = getBuilder();
var expectedResults = [];
for ( var i = 1; i <= 25; i++ ) {
expectedResults.append( { "id" = i } );
}
var expectedQuery = queryNew( "id", "integer", expectedResults );
builder.$( "count", 45 );
builder.$( "runQuery", expectedQuery );

var results = builder.from( "users" ).paginate();

expect( results ).toBe( {
"pagination": {
"maxRows": 25,
"offset": 0,
"page": 1,
"totalPages": 2,
"totalRecords": 45
},
"results": expectedResults
} );
} );

it( "can get results for subsequent pages", function() {
var builder = getBuilder();
var expectedResults = [];
for ( var i = 26; i <= 45; i++ ) {
expectedResults.append( { "id" = i } );
}
var expectedQuery = queryNew( "id", "integer", expectedResults );
builder.$( "count", 45 );
builder.$( "runQuery", expectedQuery );

var results = builder.from( "users" ).paginate( page = 2 );

expect( results ).toBe( {
"pagination": {
"maxRows": 25,
"offset": 25,
"page": 2,
"totalPages": 2,
"totalRecords": 45
},
"results": expectedResults
} );
} );

it( "can provide a custom amount per page", function() {
var builder = getBuilder();
var expectedResults = [];
for ( var i = 1; i <= 10; i++ ) {
expectedResults.append( { "id" = i } );
}
var expectedQuery = queryNew( "id", "integer", expectedResults );
builder.$( "count", 45 );
builder.$( "runQuery", expectedQuery );

var results = builder.from( "users" ).paginate( page = 1, maxRows = 10);

expect( results ).toBe( {
"pagination": {
"maxRows": 10,
"offset": 0,
"page": 1,
"totalPages": 5,
"totalRecords": 45
},
"results": expectedResults
} );
} );

it( "can provide a custom paginator shell", function() {
var builder = getBuilder();
builder.setPaginationCollector( {
"generateWithResults" = function( totalRecords, results, page, maxRows ) {
return {
"total": totalRecords,
"pageNumber": page,
"limit": maxRows,
"data": results
};
}
} );
var expectedResults = [];
for ( var i = 1; i <= 25; i++ ) {
expectedResults.append( { "id" = i } );
}
var expectedQuery = queryNew( "id", "integer", expectedResults );
builder.$( "count", 45 );
builder.$( "runQuery", expectedQuery );

var results = builder.from( "users" ).paginate();

expect( results ).toBe( {
"total": 45,
"pageNumber": 1,
"limit": 25,
"data": expectedResults
} );
} );
} );
}

private function getBuilder() {
var grammar = getMockBox()
.createMock( "qb.models.Grammars.BaseGrammar" )
.init();
var builder = getMockBox().createMock( "qb.models.Query.QueryBuilder" )
.init( grammar );
return builder;
}

private array function getTestBindings( builder ) {
return builder.getBindings().map( function( binding ) {
return binding.value;
} );
}

}

0 comments on commit 4b2d85f

Please sign in to comment.