This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
This repository was archived by the owner on Sep 16, 2025. It is now read-only.

Repository files navigation

objection-cursor

An Objection.js plugin for cursor-based pagination, AKA keyset pagination.

Using offsets for pagination is a widely popular technique. Clients tell the number of results they want per page, and the page number they want to return results from. While easy to implement and use, offsets come with a drawback: when items are written to the database at a high frequency, offset based pagination becomes unreliable. For example, if we fetch a page with 10 rows, and then 10 rows are added, fetching the second page might contain the same rows as the first page.

Cursor-based pagination works by returning a pointer to a row in the database. Fetching the next/previous page will then return items after/before the given pointer. While reliable, this technique comes with a few drawbacks itself:

  • The cursor must be based on a unique column (or columns)
  • The concept of pages is lost, and thus you cannot jump to a specific one

Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.

Installation

$ npm install objection-cursor

Usage

Mixin

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Set optionsconstcursor=cursorMixin({limit: 10});classMovieextendscursor(Model){
...
}// Options are not requiredclassCarextendscursorMixin(Model){
...
}

Quick Start

constquery=Movie.query()// Strict ordering is required.orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10console.log(result.results);returnquery.clone().cursorPage(result.pageInfo.next);}).then(result=>{// Rows 11-20console.log(result.results);returnquery.clone().previousCursorPage(result.pageInfo.previous);}).then(result=>{// Rows 1-10console.log(result.results);});

