├── .editorconfig
├── .eslintrc.json
├── .gitignore
├── .tav.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── index.js
├── lib
├── convert.js
├── operations
│ ├── CursorPageOperation.js
│ ├── Operation.js
│ ├── OrderByExplicitOperation.js
│ ├── OrderByOperation.js
│ └── utils.js
├── query-builder
│ └── CursorQueryBuilder.js
├── serialize.js
└── type-serializer.js
├── package.json
├── test
├── .babelrc
├── .eslintrc.json
├── conversion.js
├── index.js
├── options.js
├── query-builder
│ ├── column-name-mappers.js
│ ├── index.js
│ ├── lib
│ │ └── pagination.js
│ ├── mixin-composing.js
│ ├── order-by-coalesce.js
│ ├── order-by-explicit.js
│ └── order-by.js
├── ref.js
└── serialization.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | indent_size = 2
6 | tab_width = 2
7 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "node": true,
5 | "es6": true
6 | },
7 | "plugins": [
8 | "mocha"
9 | ],
10 | "extends": [
11 | "eslint:recommended"
12 | ],
13 | "rules": {
14 | "mocha/no-exclusive-tests": "error",
15 | "no-console": "error",
16 | "no-unused-vars": ["error", {"vars": "all", "args": "all", "ignoreRestSiblings": false, "argsIgnorePattern": "^_"}],
17 | "semi": ["error", "always"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | *.db
3 |
--------------------------------------------------------------------------------
/.tav.yml:
--------------------------------------------------------------------------------
1 | objection-v2:
2 | name: objection
3 | versions: ">=2.1.0"
4 | commands: mocha --require @babel/register
5 | peerDependencies:
6 | - lodash
7 | objection-previous:
8 | name: objection
9 | versions: 0.9.1 || 0.9.4 || 1.6.11
10 | commands: mocha --require @babel/register
11 | peerDependencies:
12 | - lodash
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [1.2.6](https://github.com/olavim/objection-cursor/compare/v1.2.6-alpha.0...v1.2.6) (2022-05-24)
6 |
7 | ### [1.2.6-alpha.0](https://github.com/olavim/objection-cursor/compare/v1.2.5...v1.2.6-alpha.0) (2022-05-24)
8 |
9 |
10 | ### Bug Fixes
11 |
12 | * objection v3 and knex v1 compatibility ([53004cb](https://github.com/olavim/objection-cursor/commit/53004cbd7fb5d3aa65e663e098cd1553fcc3db08))
13 |
14 | ### [1.2.5](https://github.com/olavim/objection-cursor/compare/v1.2.4...v1.2.5) (2022-01-19)
15 |
16 |
17 | ### Bug Fixes
18 |
19 | * correctly build keyset for falsy values ([dbb4b15](https://github.com/olavim/objection-cursor/commit/dbb4b15d4dcfd516bc47a79619f86b3863fb7e3c))
20 |
21 |
22 | ## [1.2.4](https://github.com/olavim/objection-cursor/compare/v1.2.3...v1.2.4) (2020-07-02)
23 |
24 |
25 | ### Bug Fixes
26 |
27 | * Dont use `mergeContext` on objection v2, to avoid deprecationg warning (fixes olavim/objection-cursor[#21](https://github.com/olavim/objection-cursor/issues/21)) ([883674f](https://github.com/olavim/objection-cursor/commit/883674f))
28 |
29 |
30 |
31 |
32 | ## [1.2.3](https://github.com/olavim/objection-cursor/compare/v1.2.2...v1.2.3) (2020-05-20)
33 |
34 |
35 | ### Bug Fixes
36 |
37 | * prop access for schema+table+col refs ([ad5dfa5](https://github.com/olavim/objection-cursor/commit/ad5dfa5))
38 |
39 |
40 |
41 |
42 | ## [1.2.2](https://github.com/olavim/objection-cursor/compare/v1.2.1...v1.2.2) (2020-04-03)
43 |
44 |
45 | ### Bug Fixes
46 |
47 | * **serializer:** handle custom formatters properly ([8cb45d6](https://github.com/olavim/objection-cursor/commit/8cb45d6))
48 |
49 |
50 |
51 |
52 | ## [1.2.1](https://github.com/olavim/objection-cursor/compare/v1.2.0...v1.2.1) (2020-03-17)
53 |
54 |
55 |
56 |
57 | # [1.2.0](https://github.com/olavim/objection-cursor/compare/v1.2.0-alpha.2...v1.2.0) (2020-03-12)
58 |
59 |
60 |
61 |
62 | # [1.2.0-alpha.2](https://github.com/olavim/objection-cursor/compare/v1.2.0-alpha.1...v1.2.0-alpha.2) (2020-03-06)
63 |
64 |
65 | ### Bug Fixes
66 |
67 | * call native orderBy properly ([b3840a9](https://github.com/olavim/objection-cursor/commit/b3840a9))
68 |
69 |
70 |
71 |
72 | # [1.2.0-alpha.1](https://github.com/olavim/objection-cursor/compare/v1.2.0-alpha.0...v1.2.0-alpha.1) (2020-03-05)
73 |
74 |
75 | ### Bug Fixes
76 |
77 | * error when results option is false ([9437213](https://github.com/olavim/objection-cursor/commit/9437213))
78 |
79 |
80 |
81 |
82 | # [1.2.0-alpha.0](https://github.com/olavim/objection-cursor/compare/v1.1.0...v1.2.0-alpha.0) (2020-03-05)
83 |
84 |
85 | ### Features
86 |
87 | * nodes property contains results with row-specific cursors ([45f7d8c](https://github.com/olavim/objection-cursor/commit/45f7d8c))
88 |
89 |
90 |
91 |
92 | # [1.1.0](https://github.com/olavim/objection-cursor/compare/v1.0.1...v1.1.0) (2020-02-19)
93 |
94 |
95 | ### Bug Fixes
96 |
97 | * objection v2 support ([9b4b286](https://github.com/olavim/objection-cursor/commit/9b4b286))
98 | * **pkg:** fix alpha release script ([70d2cdc](https://github.com/olavim/objection-cursor/commit/70d2cdc))
99 | * **readme:** wrong orderByExplicit description ([5663ee6](https://github.com/olavim/objection-cursor/commit/5663ee6))
100 |
101 |
102 | ### Features
103 |
104 | * orderByExplicit querybuilding method ([78e23ef](https://github.com/olavim/objection-cursor/commit/78e23ef))
105 |
106 |
107 |
108 |
109 | ## [1.0.2](https://github.com/olavim/objection-cursor/compare/v1.0.2-alpha.0...v1.0.2) (2020-02-02)
110 |
111 |
112 |
113 |
114 | ## [1.0.2-alpha.0](https://github.com/olavim/objection-cursor/compare/v1.0.1...v1.0.2-alpha.0) (2020-02-02)
115 |
116 |
117 | ### Bug Fixes
118 |
119 | * objection v2 support ([1a038f3](https://github.com/olavim/objection-cursor/commit/1a038f3))
120 |
121 |
122 |
123 |
124 | ## [1.0.1](https://github.com/olavim/objection-cursor/compare/v1.0.1-alpha.0...v1.0.1) (2020-02-02)
125 |
126 |
127 |
128 |
129 | ## [1.0.1-alpha.0](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.5...v1.0.1-alpha.0) (2020-02-02)
130 |
131 |
132 | ### Bug Fixes
133 |
134 | * resultSize with conflicting limits ([ce1ad67](https://github.com/olavim/objection-cursor/commit/ce1ad67))
135 |
136 |
137 |
138 |
139 | # [1.0.0](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.5...v1.0.0) (2019-08-23)
140 |
141 |
142 |
143 |
144 | # [1.0.0-alpha.5](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.4...v1.0.0-alpha.5) (2019-08-23)
145 |
146 |
147 |
148 |
149 | # [1.0.0-alpha.4](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.3...v1.0.0-alpha.4) (2019-08-23)
150 |
151 |
152 | ### Bug Fixes
153 |
154 | * no error when unordered ([38feb77](https://github.com/olavim/objection-cursor/commit/38feb77))
155 |
156 |
157 |
158 |
159 | # [1.0.0-alpha.3](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.2...v1.0.0-alpha.3) (2019-08-17)
160 |
161 |
162 | ### Bug Fixes
163 |
164 | * serialize empty cursor ([1940817](https://github.com/olavim/objection-cursor/commit/1940817))
165 |
166 |
167 |
168 |
169 | # [1.0.0-alpha.2](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2019-08-16)
170 |
171 |
172 | ### Bug Fixes
173 |
174 | * datetime comparisons ([e2cbfe0](https://github.com/olavim/objection-cursor/commit/e2cbfe0))
175 |
176 |
177 |
178 |
179 | # [1.0.0-alpha.1](https://github.com/olavim/objection-cursor/compare/v1.0.0-alpha.0...v1.0.0-alpha.1) (2019-08-10)
180 |
181 |
182 | ### Features
183 |
184 | * **pageInfo:** remainingBefore, remainingAfter, hasMore ([2861f2c](https://github.com/olavim/objection-cursor/commit/2861f2c))
185 |
186 |
187 |
188 |
189 | # [1.0.0-alpha.0](https://github.com/olavim/objection-cursor/compare/v0.5.4...v1.0.0-alpha.0) (2018-09-19)
190 |
191 |
192 |
193 |
194 | ## [0.5.4](https://github.com/olavim/objection-cursor/compare/v0.5.3...v0.5.4) (2018-09-19)
195 |
196 |
197 | ### Bug Fixes
198 |
199 | * do not use objection helpers ([f9713c2](https://github.com/olavim/objection-cursor/commit/f9713c2))
200 |
201 |
202 |
203 |
204 | ## [0.5.3](https://github.com/olavim/objection-cursor/compare/v0.5.2...v0.5.3) (2018-09-19)
205 |
206 |
207 |
208 |
209 | ## [0.5.2](https://github.com/olavim/objection-cursor/compare/v0.5.1...v0.5.2) (2018-09-18)
210 |
211 |
212 | ### Bug Fixes
213 |
214 | * stringify builders in objection v1 ([6377a3e](https://github.com/olavim/objection-cursor/commit/6377a3e))
215 |
216 |
217 |
218 |
219 | ## [0.5.1](https://github.com/olavim/objection-cursor/compare/v0.5.0...v0.5.1) (2018-09-18)
220 |
221 |
222 | ### Bug Fixes
223 |
224 | * coalesce ([1b28179](https://github.com/olavim/objection-cursor/commit/1b28179))
225 |
226 |
227 |
228 |
229 | # [0.5.0](https://github.com/olavim/objection-cursor/compare/v0.4.0...v0.5.0) (2018-07-17)
230 |
231 |
232 | ### Features
233 |
234 | * orderByCoalesce ([31b6956](https://github.com/olavim/objection-cursor/commit/31b6956))
235 |
236 |
237 |
238 |
239 | # [0.4.0](https://github.com/olavim/objection-cursor/compare/v0.3.1...v0.4.0) (2018-07-17)
240 |
241 |
242 | ### Features
243 |
244 | * order by raw ([a7cd802](https://github.com/olavim/objection-cursor/commit/a7cd802))
245 |
246 |
247 |
248 |
249 | ## [0.3.1](https://github.com/olavim/objection-cursor/compare/v0.3.0...v0.3.1) (2018-07-16)
250 |
251 |
252 | ### Bug Fixes
253 |
254 | * refs with column mappers ([a1388e4](https://github.com/olavim/objection-cursor/commit/a1388e4))
255 |
256 |
257 |
258 |
259 | # [0.3.0](https://github.com/olavim/objection-cursor/compare/v0.2.8...v0.3.0) (2018-07-16)
260 |
261 |
262 | ### Features
263 |
264 | * support ordering by refs ([8e812c6](https://github.com/olavim/objection-cursor/commit/8e812c6))
265 |
266 |
267 |
268 |
269 | ## [0.2.8](https://github.com/olavim/objection-cursor/compare/v0.2.7...v0.2.8) (2018-07-09)
270 |
271 |
272 | ### Bug Fixes
273 |
274 | * support [table].[column] format in orderBy ([bbf2080](https://github.com/olavim/objection-cursor/commit/bbf2080))
275 |
276 |
277 |
278 |
279 | ## [0.2.7](https://github.com/olavim/objection-cursor/compare/v0.2.6...v0.2.7) (2018-07-07)
280 |
281 |
282 | ### Bug Fixes
283 |
284 | * do not use runAfter builder for remaining count ([55d31ce](https://github.com/olavim/objection-cursor/commit/55d31ce))
285 |
286 |
287 |
288 |
289 | ## [0.2.6](https://github.com/olavim/objection-cursor/compare/v0.2.5...v0.2.6) (2018-07-06)
290 |
291 |
292 | ### Bug Fixes
293 |
294 | * return postgres total as number ([c139579](https://github.com/olavim/objection-cursor/commit/c139579))
295 |
296 |
297 |
298 |
299 | ## [0.2.5](https://github.com/olavim/objection-cursor/compare/v0.2.4...v0.2.5) (2018-07-06)
300 |
301 |
302 |
303 |
304 | ## [0.2.4](https://github.com/olavim/objection-cursor/compare/v0.2.3...v0.2.4) (2018-07-06)
305 |
306 |
307 |
308 |
309 | ## [0.2.3](https://github.com/olavim/objection-cursor/compare/v0.2.2...v0.2.3) (2018-07-05)
310 |
311 |
312 | ### Bug Fixes
313 |
314 | * null comparisons ([3cd5b47](https://github.com/olavim/objection-cursor/commit/3cd5b47))
315 |
316 |
317 |
318 |
319 | ## [0.2.2](https://github.com/olavim/objection-cursor/compare/v0.2.1...v0.2.2) (2018-07-05)
320 |
321 |
322 | ### Bug Fixes
323 |
324 | * support column name mappers ([6ab005e](https://github.com/olavim/objection-cursor/commit/6ab005e))
325 |
326 |
327 |
328 |
329 | ## [0.2.1](https://github.com/olavim/objection-cursor/compare/v0.2.0...v0.2.1) (2018-07-05)
330 |
331 |
332 |
333 |
334 | # [0.2.0](https://github.com/olavim/objection-cursor/compare/v0.1.0...v0.2.0) (2018-07-04)
335 |
336 |
337 | ### Features
338 |
339 | * break cursor into next and previous ([13a737c](https://github.com/olavim/objection-cursor/commit/13a737c))
340 | * options ([bccaf60](https://github.com/olavim/objection-cursor/commit/bccaf60))
341 |
342 |
343 |
344 |
345 | # [0.1.0](https://github.com/olavim/objection-cursor/compare/v0.0.4...v0.1.0) (2018-07-03)
346 |
347 |
348 | ### Features
349 |
350 | * url-safe cursors ([cceaa82](https://github.com/olavim/objection-cursor/commit/cceaa82))
351 |
352 |
353 |
354 |
355 | ## [0.0.4](https://github.com/olavim/objection-cursor/compare/v0.0.2...v0.0.4) (2018-07-03)
356 |
357 |
358 |
359 |
360 | ## [0.0.3](https://github.com/olavim/objection-cursor/compare/v0.0.2...v0.0.3) (2018-07-03)
361 |
362 |
363 |
364 |
365 | ## 0.0.2 (2018-07-03)
366 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Olavi Mustanoja
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # objection-cursor
2 |
3 | An [Objection.js](https://vincit.github.io/objection.js) plugin for cursor-based pagination, AKA keyset pagination.
4 |
5 | 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.
6 |
7 | 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:
8 |
9 | - The cursor must be based on a unique column (or columns)
10 | - The concept of pages is lost, and thus you cannot jump to a specific one
11 |
12 | Cursor pagination is used by companies such as Twitter, Facebook and Slack, and goes well with infinite scroll elements in general.
13 |
14 | # Installation
15 |
16 | ```
17 | $ npm install objection-cursor
18 | ```
19 |
20 | # Usage
21 |
22 | #### Mixin
23 |
24 | ```js
25 | const Model = require('objection').Model;
26 | const cursorMixin = require('objection-cursor');
27 |
28 | // Set options
29 | const cursor = cursorMixin({limit: 10});
30 |
31 | class Movie extends cursor(Model) {
32 | ...
33 | }
34 |
35 | // Options are not required
36 | class Car extends cursorMixin(Model) {
37 | ...
38 | }
39 | ```
40 |
41 | #### Quick Start
42 |
43 | ```js
44 | const query = Movie.query()
45 | // Strict ordering is required
46 | .orderBy('title')
47 | .orderBy('author')
48 | .limit(10);
49 |
50 | query.clone().cursorPage()
51 | .then(result => {
52 | // Rows 1-10
53 | console.log(result.results);
54 | return query.clone().cursorPage(result.pageInfo.next);
55 | })
56 | .then(result => {
57 | // Rows 11-20
58 | console.log(result.results);
59 | return query.clone().previousCursorPage(result.pageInfo.previous);
60 | })
61 | .then(result => {
62 | // Rows 1-10
63 | console.log(result.results);
64 | });
65 | ```
66 |
67 | 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.
68 |
69 | ```js
70 | const Model = require('objection').Model;
71 | const cursorMixin = require('objection-cursor');
72 |
73 | // Nodes are not returned by default, so you need to enable them
74 | const cursor = cursorMixin({nodes: true});
75 |
76 | class Movie extends cursor(Model) {
77 | ...
78 | }
79 |
80 | const query = Movie.query()
81 | .orderBy('title')
82 | .orderBy('author')
83 | .limit(10);
84 |
85 | query.clone().cursorPage()
86 | .then(result => {
87 | // Rows 1-10 with associated cursors
88 | console.log(result.nodes);
89 |
90 | // Let's take the second node
91 | const node = result.nodes[1];
92 |
93 | // result.nodes[1].data is equivalent to result.results[1]
94 | console.log(result.nodes[1].data);
95 |
96 | // You can get results before/after this row by using node.cursor
97 | return query.clone().cursorPage(node.cursor);
98 | });
99 | ```
100 |
101 | Passing a [reference builder](https://vincit.github.io/objection.js/#referencebuilder) to `orderBy` is supported. [Raw queries](https://vincit.github.io/objection.js/#raw-queries), however, are not.
102 |
103 | ```js
104 | const query = Movie.query()
105 | .joinEager('director')
106 | .orderBy(ref('director.name'))
107 | // Order by a JSON field of an eagerly joined relation
108 | .orderBy(ref('director.born:time').castText())
109 | .orderBy('id')
110 | ...
111 | ```
112 |
113 | 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...)
114 |
115 | ```js
116 | const {raw} = require('objection');
117 |
118 | const query = Movie.query()
119 |
120 | // Coalesce null values into empty string
121 | .orderByExplicit(raw('COALESCE(??, ?)', ['alt_title', '']))
122 |
123 | // Same as above
124 | .orderByExplicit(raw('COALESCE(??, ?)', ['alt_title', '']), 'asc')
125 |
126 | // Works with reference builders and strings
127 | .orderByExplicit(ref('details:completed').castText(), 'desc')
128 |
129 | // Reference builders can be used as part of raw queries
130 | .orderByExplicit(raw('COALESCE(??, ??, ?)', ['even_more_alt_title', ref('alt_title'), raw('?', '')]))
131 |
132 | // Sometimes you need to go deeper...
133 | .orderByExplicit(
134 | raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END', ['alt_title', '', 'alt_title'])
135 | 'asc',
136 |
137 | /* Since this is a cursor plugin, we need to compare actual values that are encoded in the cursor.
138 | * `orderByExplicit` needs to know how to compare a column to a value, which isn't easy to guess
139 | * when you're throwing raw queries at it. By default the callback's return value is similar to the
140 | * column raw query, except the first binding is changed to the value. If this guess would be incorrect,
141 | * you need to specify the compared value manually.
142 | */
143 | value => value || ''
144 | )
145 |
146 | // And deeper...
147 | .orderByExplicit(
148 | raw('CONCAT(??, ??)', ['id', 'title'])
149 | 'asc',
150 |
151 | /* You can return a string, ReferenceBuilder, or a RawBuilder in the callback. This is useful
152 | * when you need to use values from other columns.
153 | */
154 | value => raw('CONCAT(??, ?)', ['id', value]),
155 |
156 | /* By default the first binding in the column raw query (after column name mappers) is used to
157 | * access the relevant value from results. For example, in this case we say value = result['title']
158 | * instead of value = result['id'].
159 | */
160 | 'title'
161 | )
162 | .orderBy('id')
163 | ...
164 | ```
165 |
166 | 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.
167 |
168 | **Deprecated!** Use `orderByExplicit` instead.
169 |
170 | ```js
171 | const query = Movie.query()
172 | .orderByCoalesce('alt_title', 'asc', '') // Coalesce null values into empty string
173 | .orderByCoalesce('alt_title', 'asc') // Same as above
174 | .orderByCoalesce('alt_title', 'asc', [null, 'hello']) // First non-null value will be used
175 | .orderByCoalesce(ref('details:completed').castText(), 'desc') // Works with refs
176 | // Reference builders and raw queries can be coalesced to
177 | .orderByCoalesce('even_more_alt_title', 'asc', [ref('alt_title'), raw('?', '')])
178 | .orderBy('id')
179 | ...
180 | ```
181 |
182 | # API
183 |
184 | ## `Plugin`
185 |
186 | ### `cursor(options | Model)`
187 |
188 | You can setup the mixin with or without options.
189 |
190 | Example (with options):
191 |
192 | ```js
193 | const Model = require('objection').Model;
194 | const cursorMixin = require('objection-cursor');
195 |
196 | const cursor = cursorMixin({
197 | limit: 10,
198 | pageInfo: {
199 | total: true,
200 | hasNext: true
201 | }
202 | });
203 |
204 | class Movie extends cursor(Model) {
205 | ...
206 | }
207 |
208 | Movie.query()
209 | .orderBy('id')
210 | .cursorPage()
211 | .then(res => {
212 | console.log(res.results.length) // 10
213 | console.log(res.pageInfo.total) // Some number
214 | console.log(res.pageInfo.hasNext) // true
215 |
216 | console.log(res.pageInfo.remaining) // undefined
217 | console.log(res.pageInfo.hasPrevious) // undefined
218 | });
219 | ```
220 |
221 | Example (without options):
222 |
223 | ```js
224 | const Model = require('objection').Model;
225 | const cursorMixin = require('objection-cursor');
226 |
227 | class Movie extends cursorMixin(Model) {
228 | ...
229 | }
230 | ```
231 |
232 | ## `CursorQueryBuilder`
233 |
234 | ### `cursorPage([cursor, [before]])`
235 |
236 | - `cursor` - A URL-safe string used to determine after/before which element items should be returned.
237 | - `before` - When `true`, return items before the one specified in the cursor. Use this to "go back".
238 | - Default: `false`.
239 |
240 | **Response format:**
241 |
242 | ```js
243 | {
244 | results: // Page results
245 | nodes: // Page results where each result also has an associated cursor
246 | pageInfo: {
247 | next: // Provide this in the next `cursorPage` call to fetch items after current results.
248 | previous: // Provide this in the next `previousCursorPage` call to fetch items before current results.
249 |
250 | hasMore: // If `options.pageInfo.hasMore` is true.
251 | hasNext: // If `options.pageInfo.hasNext` is true.
252 | hasPrevious: // If `options.pageInfo.hasPrevious` is true.
253 | remaining: // If `options.pageInfo.remaining` is true. Number of items remaining (after or before `results`).
254 | remainingBefore: // If `options.pageInfo.remainingBefore` is true. Number of items remaining before `results`.
255 | remainingAfter: // If `options.pageInfo.remainingAfter` is true. Number of items remaining after `results`.
256 | total: // If `options.pageInfo.total` is true. Total number of available rows (without limit).
257 | }
258 | }
259 | ```
260 |
261 | ### `nextCursorPage([cursor])`
262 |
263 | Alias for `cursorPage`, with `before: false`.
264 |
265 | ### `previousCursorPage([cursor])`
266 |
267 | Alias for `cursorPage`, with `before: true`.
268 |
269 | ### `orderByCoalesce(column, [direction, [values]])`
270 |
271 | > **Deprecated**: use `orderByExplicit` instead.
272 |
273 | Use this if you want to sort by a nullable column.
274 |
275 | - `column` - Column to sort by.
276 | - `direction` - Sort direction.
277 | - Default: `asc`
278 | - `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*.
279 | - Default: `['']`
280 |
281 | ### `orderByExplicit(column, [direction, [compareValue], [property]])`
282 |
283 | Use this if you want to sort by a RawBuilder.
284 |
285 | - `column` - Column to sort by. If this is _not_ a RawBuilder, `compareValue` and `property` will be ignored.
286 | - `direction` - Sort direction.
287 | - Default: `asc`
288 | - `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](https://github.com/olavim/objection-cursor/blob/960a037f2d77d4578dab8c07320601b5a56a5b24/lib/query-builder/CursorQueryBuilder.js#L103) for more details.
289 | - `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.
290 |
291 | #### When do I need to use `compareValue`?
292 |
293 | Consider the following case, where we use a `CASE` statement instead of `COALESCE` to coalesce null values to empty strings
294 |
295 | ```js
296 | Movie.query()
297 | .orderByExplicit(
298 | raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END', ['title', '', 'title']),
299 | 'desc',
300 | value => value || ''
301 | )
302 | ...
303 | ```
304 |
305 | In this case we have two reasons to use `compareValue`. One is that the column raw query uses the `title`
306 | column reference more than once. The other is that we would need to modify the statement slightly, at least
307 | in PostgreSQL's case (otherwise you would run into [this](https://github.com/jackc/pgx/issues/281)).
308 |
309 | #### When do I need to use `property`?
310 |
311 | 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
312 |
313 | ```js
314 | {
315 | id: 1,
316 | title: 'Hello there',
317 | author: 'Somebody McSome'
318 | }
319 | ```
320 |
321 | and your query looks like
322 |
323 | ```js
324 | Movie.query()
325 | .orderByExplicit(raw(`COALESCE(??, '')`, 'date'))
326 | ...
327 | ```
328 |
329 | you would need to use the `property` argument, because there is no `date` property in the result. This might happen if you use [`$parseDatabaseJson`](https://vincit.github.io/objection.js/api/model/instance-methods.html#parsedatabasejson) in your model, for example. Below is an example of using `property` argument together with `$parseDatabaseJson`.
330 |
331 | ```js
332 | class Movie extends cursor(Model) {
333 | $parseDatabaseJson(json) {
334 | json = super.$parseDatabaseJson(json);
335 |
336 | // Rename `title` to `newTitle`
337 | json.newTitle = json.title;
338 | delete json.title;
339 |
340 | return json;
341 | }
342 | }
343 |
344 | Movie.query()
345 | .orderByExplicit(raw(`COALESCE(??, '')`, 'title'), 'asc', 'newTitle')
346 | ....
347 | ```
348 |
349 | #### When do I need to use both?
350 |
351 | 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
352 |
353 | ```js
354 | Movie.query()
355 | .orderByExplicit(
356 | raw('CONCAT(?::TEXT, ??)', ['the ', 'title']),
357 | 'asc',
358 | val => raw('CONCAT(?::TEXT, ?::TEXT)', ['the ', val]),
359 | 'title'
360 | )
361 | ...
362 | ```
363 |
364 | 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).
365 |
366 | # Options
367 |
368 | Values shown are defaults.
369 |
370 | ```js
371 | {
372 | limit: 50, // Default limit in all queries
373 | results: true, // Page results
374 | nodes: true, // Page results where each result also has an associated cursor
375 | pageInfo: {
376 | // When true, these values will be added to `pageInfo` in query response
377 | total: false, // Total amount of rows
378 | remaining: false, // Remaining amount of rows in *this* direction
379 | remainingBefore: false, // Remaining amount of rows before current results
380 | remainingAfter: false, // Remaining amount of rows after current results
381 | hasMore: false, // Are there more rows in this direction?
382 | hasNext: false, // Are there rows after current results?
383 | hasPrevious: false, // Are there rows before current results?
384 | }
385 | }
386 | ```
387 |
388 | ### Notes
389 |
390 | - `pageInfo.total` requires additional query (**A**)
391 | - `pageInfo.remaining` requires additional query (**B**)
392 | - `pageInfo.remainingBefore` requires additional queries (**A**, **B**)
393 | - `pageInfo.remainingAfter` requires additional queries (**A**, **B**)
394 | - `pageInfo.hasMore` requires additional query (**B**)
395 | - `pageInfo.hasNext` requires additional queries (**A**, **B**)
396 | - `pageInfo.hasPrevious` requires additional queries (**A**, **B**)
397 |
398 | **`remaining` vs `remainingBefore` and `remainingAfter`:**
399 |
400 | `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`.
401 |
402 | However, if `total` is used, then using `remaining` no longer gives you the benefit of using one less query.
403 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const {merge} = require('lodash');
2 | const cursorSupport = require('./lib/query-builder/CursorQueryBuilder');
3 |
4 | const cursorMixin = options => {
5 | options = merge({
6 | limit: 50,
7 | results: true,
8 | nodes: false,
9 | pageInfo: {
10 | total: false,
11 | remaining: false,
12 | remainingBefore: false,
13 | remainingAfter: false,
14 | hasNext: false,
15 | hasPrevious: false
16 | }
17 | }, options);
18 |
19 | return Base => {
20 | const CursorQueryBuilder = cursorSupport(options, Base.QueryBuilder);
21 |
22 | return class extends Base {
23 | static get QueryBuilder() {
24 | return CursorQueryBuilder;
25 | }
26 | };
27 | };
28 | };
29 |
30 | module.exports = (options = {}) => {
31 | if (typeof options === 'function') {
32 | return cursorMixin({})(options);
33 | }
34 |
35 | return cursorMixin(options);
36 | };
37 |
--------------------------------------------------------------------------------
/lib/convert.js:
--------------------------------------------------------------------------------
1 | const {get, trim} = require('lodash');
2 |
3 | function stringToProperty(str) {
4 | return str.substr(str.lastIndexOf('.') + 1);
5 | }
6 |
7 | function refToProperty(Model, ref) {
8 | const parsedExpr = ref.parsedExpr || ref.reference; // parsedExpr for Objection v2
9 | let {columnName, access, table} = parsedExpr;
10 |
11 | if (Model.tableName === table) {
12 | // Remove table name and the folowing dot from column name
13 | columnName = columnName.substring(table.length + 1);
14 | }
15 |
16 | const columnPieces = columnName.split('.');
17 |
18 | const prop = `${columnPieces.join('.')}.${access.map(a => a.ref).join('.')}`;
19 | return trim(prop, '.');
20 | }
21 |
22 | function columnToProperty(Model, col) {
23 | if (typeof col === 'string') {
24 | return stringToProperty(col);
25 | }
26 |
27 | if (get(col, 'constructor.name') === 'ReferenceBuilder') {
28 | return refToProperty(Model, col);
29 | }
30 |
31 | throw new TypeError('orderBy column must be string or ReferenceBuilder');
32 | }
33 |
34 | module.exports = {columnToProperty};
35 |
--------------------------------------------------------------------------------
/lib/operations/CursorPageOperation.js:
--------------------------------------------------------------------------------
1 | const {get, castArray} = require('lodash');
2 | const {getOperations, hasOperation, clearOperations} = require('./utils');
3 | const Operation = require('./Operation');
4 | const OrderByOperation = require('./OrderByOperation');
5 | const {serializeCursor, deserializeCursor} = require('../serialize');
6 | const {columnToProperty} = require('../convert');
7 |
8 | class CursorPageOperation extends Operation {
9 | constructor(options) {
10 | super('cursorPage');
11 | this.options = options;
12 | this.originalBuilder = null;
13 | }
14 |
15 | onAdd(builder, args) {
16 | if (hasOperation(builder, CursorPageOperation)) {
17 | return false;
18 | }
19 |
20 | const [cursor = null, before = false] = args;
21 | return super.onAdd(builder, [cursor, before]);
22 | }
23 |
24 | onBefore(builder, result) {
25 | const orderByOps = getOperations(builder, OrderByOperation);
26 |
27 | if (this.args[0] && orderByOps.length !== this.keyset.length) {
28 | // Cursor was given, but keyset length does not match the number of orderBy operations
29 | throw new Error('Cursor does not match ordering');
30 | }
31 |
32 | if (this.before) {
33 | // Reverse order by operation directions
34 | for (const op of orderByOps) {
35 | op.args[1] = op.order === 'asc' ? 'desc' : 'asc';
36 | }
37 | }
38 |
39 | return result;
40 | }
41 |
42 | onBuild(builder) {
43 | // Save copy of builder without modifications made by this operation
44 | this.originalBuilder = clearOperations(builder.clone(), CursorPageOperation);
45 |
46 | whereMore(builder, this.keyset);
47 |
48 | // Add default limit
49 | if (!builder.has(/limit/)) {
50 | builder.limit(this.options.limit);
51 | }
52 | }
53 |
54 | onAfter(builder, data) {
55 | // We want to always return results in the same order, as if turning pages in a book
56 | if (this.before) {
57 | data.reverse();
58 | }
59 |
60 | // Get more results before the first result, or after the last result
61 | const firstResult = data[0];
62 | const lastResult = data[data.length - 1];
63 |
64 | // If we didn't get results, use the last known keyset as a fallback
65 | const fallbackPreviousKeyset = this.before ? this.keyset : null;
66 | const fallbackNextKeyset = this.before ? null : this.keyset;
67 |
68 | /* When we reach end while going forward, save the last element of the last page, but discard
69 | * first element of last page. If we try to go forward, we get an empty result, because
70 | * there are no elements after the last one. If we go back from there, we get results for
71 | * the last page. The opposite is true when going backward from the first page.
72 | */
73 | const previousKeyset = firstResult ? toKeyset(builder, firstResult) : fallbackPreviousKeyset;
74 | const nextKeyset = lastResult ? toKeyset(builder, lastResult) : fallbackNextKeyset;
75 |
76 | let results;
77 | let nodes;
78 |
79 | if (this.options.results) {
80 | results = data;
81 | }
82 |
83 | if (this.options.nodes) {
84 | nodes = data.map(data => ({
85 | data,
86 | cursor: serializeCursor(toKeyset(builder, data))
87 | }));
88 | }
89 |
90 | return this._getAdditionalPageInfo(data)
91 | .then(additionalPageInfo => ({
92 | results,
93 | nodes,
94 | pageInfo: Object.assign(additionalPageInfo, {
95 | next: serializeCursor(nextKeyset),
96 | previous: serializeCursor(previousKeyset)
97 | })
98 | }));
99 | }
100 |
101 | get keyset() {
102 | return deserializeCursor(this.args[0]);
103 | }
104 |
105 | get before() {
106 | return this.args[1] || false;
107 | }
108 |
109 | clone() {
110 | const op = super.clone();
111 | op.options = Object.assign({}, this.options);
112 | op.originalBuilder = this.originalBuilder && this.originalBuilder.clone();
113 | return op;
114 | }
115 |
116 | _getAdditionalPageInfo(result) {
117 | const pageInfo = {};
118 |
119 | // Check if at least one given option is enabled
120 | const isEnabled = opts => castArray(opts).some(key => this.options.pageInfo[key]);
121 | const setIfEnabled = (key, val) => {
122 | pageInfo[key] = this.options.pageInfo[key] ? val : undefined;
123 | };
124 |
125 | let total;
126 |
127 | return Promise.resolve()
128 | .then(() => {
129 | if (isEnabled(['total', 'hasNext', 'hasPrevious', 'remainingBefore', 'remainingAfter'])) {
130 | // Count number of rows without where statements or limits
131 | return this.originalBuilder.clone().resultSize().then(rs => {
132 | total = parseInt(rs, 10);
133 | setIfEnabled('total', total);
134 | });
135 | }
136 | })
137 | .then(() => {
138 | if (isEnabled(['hasMore', 'hasNext', 'hasPrevious', 'remaining', 'remainingBefore', 'remainingAfter'])) {
139 | const builder = this.originalBuilder.clone();
140 | whereMore(builder, this.keyset);
141 |
142 | /* Count number of rows without limits, but retain where statements to count rows
143 | * only in one direction. I.e. get number of rows before/after current results.
144 | */
145 | return builder.resultSize().then(rs => {
146 | const remaining = rs - result.length;
147 | setIfEnabled('remaining', remaining);
148 | setIfEnabled('remainingBefore', this.before ? remaining : total - rs);
149 | setIfEnabled('remainingAfter', this.before ? total - rs : remaining);
150 | setIfEnabled('hasMore', remaining > 0);
151 | setIfEnabled('hasPrevious', (this.before && remaining > 0) || (!this.before && total - rs > 0));
152 | setIfEnabled('hasNext', (!this.before && remaining > 0) || (this.before && total - rs > 0));
153 | });
154 | }
155 | })
156 | .then(() => pageInfo);
157 | }
158 | }
159 |
160 | /**
161 | * Returns array of object values, ordered by `orderBy` operations.
162 | */
163 | function toKeyset(builder, obj) {
164 | const databaseJson = obj.$toDatabaseJson();
165 |
166 | return getOperations(builder, OrderByOperation).map(op => {
167 | const property = op.property || columnToProperty(builder.modelClass(), op.column);
168 | const value = get(databaseJson, property, null);
169 | const fallbackValue = get(obj, property, null);
170 |
171 | // `$toDatabaseJson` removes joined data, so we also check the original model
172 | return (value !== null && value !== undefined) ? value : fallbackValue;
173 | });
174 | }
175 |
176 | function whereMore(builder, keyset) {
177 | if (keyset.length > 0) {
178 | const comparisons = getOperations(builder, OrderByOperation).map((op, idx) => ({
179 | column: op.column,
180 | order: op.order,
181 | value: op.compareValue ? op.compareValue(keyset[idx]) : keyset[idx]
182 | }));
183 |
184 | _whereMore(builder, comparisons, []);
185 | }
186 | }
187 |
188 |
189 | /**
190 | * Recursive procedure to build where statements needed to get rows before/after some given row.
191 | *
192 | * Let's say we want to order by columns [c1, c2, c3], all in ascending order for simplicity.
193 | * The resulting structure looks like this:
194 | *
195 | * - If c1 > value, return row
196 | * - Otherwise, if c1 = value and c2 > value, return row
197 | * - Otherwise, if c1 = value and c2 = value and c3 > value, return row
198 | * - Otherwise, do not return row
199 | *
200 | * Comparisons are simply flipped if order is 'desc', and Objection (usually) knows to compare
201 | * columns to nulls correctly with "column IS NULL" instead of "column = NULL".
202 | */
203 | function _whereMore(builder, comparisons, composites) {
204 | const comparison = comparisons[0];
205 | composites = [comparison, ...composites];
206 |
207 | const op = comparison.order === 'asc' ? '>' : '<';
208 |
209 | builder.andWhere(function () {
210 | this.where(comparison.column, op, comparison.value);
211 |
212 | if (comparisons.length > 1) {
213 | this.orWhere(function () {
214 | for (const composite of composites) {
215 | this.andWhere(composite.column, composite.value);
216 | }
217 |
218 | this.andWhere(function () {
219 | // Add where statements recursively
220 | _whereMore(this, comparisons.slice(1), composites);
221 | });
222 | });
223 | }
224 | });
225 | }
226 |
227 | module.exports = CursorPageOperation;
228 |
--------------------------------------------------------------------------------
/lib/operations/Operation.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Mimics Objection's operation system, which is unfortunately private (https://github.com/Vincit/objection.js/issues/1697).
3 | *
4 | * The idea is to encapsulate each operation's life cycle in a class instead of handling them inside
5 | * runBefore, onBuild and runAfter methods. This makes it easier to prevent the query builder class
6 | * from becoming a bloated monolith.
7 | */
8 | class Operation {
9 | constructor(name = null) {
10 | this.name = name;
11 | this.args = [];
12 | }
13 |
14 | onAdd(_builder, args) {
15 | this.args = args;
16 | return true;
17 | }
18 |
19 | onBefore(_builder, results) {
20 | return results;
21 | }
22 |
23 | onBuild() {}
24 |
25 | onAfter(_builder, results) {
26 | return results;
27 | }
28 |
29 | clone() {
30 | const op = new this.constructor(this.name);
31 | op.args = this.args.slice();
32 | return op;
33 | }
34 | }
35 |
36 | module.exports = Operation;
37 |
--------------------------------------------------------------------------------
/lib/operations/OrderByExplicitOperation.js:
--------------------------------------------------------------------------------
1 | const {raw} = require('objection');
2 | const OrderByOperation = require('./OrderByOperation');
3 | const {columnToProperty} = require('../convert');
4 |
5 | class OrderByExplicitOperation extends OrderByOperation {
6 | onAdd(builder, args) {
7 | let [column, order, compareValue, property] = args;
8 |
9 | // Convert `column` to RawBuilder if it isn't one
10 | if (typeof column === 'string' || column.constructor.name !== 'RawBuilder') {
11 | column = raw('??', column);
12 | }
13 |
14 | if (typeof compareValue === 'string') {
15 | property = compareValue;
16 | compareValue = null;
17 | }
18 |
19 | /* By default `compareValue` is a function that returns a RawBuilder that is identical to the
20 | * column RawBuilder, except first argument is the given value instead of column name.
21 | */
22 | if (!compareValue) {
23 | // Change first ?? binding to ? (value instead of column)
24 | const sql = column._sql.replace('??', '?');
25 | compareValue = val => raw(sql, [val].concat(column._args.slice(1)));
26 | }
27 |
28 | // By default, get column name from first argument of the column RawBuilder
29 | if (!property) {
30 | property = columnToProperty(builder.modelClass(), column._args[0]);
31 | }
32 |
33 | return super.onAdd(builder, [column, order, compareValue, property]);
34 | }
35 |
36 | get compareValue() {
37 | return this.args[2];
38 | }
39 |
40 | get property() {
41 | return this.args[3];
42 | }
43 | }
44 |
45 | module.exports = OrderByExplicitOperation;
46 |
--------------------------------------------------------------------------------
/lib/operations/OrderByOperation.js:
--------------------------------------------------------------------------------
1 | const Operation = require('./Operation');
2 |
3 | class OrderByOperation extends Operation {
4 | constructor() {
5 | super('orderBy');
6 | }
7 |
8 | onAdd(builder, args) {
9 | args[1] = (args[1] || 'asc').toLowerCase();
10 | return super.onAdd(builder, args);
11 | }
12 |
13 | onBuild(builder) {
14 | builder.orderBy(this.column, this.order, this.nulls, true);
15 | }
16 |
17 | get column() {
18 | return this.args[0];
19 | }
20 |
21 | get order() {
22 | return this.args[1];
23 | }
24 |
25 | get nulls() {
26 | return this.args[2];
27 | }
28 | }
29 |
30 | module.exports = OrderByOperation;
31 |
--------------------------------------------------------------------------------
/lib/operations/utils.js:
--------------------------------------------------------------------------------
1 | const Operation = require('./Operation');
2 |
3 | const CTX_OPERATIONS = '__cursor_operations';
4 |
5 | function mergeContext(builder, nextContext) {
6 | if (typeof builder.clearContext === 'undefined') {
7 | // objection v1 (before this commit:
8 | // https://github.com/Vincit/objection.js/commit/9c9b25569e99ac3fd26d58791a7720d6d608c074 )
9 | return builder.mergeContext(nextContext);
10 | } else {
11 | return builder.context(nextContext);
12 | }
13 | }
14 |
15 | function addOperation(builder, operation, args = []) {
16 | if (!operation.onAdd(builder, args)) {
17 | return builder;
18 | }
19 |
20 | const ops = builder.context()[CTX_OPERATIONS] || [];
21 | mergeContext(builder, {[CTX_OPERATIONS]: [...ops, operation]});
22 |
23 | if (ops.length > 0) {
24 | return builder;
25 | }
26 |
27 | return builder
28 | .runBefore((result, builder) => {
29 | return getOperations(builder).reduce((res, op) => op.onBefore(builder, res), result);
30 | })
31 | .onBuild(builder => {
32 | getOperations(builder).forEach(op => op.onBuild(builder));
33 | })
34 | .runAfter((result, builder) => {
35 | return getOperations(builder).reduce((res, op) => op.onAfter(builder, res), result);
36 | });
37 | }
38 |
39 | function getOperations(builder, selector = true, match = true) {
40 | const ops = builder.context()[CTX_OPERATIONS] || [];
41 | const predicate = predicateForOperationSelector(selector);
42 | return ops.filter(op => predicate(op) === match);
43 | }
44 |
45 | function clearOperations(builder, selector) {
46 | const ops = getOperations(builder, selector, false);
47 | return mergeContext(builder, {[CTX_OPERATIONS]: ops});
48 | }
49 |
50 | function hasOperation(builder, selector) {
51 | return getOperations(builder, selector).length > 0;
52 | }
53 |
54 | function cloneOperations(builder) {
55 | return getOperations(builder).map(op => op.clone());
56 | }
57 |
58 | function setOperations(builder, operations) {
59 | return mergeContext(builder, {[CTX_OPERATIONS]: operations});
60 | }
61 |
62 | function predicateForOperationSelector(selector) {
63 | if (selector instanceof RegExp) {
64 | return op => selector.test(op.name);
65 | }
66 |
67 | if (selector && selector.prototype instanceof Operation) {
68 | return op => op instanceof selector;
69 | }
70 |
71 | if (typeof selector === 'string') {
72 | return op => op.name === selector;
73 | }
74 |
75 | if (typeof selector === 'boolean') {
76 | return () => selector;
77 | }
78 |
79 | return () => false;
80 | }
81 |
82 | module.exports = {
83 | addOperation,
84 | getOperations,
85 | setOperations,
86 | clearOperations,
87 | cloneOperations,
88 | hasOperation
89 | };
90 |
--------------------------------------------------------------------------------
/lib/query-builder/CursorQueryBuilder.js:
--------------------------------------------------------------------------------
1 | const {castArray} = require('lodash');
2 | const {raw} = require('objection');
3 | const {
4 | addOperation,
5 | setOperations,
6 | clearOperations,
7 | cloneOperations,
8 | hasOperation
9 | } = require('../operations/utils');
10 | const OrderByOperation = require('../operations/OrderByOperation');
11 | const OrderByExplicitOperation = require('../operations/OrderByExplicitOperation');
12 | const CursorPageOperation = require('../operations/CursorPageOperation');
13 |
14 | module.exports = function (options, Base) {
15 | return class extends Base {
16 | cursorPage(cursor, before) {
17 | return addOperation(this, new CursorPageOperation(options), [cursor, before]);
18 | }
19 |
20 | nextCursorPage(cursor) {
21 | return this.cursorPage(cursor, false);
22 | }
23 |
24 | previousCursorPage(cursor) {
25 | return this.cursorPage(cursor, true);
26 | }
27 |
28 | // DEPRECATED: replaced by `orderByExplicit`
29 | orderByCoalesce(column, order, coalesceValues = ['']) {
30 | coalesceValues = castArray(coalesceValues);
31 | const coalesceBindingsStr = coalesceValues.map(() => '?').join(', ');
32 |
33 | return this.orderByExplicit(
34 | raw(`COALESCE(??, ${coalesceBindingsStr})`, [column].concat(coalesceValues)),
35 | order
36 | );
37 | }
38 |
39 | orderByExplicit(...args) {
40 | return addOperation(this, new OrderByExplicitOperation(), args);
41 | }
42 |
43 | orderBy(column, order, nulls = '', native = false) {
44 | if (native) {
45 | return super.orderBy(column, order, nulls, native);
46 | }
47 |
48 | return addOperation(this, new OrderByOperation(), [column, order, nulls]);
49 | }
50 |
51 | clear(selector) {
52 | super.clear(selector);
53 | return clearOperations(this, selector);
54 | }
55 |
56 | has(selector) {
57 | return super.has(selector) || hasOperation(this, selector);
58 | }
59 |
60 | clone() {
61 | const clone = super.clone();
62 | setOperations(clone, cloneOperations(this));
63 | return clone;
64 | }
65 | };
66 | };
67 |
--------------------------------------------------------------------------------
/lib/serialize.js:
--------------------------------------------------------------------------------
1 | const base64url = require('base64url');
2 | const {serializeValue, deserializeString} = require('./type-serializer');
3 |
4 | function serializeCursor(values) {
5 | if (!values) {
6 | return '';
7 | }
8 |
9 | return values
10 | .map(value => base64url(serializeValue(value)))
11 | .join('.');
12 | }
13 |
14 | function deserializeCursor(cursor = '') {
15 | if (!cursor) {
16 | return [];
17 | }
18 |
19 | return cursor
20 | .split('.')
21 | .map(b64 => b64 ? deserializeString(base64url.decode(b64)) : b64);
22 | }
23 |
24 | module.exports = {serializeCursor, deserializeCursor};
25 |
--------------------------------------------------------------------------------
/lib/type-serializer.js:
--------------------------------------------------------------------------------
1 | class TypeSerializer {
2 | constructor(typeName) {
3 | this.typeName = typeName;
4 | }
5 |
6 | getPrefix() {
7 | return `(${this.typeName})`;
8 | }
9 |
10 | test() {
11 | return false;
12 | }
13 | }
14 |
15 | class DateSerializer extends TypeSerializer {
16 | constructor() {
17 | super('date');
18 | }
19 |
20 | test(value) {
21 | return value instanceof Date;
22 | }
23 |
24 | serialize(value) {
25 | return value.toISOString();
26 | }
27 |
28 | deserialize(value) {
29 | return new Date(value);
30 | }
31 | }
32 |
33 | class JSONSerializer extends TypeSerializer {
34 | constructor() {
35 | super('json');
36 | }
37 |
38 | test() {
39 | // Any type can be stringified
40 | return true;
41 | }
42 |
43 | serialize(value) {
44 | return JSON.stringify(value);
45 | }
46 |
47 | deserialize(value) {
48 | return JSON.parse(value);
49 | }
50 | }
51 |
52 | const serializers = [
53 | new DateSerializer(),
54 | new JSONSerializer()
55 | ];
56 |
57 | function serializeValue(value) {
58 | const serializer = serializers.find(s => s.test(value));
59 | return `${serializer.getPrefix()}${serializer.serialize(value)}`;
60 | }
61 |
62 | function deserializeString(str) {
63 | const matches = str.match(/\((.*?)\)(.*)/);
64 |
65 | if (!matches) {
66 | throw new TypeError('Invalid cursor');
67 | }
68 |
69 | const typeName = matches[1];
70 | const serializedValue = matches[2];
71 | const serializer = serializers.find(s => s.typeName === typeName);
72 | return serializer.deserialize(serializedValue);
73 | }
74 |
75 | module.exports = {serializeValue, deserializeString};
76 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "objection-cursor",
3 | "version": "1.2.6",
4 | "description": "Cursor based pagination plugin for Objection.js",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "mocha --require @babel/register",
8 | "test:sqlite": "CLIENT=sqlite3 mocha --require @babel/register",
9 | "test:pg": "CLIENT=pg mocha --require @babel/register",
10 | "test:tav": "tav",
11 | "lint": "eslint --fix \"index.js\" \"lib/**/*.js\" \"test/**/*.js\"",
12 | "release": "standard-version",
13 | "release:alpha": "standard-version --prerelease alpha"
14 | },
15 | "engines": {
16 | "node": ">=6.0.0"
17 | },
18 | "homepage": "https://github.com/olavim/objection-cursor#readme",
19 | "repository": "https://github.com/olavim/objection-cursor",
20 | "bugs": {
21 | "url": "https://github.com/olavim/objection-cursor/issues"
22 | },
23 | "author": "Olavi Mustanoja ",
24 | "license": "MIT",
25 | "devDependencies": {
26 | "@babel/core": "^7.8.4",
27 | "@babel/preset-env": "^7.8.4",
28 | "@babel/register": "^7.8.3",
29 | "chai": "^4.1.2",
30 | "chai-as-promised": "^7.1.1",
31 | "eslint": "^4.19.1",
32 | "eslint-plugin-mocha": "^5.0.0",
33 | "knex": "^2",
34 | "mocha": "^5.2.0",
35 | "mysql": "^2.17.1",
36 | "objection": "^3",
37 | "pg": "^8.7.3",
38 | "sqlite3": "^5.0.0",
39 | "test-all-versions": "^4.1.1"
40 | },
41 | "dependencies": {
42 | "base64url": "^3.0.0",
43 | "lodash": "^4.17.10",
44 | "moment": "^2.24.0"
45 | },
46 | "peerDependencies": {
47 | "objection": ">=0.9.1"
48 | },
49 | "keywords": [
50 | "objection",
51 | "plugin",
52 | "sql",
53 | "mysql",
54 | "sqlite3",
55 | "cursor",
56 | "pagination"
57 | ]
58 | }
59 |
--------------------------------------------------------------------------------
/test/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "node": "current"
8 | }
9 | }
10 | ]
11 | ]
12 | }
--------------------------------------------------------------------------------
/test/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "mocha": true
4 | },
5 | "parserOptions": {
6 | "ecmaVersion": 8,
7 | "sourceType": "module"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/test/conversion.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {ref, Model} from 'objection';
3 | import {columnToProperty} from '../lib/convert';
4 |
5 | describe('conversion tests', () => {
6 | describe('column to property', () => {
7 | class Movie extends Model {
8 | static get tableName() {
9 | return 'movie';
10 | }
11 | }
12 | class SchemaMovie extends Model {
13 | static get tableName() {
14 | return 'movies.movie';
15 | }
16 | }
17 |
18 | it('string', () => {
19 | expect(columnToProperty(Movie, 'author_name')).to.equal('author_name');
20 | expect(columnToProperty(Movie, 'movie.author_name')).to.equal('author_name');
21 | expect(columnToProperty(Movie, 'movie.data:id')).to.equal('data:id');
22 | expect(columnToProperty(Movie, 'schema.movie.author_name')).to.equal('author_name');
23 | });
24 |
25 | it('ref', () => {
26 | expect(columnToProperty(Movie, ref('id'))).to.equal('id');
27 | expect(columnToProperty(Movie, ref('movie.id'))).to.equal('id');
28 | expect(columnToProperty(Movie, ref('data:id'))).to.equal('data.id');
29 | expect(columnToProperty(Movie, ref('movie.data:id'))).to.equal('data.id');
30 | expect(columnToProperty(Movie, ref('movie.data:some.field'))).to.equal('data.some.field');
31 |
32 | expect(columnToProperty(SchemaMovie, ref('id'))).to.equal('id');
33 | expect(columnToProperty(SchemaMovie, ref('movies.movie.id'))).to.equal('id');
34 | expect(columnToProperty(SchemaMovie, ref('data:id'))).to.equal('data.id');
35 | expect(columnToProperty(SchemaMovie, ref('movies.movie.data:id'))).to.equal('data.id');
36 | expect(columnToProperty(SchemaMovie, ref('movies.movie.data:some.field'))).to.equal('data.some.field');
37 | });
38 |
39 | it('ref with cast', () => {
40 | expect(columnToProperty(Movie, ref('a').castText())).to.equal('a');
41 | expect(columnToProperty(Movie, ref('a.a').castText())).to.equal('a.a');
42 | expect(columnToProperty(Movie, ref('a.a:b').castText())).to.equal('a.a.b');
43 | expect(columnToProperty(Movie, ref('a.a:b.c').castText())).to.equal('a.a.b.c');
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/test/index.js:
--------------------------------------------------------------------------------
1 | import Knex from 'knex';
2 | import moment from 'moment';
3 | import queryBuilderTests from './query-builder';
4 | import optionsTests from './options';
5 | import referenceTests from './ref';
6 |
7 | function padStart(str, targetLength, padString) {
8 | let padded = str;
9 | while (padded.length < targetLength) {
10 | padded = padString + padded;
11 | }
12 | return padded;
13 | }
14 |
15 | const generateMovies = num => {
16 | const d = new Date(2000, 1, 1, 0, 0, 0, 0);
17 | const arr = [...new Array(num)].map((_val, key) => {
18 | return {
19 | // Add some undefined values
20 | title: key % 10 === 0 ? null : `movie-${padStart(String(key % 15), 2, '0')}`,
21 | alt_title: `film-${padStart(String(key % 15), 2, '0')}`,
22 | author: `author-${key % 5}`,
23 | createdAt: new Date(d.getTime() + key).toISOString(),
24 | // Add some null values
25 | date: key % 3 === 0 ? null : new Date(d.getTime() + (key % 7)).toISOString()
26 | };
27 | });
28 |
29 | // Make some createdAt same
30 | arr[num - 1].createdAt = arr[num - 3].createdAt;
31 | arr[num - 2].createdAt = arr[num - 3].createdAt;
32 |
33 | return arr;
34 | };
35 |
36 | describe('database tests', () => {
37 | const dbConnections = [{
38 | client: 'sqlite3',
39 | useNullAsDefault: true,
40 | connection: {
41 | filename: 'test.db'
42 | }
43 | }, {
44 | client: 'pg',
45 | connection: {
46 | host: '127.0.0.1',
47 | user: 'cursortest',
48 | database: 'objection-cursor-test'
49 | }
50 | }, {
51 | client: 'mysql',
52 | version: '5.7',
53 | connection: {
54 | host: '127.0.0.1',
55 | user: 'cursortest',
56 | database: 'objection-cursor-test'
57 | }
58 | }];
59 |
60 | const tasks = dbConnections
61 | .filter(config => !process.env.CLIENT || process.env.CLIENT === config.client)
62 | .map(config => {
63 | const knex = Knex(config);
64 |
65 | describe(config.client, () => {
66 | before(() => {
67 | return knex.schema.dropTableIfExists('movies');
68 | });
69 |
70 | before(() => {
71 | return knex.schema.dropTableIfExists('movie_refs');
72 | });
73 |
74 | before(() => {
75 | return knex.schema.createTable('movies', table => {
76 | table.increments();
77 | table.string('title');
78 | table.string('author');
79 | table.string('alt_title');
80 |
81 | if (config.client === 'mysql') {
82 | table.specificType('date', 'DATETIME(3)');
83 | table.specificType('createdAt', 'DATETIME(3)');
84 | } else {
85 | table.dateTime('date');
86 | table.dateTime('createdAt');
87 | }
88 | });
89 | });
90 |
91 | before(() => {
92 | return knex.schema.createTable('movie_refs', table => {
93 | table.increments();
94 | table.integer('movie_id');
95 | table.json('data');
96 | });
97 | });
98 |
99 | before(() => {
100 | const movies = generateMovies(20);
101 |
102 | if (config.client === 'mysql') {
103 | for (const movie of movies) {
104 | movie.date = movie.date && moment(movie.date).format('YYYY-MM-DD HH:mm:ss.SSS');
105 | movie.createdAt = moment(movie.createdAt).format('YYYY-MM-DD HH:mm:ss.SSS');
106 | }
107 | }
108 |
109 | return knex('movies').insert(movies);
110 | });
111 |
112 | queryBuilderTests(knex);
113 | optionsTests(knex);
114 |
115 | if (config.client === 'pg') {
116 | before(() => {
117 | return knex('movie_refs').insert(generateMovies(20).map((movie, id) => ({
118 | movie_id: id + 1,
119 | data: {title: movie.title}
120 | })));
121 | });
122 |
123 | referenceTests(knex);
124 | }
125 | });
126 |
127 | return knex;
128 | });
129 |
130 | after(() => {
131 | return Promise.all(tasks.map(knex => knex.destroy()));
132 | });
133 | });
134 |
--------------------------------------------------------------------------------
/test/options.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {Model} from 'objection';
3 | import cursorPagination from '..';
4 |
5 | module.exports = knex => {
6 | describe('options tests', () => {
7 | it('has total', async () => {
8 | class Movie extends cursorPagination({pageInfo: {total: true}})(Model) {
9 | static get tableName() {
10 | return 'movies';
11 | }
12 | }
13 |
14 | const res = await Movie.query(knex)
15 | .orderBy('id', 'asc')
16 | .cursorPage();
17 |
18 | expect(res.pageInfo.total).to.equal(20);
19 | });
20 |
21 | it('has remaining', async () => {
22 | class Movie extends cursorPagination({pageInfo: {remaining: true}})(Model) {
23 | static get tableName() {
24 | return 'movies';
25 | }
26 | }
27 |
28 | const res = await Movie.query(knex)
29 | .orderBy('id', 'asc')
30 | .cursorPage()
31 | .limit(10);
32 |
33 | expect(res.pageInfo.remaining).to.equal(10);
34 | });
35 |
36 | it('has remainingBefore', async () => {
37 | class Movie extends cursorPagination({pageInfo: {remainingBefore: true}})(Model) {
38 | static get tableName() {
39 | return 'movies';
40 | }
41 | }
42 |
43 | const res = await Movie.query(knex)
44 | .orderBy('id', 'asc')
45 | .cursorPage()
46 | .limit(10);
47 |
48 | expect(res.pageInfo.remainingBefore).to.equal(0);
49 | });
50 |
51 | it('has remainingAfter', async () => {
52 | class Movie extends cursorPagination({pageInfo: {remainingAfter: true}})(Model) {
53 | static get tableName() {
54 | return 'movies';
55 | }
56 | }
57 |
58 | const res = await Movie.query(knex)
59 | .orderBy('id', 'asc')
60 | .cursorPage()
61 | .limit(10);
62 |
63 | expect(res.pageInfo.remainingAfter).to.equal(10);
64 | });
65 |
66 | it('has hasMore', async () => {
67 | class Movie extends cursorPagination({pageInfo: {hasMore: true}})(Model) {
68 | static get tableName() {
69 | return 'movies';
70 | }
71 | }
72 |
73 | return Movie
74 | .query(knex)
75 | .orderBy('id', 'asc')
76 | .cursorPage()
77 | .limit(10)
78 | .then(({pageInfo}) => {
79 | expect(pageInfo.hasMore).to.equal(true);
80 | });
81 | });
82 |
83 | it('has hasNext', async () => {
84 | class Movie extends cursorPagination({pageInfo: {hasNext: true}})(Model) {
85 | static get tableName() {
86 | return 'movies';
87 | }
88 | }
89 |
90 | const res = await Movie.query(knex)
91 | .orderBy('id', 'asc')
92 | .cursorPage()
93 | .limit(10);
94 |
95 | expect(res.pageInfo.hasNext).to.equal(true);
96 | });
97 |
98 | it('has hasPrevious', async () => {
99 | class Movie extends cursorPagination({pageInfo: {hasPrevious: true}})(Model) {
100 | static get tableName() {
101 | return 'movies';
102 | }
103 | }
104 |
105 | const res = await Movie.query(knex)
106 | .orderBy('id', 'asc')
107 | .cursorPage();
108 |
109 | expect(res.pageInfo.hasPrevious).to.equal(false);
110 | });
111 |
112 | it('has limit', async () => {
113 | class Movie extends cursorPagination({limit: 10})(Model) {
114 | static get tableName() {
115 | return 'movies';
116 | }
117 | }
118 |
119 | const res = await Movie.query(knex)
120 | .orderBy('id', 'asc')
121 | .cursorPage();
122 |
123 | expect(res.results.length).to.equal(10);
124 | });
125 | });
126 | };
--------------------------------------------------------------------------------
/test/query-builder/column-name-mappers.js:
--------------------------------------------------------------------------------
1 | import {Model} from 'objection';
2 | import {expect} from 'chai';
3 | import {mapKeys, camelCase, snakeCase} from 'lodash';
4 | import cursorPagination from '../../';
5 | import testPagination from './lib/pagination';
6 |
7 | export default knex => {
8 | const cursor = cursorPagination({
9 | limit: 10,
10 | results: true,
11 | nodes: true,
12 | pageInfo: {
13 | total: true,
14 | hasMore: true,
15 | hasNext: true,
16 | hasPrevious: true,
17 | remaining: true,
18 | remainingBefore: true,
19 | remainingAfter: true
20 | }
21 | });
22 |
23 | class Movie extends cursor(Model) {
24 | static get tableName() {
25 | return 'movies';
26 | }
27 | }
28 |
29 | Movie.knex(knex);
30 |
31 | describe('column name mappers', () => {
32 | it('lodash snakeCase -> camelCase', async () => {
33 | class CaseMovie extends Movie {
34 | static get columnNameMappers() {
35 | return {
36 | parse(obj) {
37 | return mapKeys(obj, (_val, key) => camelCase(key));
38 | },
39 | format(obj) {
40 | return mapKeys(obj, (_val, key) => snakeCase(key));
41 | }
42 | };
43 | }
44 | }
45 |
46 | const query = CaseMovie
47 | .query()
48 | .orderBy('alt_title')
49 | .orderBy('id', 'asc');
50 |
51 | const results = await query.clone();
52 |
53 | for (const data of results) {
54 | for (const key of Object.keys(data)) {
55 | expect(key.match(/_/)).to.be.null;
56 | }
57 | }
58 |
59 | return testPagination(query, [2, 5]);
60 | });
61 |
62 | it('prefix', async () => {
63 | class PrefixMovie extends Movie {
64 | static get columnNameMappers() {
65 | return {
66 | parse(obj) {
67 | return mapKeys(obj, (_val, key) => `test_${key}`);
68 | },
69 | format(obj) {
70 | return mapKeys(obj, (_val, key) => key.substring(5));
71 | }
72 | };
73 | }
74 | }
75 |
76 | const query = PrefixMovie
77 | .query()
78 | .orderBy('alt_title')
79 | .orderBy('id', 'asc');
80 |
81 | const results = await query.clone();
82 |
83 | for (const data of results) {
84 | for (const key of Object.keys(data)) {
85 | expect(key.match(/^test_/)).to.not.be.null;
86 | }
87 | }
88 |
89 | return testPagination(query, [2, 5]);
90 | });
91 | });
92 | };
--------------------------------------------------------------------------------
/test/query-builder/index.js:
--------------------------------------------------------------------------------
1 | import chai, {expect} from 'chai';
2 | import chaiAsPromised from 'chai-as-promised';
3 | import {Model} from 'objection';
4 | import cursorPagination from '../../';
5 | import {serializeValue} from '../../lib/type-serializer';
6 | import orderByTests from './order-by';
7 | import orderByCoalesceTests from './order-by-coalesce';
8 | import orderByExplicitTests from './order-by-explicit';
9 | import columnNameMapperTests from './column-name-mappers';
10 | import mixinComposingTests from './mixin-composing';
11 |
12 | chai.use(chaiAsPromised);
13 |
14 | export default knex => {
15 | const cursor = cursorPagination({
16 | limit: 10,
17 | results: true,
18 | nodes: true,
19 | pageInfo: {
20 | total: true,
21 | hasMore: true,
22 | hasNext: true,
23 | hasPrevious: true,
24 | remaining: true,
25 | remainingBefore: true,
26 | remainingAfter: true
27 | }
28 | });
29 |
30 | class Movie extends cursor(Model) {
31 | static get tableName() {
32 | return 'movies';
33 | }
34 | }
35 |
36 | Movie.knex(knex);
37 |
38 | describe('query builder', () => {
39 | it('unordered', async () => {
40 | const query = Movie.query();
41 |
42 | const expected = await query.clone();
43 | let res = await query.clone().limit(10).cursorPage();
44 | expect(res.results).to.deep.equal(expected.slice(0, 10));
45 | res = await query.clone().limit(10).cursorPage(res.pageInfo.next);
46 | expect(res.results).to.deep.equal(expected.slice(0, 10));
47 | });
48 |
49 | it('invalid cursor', () => {
50 | const query = Movie.query().cursorPage('what is going on');
51 | expect(query).to.be.rejectedWith(TypeError, 'Invalid cursor');
52 | });
53 |
54 | it('invalid serialized cursor', async () => {
55 | const query = Movie.query().cursorPage(serializeValue('what is going on'));
56 | expect(query).to.be.rejectedWith(TypeError, 'Invalid cursor');
57 | });
58 |
59 | it('wrong ordering', async () => {
60 | const res1 = await Movie.query()
61 | .orderBy('id')
62 | .cursorPage();
63 |
64 | const query = Movie.query()
65 | .orderBy('title')
66 | .orderBy('id')
67 | .cursorPage(res1.pageInfo.next);
68 |
69 | expect(query).to.be.rejectedWith(Error, 'Cursor does not match ordering');
70 | });
71 |
72 | orderByTests(knex);
73 | orderByCoalesceTests(knex);
74 | orderByExplicitTests(knex);
75 | columnNameMapperTests(knex);
76 | mixinComposingTests(knex);
77 | });
78 | };
79 |
--------------------------------------------------------------------------------
/test/query-builder/lib/pagination.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 |
3 | function keysetKeys(query) {
4 | const keys = [];
5 | query.forEachOperation(/orderBy/, op => {
6 | keys.push(op.args[3] || op.args[0]);
7 | });
8 | return keys;
9 | }
10 |
11 | function mapResults(query, results) {
12 | const keys = keysetKeys(query);
13 | return results.map(r => {
14 | return keys.map(k => r[k]).join(', ');
15 | });
16 | }
17 |
18 | // Test query on different page sizes by going from first to last page, and then back.
19 | export default async function test(query, pageSizeRange) {
20 | const totalExpected = await query.clone();
21 |
22 | const pageSizes = [...Array(pageSizeRange[1] - pageSizeRange[0] + 1)].map((_, i) => i + pageSizeRange[0]);
23 |
24 | await Promise.all(
25 | pageSizes.map(async pageSize => {
26 | let cursor;
27 |
28 | for (let offset = 0; offset < totalExpected.length; offset += pageSize) {
29 | const end = Math.min(offset + pageSize, totalExpected.length);
30 |
31 | const {results, nodes, pageInfo} = await query.clone().limit(end - offset).cursorPage(cursor);
32 |
33 | const expected = mapResults(query, results);
34 | const actual = mapResults(query, totalExpected.slice(offset, end));
35 | const pageDisplay = `rows: ${offset} - ${end} / ${totalExpected.length}`;
36 |
37 | expect(results.length, pageDisplay).to.equal(end - offset);
38 | expect(nodes.map(n => n.data)).to.deep.equal(results);
39 | expect(pageInfo.total, pageDisplay).to.equal(totalExpected.length);
40 | expect(pageInfo.remaining, pageDisplay).to.equal(totalExpected.length - end);
41 | expect(pageInfo.remainingAfter, pageDisplay).to.equal(totalExpected.length - end);
42 | expect(pageInfo.remainingBefore, pageDisplay).to.equal(offset);
43 | expect(pageInfo.hasMore, pageDisplay).to.equal(end < totalExpected.length);
44 | expect(pageInfo.hasNext, pageDisplay).to.equal(end < totalExpected.length);
45 | expect(pageInfo.hasPrevious, pageDisplay).to.equal(offset > 0);
46 | expect(expected, pageDisplay).to.deep.equal(actual);
47 |
48 | cursor = pageInfo.next;
49 | }
50 |
51 | const resEnd = await query.clone().limit(5).cursorPage(cursor);
52 | expect(resEnd.results).to.deep.equal([]);
53 |
54 | cursor = resEnd.pageInfo.previous;
55 |
56 | for (let end = totalExpected.length; end >= 0; end -= pageSize) {
57 | const offset = Math.max(0, end - pageSize);
58 |
59 | const {results, nodes, pageInfo} = await query.clone().limit(end - offset).previousCursorPage(cursor);
60 |
61 | const expected = mapResults(query, results);
62 | const actual = mapResults(query, totalExpected.slice(offset, end));
63 | const pageDisplay = `rows: ${offset} - ${end} / ${totalExpected.length}`;
64 |
65 | expect(results.length, pageDisplay).to.equal(end - offset);
66 | expect(nodes.map(n => n.data)).to.deep.equal(results);
67 | expect(pageInfo.total, pageDisplay).to.equal(totalExpected.length);
68 | expect(pageInfo.remaining, pageDisplay).to.equal(offset);
69 | expect(pageInfo.remainingAfter, pageDisplay).to.equal(totalExpected.length - end);
70 | expect(pageInfo.remainingBefore, pageDisplay).to.equal(offset);
71 | expect(pageInfo.hasMore, pageDisplay).to.equal(offset > 0);
72 | expect(pageInfo.hasNext, pageDisplay).to.equal(end < totalExpected.length);
73 | expect(pageInfo.hasPrevious, pageDisplay).to.equal(offset > 0);
74 | expect(expected, pageDisplay).to.deep.equal(actual);
75 |
76 | cursor = pageInfo.previous;
77 | }
78 |
79 | const resStart = await query.clone().limit(5).previousCursorPage(cursor);
80 | expect(resStart.results).to.deep.equal([]);
81 | })
82 | );
83 |
84 | await testEdges(query);
85 | }
86 |
87 | async function testEdges(query) {
88 | const totalExpected = await query.clone();
89 | const firstPage = await query.clone().cursorPage();
90 | const numResults = firstPage.results.length;
91 |
92 | for (let i = 0; i < numResults; i++) {
93 | const page = await query.clone().cursorPage(firstPage.nodes[i].cursor);
94 | expect(page.results).to.deep.equal(totalExpected.slice(i + 1, numResults + i + 1));
95 | expect(page.nodes.map(n => n.data)).to.deep.equal(page.results);
96 | expect(page.pageInfo.total).to.equal(totalExpected.length);
97 | expect(page.pageInfo.remaining).to.equal(totalExpected.length - page.results.length - i - 1);
98 | expect(page.pageInfo.remainingAfter).to.equal(totalExpected.length - page.results.length - i - 1);
99 | expect(page.pageInfo.remainingBefore).to.equal(i + 1);
100 | expect(page.pageInfo.hasMore).to.equal(i + page.results.length + 1 < totalExpected.length);
101 | expect(page.pageInfo.hasNext).to.equal(i + page.results.length + 1 < totalExpected.length);
102 | expect(page.pageInfo.hasPrevious).to.equal(true);
103 | }
104 |
105 | for (let i = numResults - 1; i >= 0; i--) {
106 | const page = await query.clone().previousCursorPage(firstPage.nodes[i].cursor);
107 | expect(page.results).to.deep.equal(totalExpected.slice(0, i));
108 | expect(page.nodes.map(n => n.data)).to.deep.equal(page.results);
109 | expect(page.pageInfo.total).to.equal(totalExpected.length);
110 | expect(page.pageInfo.remaining).to.equal(0);
111 | expect(page.pageInfo.remainingAfter).to.equal(totalExpected.length - i);
112 | expect(page.pageInfo.remainingBefore).to.equal(0);
113 | expect(page.pageInfo.hasMore).to.equal(false);
114 | expect(page.pageInfo.hasNext).to.equal(numResults < totalExpected.length);
115 | expect(page.pageInfo.hasPrevious).to.equal(false);
116 | }
117 | }
--------------------------------------------------------------------------------
/test/query-builder/mixin-composing.js:
--------------------------------------------------------------------------------
1 | import {Model} from 'objection';
2 | import {expect} from 'chai';
3 | import cursorPagination from '../../';
4 | import testPagination from './lib/pagination';
5 |
6 | export default knex => {
7 | const cursor = cursorPagination({
8 | limit: 10,
9 | results: true,
10 | nodes: true,
11 | pageInfo: {
12 | total: true,
13 | hasMore: true,
14 | hasNext: true,
15 | hasPrevious: true,
16 | remaining: true,
17 | remainingBefore: true,
18 | remainingAfter: true
19 | }
20 | });
21 |
22 | class Movie extends cursor(Model) {
23 | static get tableName() {
24 | return 'movies';
25 | }
26 | }
27 |
28 | Movie.knex(knex);
29 |
30 | describe('mixin composing', () => {
31 | it('overriden orderBy', async () => {
32 | class MixinMovie extends Movie {
33 | static get QueryBuilder() {
34 | return class extends Movie.QueryBuilder {
35 | orderBy(...args) {
36 | return super.orderBy(...args);
37 | }
38 | };
39 | }
40 | }
41 |
42 | const query = MixinMovie
43 | .query()
44 | .orderBy('id', 'asc');
45 |
46 | return testPagination(query, [2, 5]);
47 | });
48 |
49 | it('wrapped results', async () => {
50 | class MixinMovie extends Movie {
51 | static get QueryBuilder() {
52 | return class extends Movie.QueryBuilder {
53 | cursorPage(...args) {
54 | return super.cursorPage(...args).runAfter(res => ({wrapped: res}));
55 | }
56 | };
57 | }
58 | }
59 |
60 | const query = MixinMovie
61 | .query()
62 | .orderBy('alt_title')
63 | .orderBy('id', 'asc');
64 |
65 | const expected = await query.clone();
66 |
67 | const res1 = await query.clone().cursorPage();
68 | expect(res1.wrapped.results).to.deep.equal(expected.slice(0, 10));
69 | const res2 = await query.clone().cursorPage(res1.wrapped.pageInfo.next);
70 | expect(res2.wrapped.results).to.deep.equal(expected.slice(10, 20));
71 | const res3 = await query.clone().cursorPage(res2.wrapped.pageInfo.next);
72 | expect(res3.wrapped.results).to.deep.equal([]);
73 | });
74 | });
75 | };
--------------------------------------------------------------------------------
/test/query-builder/order-by-coalesce.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {Model, raw} from 'objection';
3 | import cursorPagination from '../../';
4 | import testPagination from './lib/pagination';
5 |
6 | export default knex => {
7 | const cursor = cursorPagination({
8 | limit: 10,
9 | results: true,
10 | nodes: true,
11 | pageInfo: {
12 | total: true,
13 | hasMore: true,
14 | hasNext: true,
15 | hasPrevious: true,
16 | remaining: true,
17 | remainingBefore: true,
18 | remainingAfter: true
19 | }
20 | });
21 |
22 | class Movie extends cursor(Model) {
23 | static get tableName() {
24 | return 'movies';
25 | }
26 | }
27 |
28 | Movie.knex(knex);
29 |
30 | describe('orderByCoalesce', () => {
31 | it('two order by columns: asc,desc', () => {
32 | const query = Movie
33 | .query()
34 | .orderByCoalesce('title', 'asc')
35 | .orderBy('id', 'desc');
36 |
37 | return testPagination(query, [2, 5]);
38 | });
39 |
40 | it('three order by columns: asc,desc,asc', () => {
41 | const query = Movie
42 | .query()
43 | .orderByCoalesce('title', 'asc')
44 | .orderBy('author', 'desc')
45 | .orderBy('id', 'asc');
46 |
47 | return testPagination(query, [2, 5]);
48 | });
49 |
50 | it('four order by columns: asc,desc,desc,asc', () => {
51 | const datetimeType = knex.client.config.client === 'mysql'
52 | ? 'datetime'
53 | : 'timestamptz';
54 |
55 | const query = Movie
56 | .query()
57 | .orderByCoalesce('title', 'asc')
58 | .orderBy('author', 'desc')
59 | .orderByCoalesce('date', 'desc', raw(`CAST(? as ${datetimeType})`, '1970-1-1'))
60 | .orderBy('id', 'asc');
61 |
62 | return testPagination(query, [2, 5]);
63 | });
64 |
65 | it('cursorPage does not have to be last call', async () => {
66 | const cursorPage = async (...args) => Movie.query()
67 | .cursorPage(...args)
68 | .orderByCoalesce('title', 'desc')
69 | .orderBy('id', 'asc')
70 | .limit(5);
71 |
72 | const expected = await Movie.query()
73 | .orderByCoalesce('title', 'desc')
74 | .orderBy('id', 'asc');
75 |
76 | let res = await cursorPage();
77 | expect(res.results).to.deep.equal(expected.slice(0, 5));
78 | res = await cursorPage(res.pageInfo.next);
79 | expect(res.results).to.deep.equal(expected.slice(5, 10));
80 | res = await cursorPage(res.pageInfo.previous, true);
81 | expect(res.results).to.deep.equal(expected.slice(0, 5));
82 | });
83 |
84 | it('order by coalesce raw', () => {
85 | const query = Movie
86 | .query()
87 | .orderByCoalesce('title', 'desc', raw('?', ['ab']))
88 | .orderBy('id', 'asc');
89 |
90 | return testPagination(query, [2, 5]);
91 | });
92 |
93 | it('column formatter', async () => {
94 | class Title {
95 | constructor(title) {
96 | this.title = title;
97 | }
98 |
99 | toString() {
100 | return this.title;
101 | }
102 | }
103 |
104 | class SuperMovie extends Movie {
105 | $parseDatabaseJson(json) {
106 | json = super.$parseDatabaseJson(json);
107 |
108 | if (json.title) {
109 | json.title = new Title(json.title);
110 | }
111 |
112 | return json;
113 | }
114 |
115 | $formatDatabaseJson(json) {
116 | json = super.$formatDatabaseJson(json);
117 |
118 | if (json.title instanceof Title) {
119 | json.title = json.title.toString();
120 | }
121 |
122 | return json;
123 | }
124 | }
125 |
126 | const query = SuperMovie.query()
127 | .orderByCoalesce('title', 'asc')
128 | .orderBy('id');
129 |
130 | return testPagination(query, [2, 5]);
131 | });
132 | });
133 | };
--------------------------------------------------------------------------------
/test/query-builder/order-by-explicit.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {Model, raw} from 'objection';
3 | import cursorPagination from '../../';
4 | import testPagination from './lib/pagination';
5 |
6 | export default knex => {
7 | const cursor = cursorPagination({
8 | limit: 10,
9 | results: true,
10 | nodes: true,
11 | pageInfo: {
12 | total: true,
13 | hasMore: true,
14 | hasNext: true,
15 | hasPrevious: true,
16 | remaining: true,
17 | remainingBefore: true,
18 | remainingAfter: true
19 | }
20 | });
21 |
22 | class Movie extends cursor(Model) {
23 | static get tableName() {
24 | return 'movies';
25 | }
26 | }
27 |
28 | Movie.knex(knex);
29 |
30 | describe('orderByExplicit', () => {
31 | it('two order by columns: asc,desc', () => {
32 | const query = Movie
33 | .query()
34 | .orderByExplicit(raw('COALESCE(??, ?)', ['title', '']), 'asc')
35 | .orderBy('id', 'desc');
36 |
37 | return testPagination(query, [2, 5]);
38 | });
39 |
40 | it('three order by columns: asc,desc,asc', () => {
41 | const query = Movie
42 | .query()
43 | .orderByExplicit(raw('COALESCE(??, ?)', ['title', '']), 'asc')
44 | .orderBy('author', 'desc')
45 | .orderBy('id', 'asc');
46 |
47 | return testPagination(query, [2, 5]);
48 | });
49 |
50 | it('four order by columns: asc,desc,desc,asc', () => {
51 | const datetimeType = knex.client.config.client === 'mysql'
52 | ? 'datetime'
53 | : 'timestamptz';
54 |
55 | const query = Movie
56 | .query()
57 | .orderByExplicit(raw('COALESCE(??, ?)', ['title', '']), 'asc')
58 | .orderBy('author', 'desc')
59 | .orderByExplicit(raw('COALESCE(??, ?)', ['date', raw(`CAST(? as ${datetimeType})`, '1970-1-1')]), 'desc')
60 | .orderBy('id', 'asc');
61 |
62 | return testPagination(query, [2, 5]);
63 | });
64 |
65 | it('cursorPage does not have to be last call', async () => {
66 | const cursorPage = async (...args) => Movie.query()
67 | .cursorPage(...args)
68 | .orderByExplicit(raw('COALESCE(??, ?)', ['title', '']), 'desc')
69 | .orderBy('id', 'asc')
70 | .limit(5);
71 |
72 | const expected = await Movie.query()
73 | .orderByExplicit(raw('COALESCE(??, ?)', ['title', '']), 'desc')
74 | .orderBy('id', 'asc');
75 |
76 | let res = await cursorPage();
77 | expect(res.results).to.deep.equal(expected.slice(0, 5));
78 | res = await cursorPage(res.pageInfo.next);
79 | expect(res.results).to.deep.equal(expected.slice(5, 10));
80 | res = await cursorPage(res.pageInfo.previous, true);
81 | expect(res.results).to.deep.equal(expected.slice(0, 5));
82 | });
83 |
84 | it('raw queries', () => {
85 | const query = Movie
86 | .query()
87 | .orderByExplicit(raw('COALESCE(??, ?)', ['title', raw('?', ['ab'])]), 'desc')
88 | .orderBy('id', 'asc');
89 |
90 | return testPagination(query, [2, 5]);
91 | });
92 |
93 | it('parseDatabaseJson', () => {
94 | class SuperMovie extends Movie {
95 | $parseDatabaseJson(json) {
96 | json = super.$parseDatabaseJson(json);
97 | json.waitWhat = json.title;
98 | delete json.title;
99 | return json;
100 | }
101 | }
102 |
103 | const query = SuperMovie
104 | .query()
105 | .orderByExplicit(raw(`COALESCE(??, '')`, 'title'), 'asc', 'waitWhat')
106 | .orderBy('id');
107 |
108 | return testPagination(query, [2, 5]);
109 | });
110 |
111 | it('column formatter', async () => {
112 | class Title {
113 | constructor(title) {
114 | this.title = title;
115 | }
116 |
117 | toString() {
118 | return this.title;
119 | }
120 | }
121 |
122 | class SuperMovie extends Movie {
123 | $parseDatabaseJson(json) {
124 | json = super.$parseDatabaseJson(json);
125 |
126 | if (json.title) {
127 | json.title = new Title(json.title);
128 | }
129 |
130 | return json;
131 | }
132 |
133 | $formatDatabaseJson(json) {
134 | json = super.$formatDatabaseJson(json);
135 |
136 | if (json.title instanceof Title) {
137 | json.title = json.title.toString();
138 | }
139 |
140 | return json;
141 | }
142 | }
143 |
144 | const query = SuperMovie.query()
145 | .orderByExplicit(raw(`COALESCE(??, '')`, 'title'), 'asc')
146 | .orderBy('id');
147 |
148 | return testPagination(query, [2, 5]);
149 | });
150 |
151 | if (knex.client.config.client === 'pg') {
152 | describe('PostgreSQL specific', () => {
153 | it('raw case expressions', () => {
154 | const query = Movie
155 | .query()
156 | .orderByExplicit(
157 | raw('CASE WHEN ?? IS NULL THEN ? ELSE ?? END', ['title', '', 'title']),
158 | 'desc',
159 | val => val || ''
160 | )
161 | .orderBy('id', 'asc');
162 |
163 | return testPagination(query, [2, 5]);
164 | });
165 |
166 | it('column name is not first argument in raw', () => {
167 | const query = Movie
168 | .query()
169 | .orderByExplicit(
170 | raw('CONCAT(?::TEXT, ??)', ['tmp', 'title']),
171 | 'asc',
172 | val => 'tmp' + (val || ''),
173 | 'title'
174 | )
175 | .orderBy('id');
176 |
177 | return testPagination(query, [2, 5]);
178 | });
179 | });
180 | }
181 | });
182 | };
183 |
--------------------------------------------------------------------------------
/test/query-builder/order-by.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {Model} from 'objection';
3 | import cursorPagination from '../../';
4 | import testPagination from './lib/pagination';
5 |
6 | export default knex => {
7 | const cursor = cursorPagination({
8 | limit: 10,
9 | results: true,
10 | nodes: true,
11 | pageInfo: {
12 | total: true,
13 | hasMore: true,
14 | hasNext: true,
15 | hasPrevious: true,
16 | remaining: true,
17 | remainingBefore: true,
18 | remainingAfter: true
19 | }
20 | });
21 |
22 | class Movie extends cursor(Model) {
23 | static get tableName() {
24 | return 'movies';
25 | }
26 | }
27 |
28 | Movie.knex(knex);
29 |
30 | describe('orderBy', () => {
31 | it('other where statements', () => {
32 | const query = Movie
33 | .query()
34 | .orderBy('author')
35 | .orderBy('id')
36 | .where('title', 'like', 'movie-0%');
37 |
38 | return testPagination(query, [2, 5]);
39 | });
40 |
41 | it('one order by column', () => {
42 | const query = Movie
43 | .query()
44 | .orderBy('id');
45 |
46 | return testPagination(query, [2, 5]);
47 | });
48 |
49 | it('no results', async () => {
50 | const query = Movie
51 | .query()
52 | .orderBy('id', 'asc')
53 | .where('id', '0');
54 |
55 | const expected = await query.clone();
56 | expect(expected).to.deep.equal([]);
57 |
58 | let res = await query.clone().cursorPage();
59 | expect(res.results).to.deep.equal([]);
60 | res = await query.clone().cursorPage(res.pageInfo.next);
61 | expect(res.results).to.deep.equal([]);
62 | res = await query.clone().previousCursorPage(res.pageInfo.previous);
63 | expect(res.results).to.deep.equal([]);
64 | res = await query.clone().previousCursorPage(res.pageInfo.previous);
65 | expect(res.results).to.deep.equal([]);
66 | });
67 |
68 | it('[table].[column]', () => {
69 | const query = Movie
70 | .query()
71 | .orderBy('movies.id', 'asc');
72 |
73 | return testPagination(query, [2, 5]);
74 | });
75 |
76 | it('date columns', () => {
77 | const query = Movie
78 | .query()
79 | .orderBy('createdAt', 'asc')
80 | .orderBy('id', 'asc');
81 |
82 | return testPagination(query, [2, 5]);
83 | });
84 |
85 | it('column formatter', async () => {
86 | class Title {
87 | constructor(title) {
88 | this.title = title;
89 | }
90 |
91 | toString() {
92 | return this.title;
93 | }
94 | }
95 |
96 | class SuperMovie extends Movie {
97 | $parseDatabaseJson(json) {
98 | json = super.$parseDatabaseJson(json);
99 |
100 | if (json.title) {
101 | json.title = new Title(json.title);
102 | }
103 |
104 | return json;
105 | }
106 |
107 | $formatDatabaseJson(json) {
108 | json = super.$formatDatabaseJson(json);
109 |
110 | if (json.title instanceof Title) {
111 | json.title = json.title.toString();
112 | }
113 |
114 | return json;
115 | }
116 | }
117 |
118 | const query = SuperMovie.query()
119 | .orderBy('createdAt', 'asc')
120 | .orderBy('id');
121 |
122 | return testPagination(query, [2, 5]);
123 | });
124 | });
125 | };
--------------------------------------------------------------------------------
/test/ref.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {Model, ref, raw} from 'objection';
3 | import {mapKeys, snakeCase, camelCase} from 'lodash';
4 | import cursorPagination from '..';
5 |
6 | module.exports = knex => {
7 | describe('reference tests', () => {
8 | const cursor = cursorPagination({
9 | pageInfo: {
10 | total: true,
11 | hasNext: true,
12 | hasPrevious: true,
13 | remaining: true
14 | }
15 | });
16 |
17 | class MovieRef extends cursor(Model) {
18 | static get tableName() {
19 | return 'movie_refs';
20 | }
21 | }
22 |
23 | class Movie extends cursor(Model) {
24 | static get tableName() {
25 | return 'movies';
26 | }
27 |
28 | static get relationMappings() {
29 | return {
30 | ref: {
31 | relation: Model.HasOneRelation,
32 | modelClass: MovieRef,
33 | join: {
34 | from: 'movies.id',
35 | to: 'movie_refs.movie_id'
36 | }
37 | }
38 | };
39 | }
40 | }
41 |
42 | MovieRef.knex(knex);
43 | Movie.knex(knex);
44 |
45 | it('order by ref - 1 column', async () => {
46 | const query = Movie.query().orderBy(ref('movies.id'), 'asc');
47 |
48 | const expected = await query.clone();
49 |
50 | let res = await query.clone().limit(5).cursorPage();
51 | expect(res.results).to.deep.equal(expected.slice(0, 5));
52 | res = await query.clone().limit(5).cursorPage(res.pageInfo.next);
53 | expect(res.results).to.deep.equal(expected.slice(5, 10));
54 | res = await query.clone().limit(10).cursorPage(res.pageInfo.next);
55 | expect(res.results).to.deep.equal(expected.slice(10, 20));
56 | res = await query.clone().limit(10).previousCursorPage(res.pageInfo.previous);
57 | expect(res.results).to.deep.equal(expected.slice(0, 10));
58 | });
59 |
60 | it('order by ref - 2 columns', async () => {
61 | let query = Movie.query()
62 | .orderByCoalesce(ref('ref.data:none').castText(), 'desc', raw('?', ''))
63 | .orderBy('movies.id', 'asc');
64 |
65 | if (query.withGraphJoined) {
66 | query = query.withGraphJoined('ref');
67 | } else {
68 | query = query.joinEager('ref');
69 | }
70 |
71 | const expected = await query.clone();
72 |
73 | let res = await query.clone().limit(5).cursorPage();
74 | expect(res.results).to.deep.equal(expected.slice(0, 5));
75 | res = await query.clone().limit(5).cursorPage(res.pageInfo.next);
76 | expect(res.results).to.deep.equal(expected.slice(5, 10));
77 | res = await query.clone().limit(10).cursorPage(res.pageInfo.next);
78 | expect(res.results).to.deep.equal(expected.slice(10, 20));
79 | res = await query.clone().limit(10).previousCursorPage(res.pageInfo.previous);
80 | expect(res.results).to.deep.equal(expected.slice(0, 10));
81 | });
82 |
83 | it('order by ref with column mappers', async () => {
84 | class CaseMovie extends Movie {
85 | $formatDatabaseJson(json) {
86 | const formatted = super.$formatDatabaseJson(json);
87 | return mapKeys(formatted, (_val, key) => snakeCase(key));
88 | }
89 |
90 | $parseDatabaseJson(json) {
91 | const parsed = super.$parseDatabaseJson(json);
92 | return mapKeys(parsed, (_val, key) => camelCase(key));
93 | }
94 | }
95 |
96 | let query = CaseMovie.query()
97 | .orderByCoalesce(ref('ref.data:title').castText(), 'desc')
98 | .orderBy('movies.id', 'asc');
99 |
100 | if (query.withGraphJoined) {
101 | query = query.withGraphJoined('ref');
102 | } else {
103 | query = query.joinEager('ref');
104 | }
105 |
106 | const expected = await query.clone();
107 |
108 | let res = await query.clone().limit(5).cursorPage();
109 | expect(res.results).to.deep.equal(expected.slice(0, 5));
110 | res = await query.clone().limit(5).cursorPage(res.pageInfo.next);
111 | expect(res.results).to.deep.equal(expected.slice(5, 10));
112 | res = await query.clone().limit(10).cursorPage(res.pageInfo.next);
113 | expect(res.results).to.deep.equal(expected.slice(10, 20));
114 | res = await query.clone().limit(10).previousCursorPage(res.pageInfo.previous);
115 | expect(res.results).to.deep.equal(expected.slice(0, 10));
116 | });
117 | });
118 | };
119 |
--------------------------------------------------------------------------------
/test/serialization.js:
--------------------------------------------------------------------------------
1 | import {expect} from 'chai';
2 | import {serializeCursor, deserializeCursor} from '../lib/serialize';
3 |
4 | const SERIALIZE_ITEMS = [
5 | ['
'],
6 | [new Date(38573587)],
7 | [12]
8 | ];
9 |
10 | describe('serialization tests', () => {
11 | it('serializes into url-safe strings', () => {
12 | for (const item of SERIALIZE_ITEMS) {
13 | const cursor = serializeCursor(item);
14 | expect(/^[a-zA-Z0-9~._-]+$/.test(cursor)).to.be.true;
15 | }
16 | });
17 |
18 | it('deserializes cursor back to item', () => {
19 | for (const item of SERIALIZE_ITEMS) {
20 | const cursor = serializeCursor(item);
21 | const deserialized = deserializeCursor(cursor);
22 | expect(deserialized).to.deep.equal(item);
23 | }
24 | });
25 | });
26 |
--------------------------------------------------------------------------------