You have the option of returning page results as plain database row objects (as in above example), or nodes where each result is associated with a cursor of its own, or both.

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');// Nodes are not returned by default, so you need to enable themconstcursor=cursorMixin({nodes: true});classMovieextendscursor(Model){
...
}constquery=Movie.query().orderBy('title').orderBy('author').limit(10);query.clone().cursorPage().then(result=>{// Rows 1-10 with associated cursorsconsole.log(result.nodes);// Let's take the second nodeconstnode=result.nodes[1];// result.nodes[1].data is equivalent to result.results[1]console.log(result.nodes[1].data);// You can get results before/after this row by using node.cursorreturnquery.clone().cursorPage(node.cursor);});

Passing a reference builder to orderBy is supported. Raw queries, however, are not.

constquery=Movie.query().joinEager('director').orderBy(ref('director.name'))// Order by a JSON field of an eagerly joined relation.orderBy(ref('director.born:time').castText()).orderBy('id')...

That doesn't mean raw queries aren't supported at all. You do need to use a special function for this though, called orderByExplicit (because orderByRaw was taken...)

const{raw}=require('objection');constquery=Movie.query()// Coalesce null values into empty string.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']))// Same as above.orderByExplicit(raw('COALESCE(??, ?)',['alt_title','']),'asc')// Works with reference builders and strings.orderByExplicit(ref('details:completed').castText(),'desc')// Reference builders can be used as part of raw queries.orderByExplicit(raw('COALESCE(??, ??, ?)',['even_more_alt_title',ref('alt_title'),raw('?','')]))// Sometimes you need to go deeper....orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['alt_title','','alt_title'])'asc',/* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor. * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess * when you're throwing raw queries at it. By default the callback's return value is similar to the * column raw query, except the first binding is changed to the value. If this guess would be incorrect, * you need to specify the compared value manually. */value=>value||'')// And deeper....orderByExplicit(raw('CONCAT(??, ??)',['id','title'])'asc',/* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful * when you need to use values from other columns. */value=>raw('CONCAT(??, ?)',['id',value]),/* By default the first binding in the column raw query (after column name mappers) is used to * access the relevant value from results. For example, in this case we say value = result['title'] * instead of value = result['id']. */'title').orderBy('id')...

Cursors ordered by nullable columns won't work out-of-the-box. For this reason the mixin also introduces an orderByCoalesce method, which you can use to treat nulls as some other value for the sake of comparisons. Same as orderBy, orderByCoalesce supports reference builders, but not raw queries.

Deprecated! Use orderByExplicit instead.

constquery=Movie.query().orderByCoalesce('alt_title','asc','')// Coalesce null values into empty string.orderByCoalesce('alt_title','asc')// Same as above.orderByCoalesce('alt_title','asc',[null,'hello'])// First non-null value will be used.orderByCoalesce(ref('details:completed').castText(),'desc')// Works with refs// Reference builders and raw queries can be coalesced to.orderByCoalesce('even_more_alt_title','asc',[ref('alt_title'),raw('?','')]).orderBy('id')...

API

Plugin

cursor(options | Model)

You can setup the mixin with or without options.

Example (with options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');constcursor=cursorMixin({limit: 10,pageInfo: {total: true,hasNext: true}});classMovieextendscursor(Model){
...
}Movie.query().orderBy('id').cursorPage().then(res=>{console.log(res.results.length)// 10console.log(res.pageInfo.total)// Some numberconsole.log(res.pageInfo.hasNext)// trueconsole.log(res.pageInfo.remaining)// undefinedconsole.log(res.pageInfo.hasPrevious)// undefined});

Example (without options):

constModel=require('objection').Model;constcursorMixin=require('objection-cursor');classMovieextendscursorMixin(Model){
...
}

CursorQueryBuilder

cursorPage([cursor, [before]])

  • cursor - A URL-safe string used to determine after/before which element items should be returned.
  • before - When true, return items before the one specified in the cursor. Use this to "go back".
    • Default: false.

Response format:

{
results: // Page results
nodes: // Page results where each result also has an associated cursor
pageInfo: {
next: // Provide this in the next `cursorPage` call to fetch items after current results.
previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
hasMore: // If `options.pageInfo.hasMore` is true.
hasNext: // If `options.pageInfo.hasNext` is true.
hasPrevious: // If `options.pageInfo.hasPrevious` is true.
remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).}}

nextCursorPage([cursor])

Alias for cursorPage, with before: false.

previousCursorPage([cursor])

Alias for cursorPage, with before: true.

orderByCoalesce(column, [direction, [values]])

Deprecated: use orderByExplicit instead.

Use this if you want to sort by a nullable column.

  • column - Column to sort by.
  • direction - Sort direction.
    • Default: asc
  • values - Values to coalesce to. If column has a null value, treat it as the first non-null value in values. Can be one or many of: string, number, ReferenceBuilder or RawQuery.
    • Default: ['']

orderByExplicit(column, [direction, [compareValue], [property]])

Use this if you want to sort by a RawBuilder.

  • column - Column to sort by. If this is not a RawBuilder, compareValue and property will be ignored.
  • direction - Sort direction.
    • Default: asc
  • compareValue callback - Callback is called with a value, and should return one of string, number, ReferenceBuilder or RawQuery. The returned value will be compared against column when determining which row to show results before/after. See this code comment for more details.
  • property - Values will be encoded inside cursors based on ordering, and for this reason orderByExplicit needs to know how to access the related value in the resulting objects. By default the first argument passed to the column raw builder will be used, but if for some reason this guess would be wrong, you need to specify here how to access the value.

When do I need to use compareValue?

Consider the following case, where we use a CASE statement instead of COALESCE to coalesce null values to empty strings

Movie.query().orderByExplicit(raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END',['title','','title']),'desc',value=>value||'')...

In this case we have two reasons to use compareValue. One is that the column raw query uses the title column reference more than once. The other is that we would need to modify the statement slightly, at least in PostgreSQL's case (otherwise you would run into this).

When do I need to use property?

When the property name in your result is different than the first binding in your column raw query. For example, if your model's result structure is something like

{id: 1,title: 'Hello there',author: 'Somebody McSome'}

and your query looks like

Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'date'))...

you would need to use the property argument, because there is no date property in the result. This might happen if you use $parseDatabaseJson in your model, for example. Below is an example of using property argument together with $parseDatabaseJson.

classMovieextendscursor(Model){$parseDatabaseJson(json){json=super.$parseDatabaseJson(json);// Rename `title` to `newTitle`json.newTitle=json.title;deletejson.title;returnjson;}}Movie.query().orderByExplicit(raw(`COALESCE(??, '')`,'title'),'asc','newTitle')....

When do I need to use both?

Basically when the column binding in your column raw query is not the first binding, or if criteria for needing to use both is met for some other reason (see the previous two subchapters). Consider the following example

Movie.query().orderByExplicit(raw('CONCAT(?::TEXT, ??)',['the ','title']),'asc',val=>raw('CONCAT(?::TEXT, ?::TEXT)',['the ',val]),'title')...

Here we are concatenating "the " in front of the movie title. Here we need both compareValue and property, because title is not the first binding in the column raw query (instead "the " is).

Options

Values shown are defaults.

{limit: 50,// Default limit in all queriesresults: true,// Page resultsnodes: true,// Page results where each result also has an associated cursorpageInfo: {// When true, these values will be added to `pageInfo` in query responsetotal: false,// Total amount of rowsremaining: false,// Remaining amount of rows in *this* directionremainingBefore: false,// Remaining amount of rows before current resultsremainingAfter: false,// Remaining amount of rows after current resultshasMore: false,// Are there more rows in this direction?hasNext: false,// Are there rows after current results?hasPrevious: false,// Are there rows before current results?}}

Notes

  • pageInfo.total requires additional query (A)
  • pageInfo.remaining requires additional query (B)
  • pageInfo.remainingBefore requires additional queries (A, B)
  • pageInfo.remainingAfter requires additional queries (A, B)
  • pageInfo.hasMore requires additional query (B)
  • pageInfo.hasNext requires additional queries (A, B)
  • pageInfo.hasPrevious requires additional queries (A, B)

remaining vs remainingBefore and remainingAfter:

remaining only tells you the remaining results in the current direction and is therefore less descriptive as remainingBefore and remainingAfter combined. However, in cases where it's enough to know if there are "more" results, using only the remaining information will use one less query than using either of remainingBefore or remainingAfter. Similarly hasMore uses one less query than hasPrevious, and hasNext.

However, if total is used, then using remaining no longer gives you the benefit of using one less query.

About

Cursor based pagination plugin for Objection.js

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages