json-rest-api 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/connectors_ideas/fastify.js +66 -68
- package/connectors_ideas/koa-plugin.js +50 -51
- package/eslint.config.js +13 -0
- package/examples/url-override-example.js +63 -63
- package/index.js +13 -13
- package/lib/enhanced-logger.js +51 -51
- package/lib/error-formatter.js +61 -61
- package/lib/rest-api-errors.js +28 -28
- package/package.json +9 -5
- package/plugins/core/bulk-operations-plugin.js +142 -145
- package/plugins/core/connectors/express-plugin.js +210 -217
- package/plugins/core/connectors/lib/busboy-detector.js +84 -84
- package/plugins/core/connectors/lib/formidable-detector.js +50 -50
- package/plugins/core/connectors/lib/request-helpers.js +74 -74
- package/plugins/core/file-handling-plugin.js +125 -125
- package/plugins/core/lib/anyapi/anyapi-registry.js +254 -254
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +287 -287
- package/plugins/core/lib/anyapi/schema-utils.js +101 -101
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +41 -41
- package/plugins/core/lib/dbTablesOperations.js +134 -135
- package/plugins/core/lib/fractional-positioning.js +74 -76
- package/plugins/core/lib/querying/knex-cross-table-search.js +320 -320
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +144 -144
- package/plugins/core/lib/querying/knex-pagination-helpers.js +164 -164
- package/plugins/core/lib/querying/knex-process-includes.js +50 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +31 -31
- package/plugins/core/lib/querying/knex-query-helpers.js +451 -452
- package/plugins/core/lib/querying/knex-relationship-includes.js +1160 -1170
- package/plugins/core/lib/querying/knex-window-queries.js +89 -89
- package/plugins/core/lib/querying/url-helpers.js +35 -35
- package/plugins/core/lib/querying-writing/compile-schemas.js +97 -98
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +31 -31
- package/plugins/core/lib/querying-writing/database-capabilities.js +94 -94
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +76 -76
- package/plugins/core/lib/querying-writing/default-data-helpers.js +27 -27
- package/plugins/core/lib/querying-writing/field-utils.js +44 -42
- package/plugins/core/lib/querying-writing/knex-constants.js +15 -16
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +117 -115
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +41 -41
- package/plugins/core/lib/querying-writing/payload-validators.js +275 -275
- package/plugins/core/lib/querying-writing/schema-helpers.js +129 -129
- package/plugins/core/lib/querying-writing/scope-validations.js +143 -146
- package/plugins/core/lib/querying-writing/simplified-helpers.js +133 -129
- package/plugins/core/lib/storage/storage-adapter.js +68 -68
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +31 -31
- package/plugins/core/lib/writing/many-to-many-manipulations.js +81 -81
- package/plugins/core/lib/writing/relationship-processor.js +58 -58
- package/plugins/core/rest-api-access.js +8 -8
- package/plugins/core/rest-api-anyapi-knex-plugin.js +920 -920
- package/plugins/core/rest-api-cors-plugin.js +170 -171
- package/plugins/core/rest-api-knex-plugin.js +482 -493
- package/plugins/core/rest-api-multihome-plugin.js +247 -251
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +20 -21
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +44 -44
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +23 -23
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +15 -15
- package/plugins/core/rest-api-plugin-methods/add-route.js +9 -9
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +5 -6
- package/plugins/core/rest-api-plugin-methods/common.js +295 -297
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +35 -37
- package/plugins/core/rest-api-plugin-methods/delete.js +57 -59
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +172 -171
- package/plugins/core/rest-api-plugin-methods/get-related.js +96 -96
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +16 -18
- package/plugins/core/rest-api-plugin-methods/get.js +86 -86
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +16 -17
- package/plugins/core/rest-api-plugin-methods/patch.js +63 -64
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +54 -55
- package/plugins/core/rest-api-plugin-methods/post.js +61 -62
- package/plugins/core/rest-api-plugin-methods/put.js +86 -87
- package/plugins/core/rest-api-plugin-methods/query.js +88 -88
- package/plugins/core/rest-api-plugin-methods/release.js +5 -6
- package/plugins/core/rest-api-plugin.js +76 -78
- package/plugins/core/rest-api-positioning-plugin.js +251 -252
- package/plugins/core/socketio-plugin.js +240 -236
- package/plugins/storage/local-storage.js +103 -103
- package/plugins/storage/s3-storage.js +41 -42
- package/quickTest.js +37 -37
- package/scripts/debug-anyapi-review-filter.mjs +18 -18
- package/scripts/inspect-anyapi-descriptor-country.mjs +10 -10
- package/scripts/inspect-anyapi-descriptor.mjs +10 -10
- package/scripts/run-docs.js +112 -113
- package/test-debug.js +28 -28
- package/tests/TEST_TEMPLATE.test.js +51 -51
- package/tests/anyapi-basic.test.js +42 -42
- package/tests/anyapi-cursor-pagination.test.js +48 -48
- package/tests/bulk-operations.test.js +208 -208
- package/tests/computed-fields.test.js +204 -204
- package/tests/cors.test.js +412 -413
- package/tests/curl.test.js +275 -276
- package/tests/custom-idproperty-relationships.test.js +266 -266
- package/tests/field-getters.test.js +132 -132
- package/tests/field-setters.test.js +110 -110
- package/tests/fixtures/api-configs.js +699 -699
- package/tests/helpers/storage-mode.js +32 -32
- package/tests/helpers/test-utils.js +76 -76
- package/tests/include-depth-validation.test.js +76 -76
- package/tests/includes.test.js +145 -145
- package/tests/multihome.test.js +175 -175
- package/tests/nested-includes.test.js +255 -255
- package/tests/pagination-cursor-multifield.test.js +164 -164
- package/tests/pagination-enhanced.test.js +225 -225
- package/tests/polymorphic-relationships.test.js +96 -96
- package/tests/polymorphic-simple.test.js +44 -44
- package/tests/positioning-advanced.test.js +226 -226
- package/tests/positioning-extended.test.js +399 -399
- package/tests/positioning-multifilter.test.js +204 -204
- package/tests/positioning.test.js +266 -266
- package/tests/queries.test.js +184 -184
- package/tests/query-limits.test.js +154 -155
- package/tests/relationship-endpoints.test.js +127 -127
- package/tests/relationships.test.js +178 -179
- package/tests/rest-api-access.test.js +86 -86
- package/tests/rest-api.test.js +103 -103
- package/tests/searchschema-merge.test.js +93 -93
- package/tests/socketio.test.js +326 -326
- package/tests/virtual-fields.test.js +164 -164
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import knexLib from 'knex'
|
|
4
|
-
import {
|
|
5
|
-
validateJsonApiStructure,
|
|
6
|
-
cleanTables,
|
|
1
|
+
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import knexLib from 'knex'
|
|
4
|
+
import {
|
|
5
|
+
validateJsonApiStructure,
|
|
6
|
+
cleanTables,
|
|
7
7
|
createJsonApiDocument
|
|
8
|
-
} from './helpers/test-utils.js'
|
|
9
|
-
import { createCursorPaginationApi } from './fixtures/api-configs.js'
|
|
8
|
+
} from './helpers/test-utils.js'
|
|
9
|
+
import { createCursorPaginationApi } from './fixtures/api-configs.js'
|
|
10
10
|
|
|
11
11
|
// Create Knex instance for tests
|
|
12
12
|
const knex = knexLib({
|
|
@@ -15,25 +15,25 @@ const knex = knexLib({
|
|
|
15
15
|
filename: ':memory:'
|
|
16
16
|
},
|
|
17
17
|
useNullAsDefault: true
|
|
18
|
-
})
|
|
18
|
+
})
|
|
19
19
|
|
|
20
20
|
// API instance that persists across tests
|
|
21
|
-
let api
|
|
21
|
+
let api
|
|
22
22
|
|
|
23
23
|
describe('Multi-field Cursor Pagination', () => {
|
|
24
24
|
before(async () => {
|
|
25
25
|
// Initialize API once with cursor pagination configuration
|
|
26
|
-
api = await createCursorPaginationApi(knex)
|
|
27
|
-
})
|
|
26
|
+
api = await createCursorPaginationApi(knex)
|
|
27
|
+
})
|
|
28
28
|
|
|
29
29
|
after(async () => {
|
|
30
30
|
// Close database connection
|
|
31
|
-
await knex.destroy()
|
|
32
|
-
})
|
|
31
|
+
await knex.destroy()
|
|
32
|
+
})
|
|
33
33
|
|
|
34
34
|
describe('Basic Multi-field Cursor Pagination', () => {
|
|
35
35
|
beforeEach(async () => {
|
|
36
|
-
await cleanTables(knex, ['cursor_products'])
|
|
36
|
+
await cleanTables(knex, ['cursor_products'])
|
|
37
37
|
|
|
38
38
|
// Create test data with some duplicate categories and brands
|
|
39
39
|
const products = [
|
|
@@ -45,62 +45,62 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
45
45
|
{ name: 'Asparagus', category: 'Vegetable', brand: 'Green Gardens', price: 3.50, sku: 'GG-001' },
|
|
46
46
|
{ name: 'Broccoli', category: 'Vegetable', brand: 'Green Gardens', price: 2.00, sku: 'GG-002' },
|
|
47
47
|
{ name: 'Carrot', category: 'Vegetable', brand: 'Root Ranch', price: 1.00, sku: 'RR-001' }
|
|
48
|
-
]
|
|
48
|
+
]
|
|
49
49
|
|
|
50
50
|
for (const product of products) {
|
|
51
51
|
await api.resources.products.post({
|
|
52
52
|
inputRecord: createJsonApiDocument('products', product),
|
|
53
53
|
simplified: false
|
|
54
|
-
})
|
|
54
|
+
})
|
|
55
55
|
}
|
|
56
|
-
})
|
|
56
|
+
})
|
|
57
57
|
|
|
58
58
|
it.skip('should handle cursor pagination with multi-field sorting without skipping records', async () => {
|
|
59
59
|
// Sort by category first (which has duplicates), then by name
|
|
60
|
-
const pageSize = 3
|
|
61
|
-
const allProducts = []
|
|
62
|
-
let cursor = null
|
|
63
|
-
let pageCount = 0
|
|
60
|
+
const pageSize = 3
|
|
61
|
+
const allProducts = []
|
|
62
|
+
let cursor = null
|
|
63
|
+
let pageCount = 0
|
|
64
64
|
|
|
65
65
|
// Collect all products through cursor pagination
|
|
66
66
|
while (pageCount < 10) { // Safety limit
|
|
67
67
|
const result = await api.resources.products.query({
|
|
68
68
|
queryParams: {
|
|
69
|
-
page: {
|
|
69
|
+
page: {
|
|
70
70
|
size: pageSize,
|
|
71
71
|
...(cursor && { after: cursor })
|
|
72
72
|
},
|
|
73
73
|
sort: ['category', 'name']
|
|
74
74
|
},
|
|
75
75
|
simplified: false
|
|
76
|
-
})
|
|
76
|
+
})
|
|
77
77
|
|
|
78
|
-
validateJsonApiStructure(result, true)
|
|
79
|
-
allProducts.push(...result.data)
|
|
80
|
-
pageCount
|
|
78
|
+
validateJsonApiStructure(result, true)
|
|
79
|
+
allProducts.push(...result.data)
|
|
80
|
+
pageCount++
|
|
81
81
|
|
|
82
82
|
if (!result.meta.pagination.hasMore) {
|
|
83
|
-
break
|
|
83
|
+
break
|
|
84
84
|
}
|
|
85
|
-
cursor = result.meta.pagination.cursor.next
|
|
85
|
+
cursor = result.meta.pagination.cursor.next
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// Verify we got all 8 products
|
|
89
|
-
assert.equal(allProducts.length, 8, 'Should retrieve all 8 products')
|
|
90
|
-
|
|
89
|
+
assert.equal(allProducts.length, 8, 'Should retrieve all 8 products')
|
|
90
|
+
|
|
91
91
|
// Verify the order is correct
|
|
92
|
-
const productNames = allProducts.map(p => p.attributes.name)
|
|
92
|
+
const productNames = allProducts.map(p => p.attributes.name)
|
|
93
93
|
const expectedOrder = [
|
|
94
94
|
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', // All Fruits in alphabetical order
|
|
95
95
|
'Asparagus', 'Broccoli', 'Carrot' // All Vegetables in alphabetical order
|
|
96
|
-
]
|
|
97
|
-
assert.deepEqual(productNames, expectedOrder, 'Products should be in correct order')
|
|
96
|
+
]
|
|
97
|
+
assert.deepEqual(productNames, expectedOrder, 'Products should be in correct order')
|
|
98
98
|
|
|
99
99
|
// Verify no duplicates
|
|
100
|
-
const ids = allProducts.map(p => p.id)
|
|
101
|
-
const uniqueIds = [...new Set(ids)]
|
|
102
|
-
assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
|
|
103
|
-
})
|
|
100
|
+
const ids = allProducts.map(p => p.id)
|
|
101
|
+
const uniqueIds = [...new Set(ids)]
|
|
102
|
+
assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
|
|
103
|
+
})
|
|
104
104
|
|
|
105
105
|
it.skip('should handle DESC sorting with duplicates correctly', async () => {
|
|
106
106
|
// Sort by category DESC, then name ASC
|
|
@@ -110,28 +110,28 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
110
110
|
sort: ['-category', 'name']
|
|
111
111
|
},
|
|
112
112
|
simplified: false
|
|
113
|
-
})
|
|
113
|
+
})
|
|
114
114
|
|
|
115
115
|
const secondPage = await api.resources.products.query({
|
|
116
116
|
queryParams: {
|
|
117
|
-
page: {
|
|
117
|
+
page: {
|
|
118
118
|
size: 3,
|
|
119
119
|
after: firstPage.meta.pagination.cursor.next
|
|
120
120
|
},
|
|
121
121
|
sort: ['-category', 'name']
|
|
122
122
|
},
|
|
123
123
|
simplified: false
|
|
124
|
-
})
|
|
124
|
+
})
|
|
125
125
|
|
|
126
126
|
// First page should have vegetables (category DESC)
|
|
127
|
-
const firstPageCategories = firstPage.data.map(p => p.attributes.category)
|
|
128
|
-
assert(firstPageCategories.every(c => c === 'Vegetable'), 'First page should only have Vegetables')
|
|
127
|
+
const firstPageCategories = firstPage.data.map(p => p.attributes.category)
|
|
128
|
+
assert(firstPageCategories.every(c => c === 'Vegetable'), 'First page should only have Vegetables')
|
|
129
129
|
|
|
130
130
|
// Second page should start with remaining vegetables or fruits
|
|
131
|
-
const secondPageNames = secondPage.data.map(p => p.attributes.name)
|
|
132
|
-
assert(secondPageNames.includes('Apple') || secondPageNames.includes('Banana'),
|
|
133
|
-
'Second page should include fruits that come after vegetables')
|
|
134
|
-
})
|
|
131
|
+
const secondPageNames = secondPage.data.map(p => p.attributes.name)
|
|
132
|
+
assert(secondPageNames.includes('Apple') || secondPageNames.includes('Banana'),
|
|
133
|
+
'Second page should include fruits that come after vegetables')
|
|
134
|
+
})
|
|
135
135
|
|
|
136
136
|
it('should handle three-field sorting with multiple duplicate values', async () => {
|
|
137
137
|
// Add more products with duplicate category AND brand
|
|
@@ -144,50 +144,50 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
144
144
|
sku: 'GG-003'
|
|
145
145
|
}),
|
|
146
146
|
simplified: false
|
|
147
|
-
})
|
|
147
|
+
})
|
|
148
148
|
|
|
149
|
-
const pageSize = 2
|
|
150
|
-
const allProducts = []
|
|
151
|
-
let cursor = null
|
|
152
|
-
let iterations = 0
|
|
149
|
+
const pageSize = 2
|
|
150
|
+
const allProducts = []
|
|
151
|
+
let cursor = null
|
|
152
|
+
let iterations = 0
|
|
153
153
|
|
|
154
154
|
// Sort by category, brand, then name
|
|
155
155
|
while (iterations < 20) {
|
|
156
156
|
const result = await api.resources.products.query({
|
|
157
157
|
queryParams: {
|
|
158
|
-
page: {
|
|
158
|
+
page: {
|
|
159
159
|
size: pageSize,
|
|
160
160
|
...(cursor && { after: cursor })
|
|
161
161
|
},
|
|
162
162
|
sort: ['category', 'brand', 'name']
|
|
163
163
|
},
|
|
164
164
|
simplified: false
|
|
165
|
-
})
|
|
165
|
+
})
|
|
166
166
|
|
|
167
|
-
allProducts.push(...result.data)
|
|
167
|
+
allProducts.push(...result.data)
|
|
168
168
|
|
|
169
169
|
if (!result.meta.pagination.hasMore) {
|
|
170
|
-
break
|
|
170
|
+
break
|
|
171
171
|
}
|
|
172
|
-
cursor = result.meta.pagination.cursor.next
|
|
173
|
-
iterations += 1
|
|
172
|
+
cursor = result.meta.pagination.cursor.next
|
|
173
|
+
iterations += 1
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
assert(iterations < 20, 'Cursor pagination should converge within expected page count')
|
|
176
|
+
assert(iterations < 20, 'Cursor pagination should converge within expected page count')
|
|
177
177
|
|
|
178
178
|
// Find all Green Gardens vegetables
|
|
179
|
-
const greenGardensVeggies = allProducts.filter(p =>
|
|
180
|
-
p.attributes.category === 'Vegetable' &&
|
|
179
|
+
const greenGardensVeggies = allProducts.filter(p =>
|
|
180
|
+
p.attributes.category === 'Vegetable' &&
|
|
181
181
|
p.attributes.brand === 'Green Gardens'
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
assert.equal(greenGardensVeggies.length, 3, 'Should have all 3 Green Gardens vegetables')
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
assert.equal(greenGardensVeggies.length, 3, 'Should have all 3 Green Gardens vegetables')
|
|
185
185
|
|
|
186
186
|
// Verify they're in name order
|
|
187
|
-
const ggNames = greenGardensVeggies.map(p => p.attributes.name)
|
|
188
|
-
assert.deepEqual(ggNames, ['Asparagus', 'Broccoli', 'Zucchini'],
|
|
189
|
-
'Green Gardens vegetables should be in alphabetical order by name')
|
|
190
|
-
})
|
|
187
|
+
const ggNames = greenGardensVeggies.map(p => p.attributes.name)
|
|
188
|
+
assert.deepEqual(ggNames, ['Asparagus', 'Broccoli', 'Zucchini'],
|
|
189
|
+
'Green Gardens vegetables should be in alphabetical order by name')
|
|
190
|
+
})
|
|
191
191
|
|
|
192
192
|
it.skip('should handle cursor pagination with numeric and text fields', async () => {
|
|
193
193
|
// Sort by price (numeric), then category (text), then name (text)
|
|
@@ -197,30 +197,30 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
197
197
|
sort: ['price', 'category', 'name']
|
|
198
198
|
},
|
|
199
199
|
simplified: false
|
|
200
|
-
})
|
|
200
|
+
})
|
|
201
201
|
|
|
202
202
|
// The cheapest item is Carrot at 1.00
|
|
203
|
-
assert.equal(firstPage.data[0].attributes.name, 'Carrot')
|
|
204
|
-
assert.equal(firstPage.data[0].attributes.price, 1.00)
|
|
203
|
+
assert.equal(firstPage.data[0].attributes.name, 'Carrot')
|
|
204
|
+
assert.equal(firstPage.data[0].attributes.price, 1.00)
|
|
205
205
|
|
|
206
206
|
// Get second page
|
|
207
207
|
const secondPage = await api.resources.products.query({
|
|
208
208
|
queryParams: {
|
|
209
|
-
page: {
|
|
209
|
+
page: {
|
|
210
210
|
size: 3,
|
|
211
211
|
after: firstPage.meta.pagination.cursor.next
|
|
212
212
|
},
|
|
213
213
|
sort: ['price', 'category', 'name']
|
|
214
214
|
},
|
|
215
215
|
simplified: false
|
|
216
|
-
})
|
|
216
|
+
})
|
|
217
217
|
|
|
218
218
|
// Verify prices are non-decreasing across pages
|
|
219
|
-
const lastPriceFirstPage = firstPage.data[firstPage.data.length - 1].attributes.price
|
|
220
|
-
const firstPriceSecondPage = secondPage.data[0].attributes.price
|
|
221
|
-
assert(firstPriceSecondPage >= lastPriceFirstPage,
|
|
222
|
-
'Prices should be non-decreasing across pages')
|
|
223
|
-
})
|
|
219
|
+
const lastPriceFirstPage = firstPage.data[firstPage.data.length - 1].attributes.price
|
|
220
|
+
const firstPriceSecondPage = secondPage.data[0].attributes.price
|
|
221
|
+
assert(firstPriceSecondPage >= lastPriceFirstPage,
|
|
222
|
+
'Prices should be non-decreasing across pages')
|
|
223
|
+
})
|
|
224
224
|
|
|
225
225
|
it.skip('should generate correct cursor values for multi-field sorting', async () => {
|
|
226
226
|
const result = await api.resources.products.query({
|
|
@@ -229,20 +229,20 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
229
229
|
sort: ['category', 'name']
|
|
230
230
|
},
|
|
231
231
|
simplified: false
|
|
232
|
-
})
|
|
232
|
+
})
|
|
233
233
|
|
|
234
234
|
// Check that cursor is present
|
|
235
|
-
assert(result.meta.pagination.cursor?.next, 'Should have next cursor')
|
|
236
|
-
|
|
235
|
+
assert(result.meta.pagination.cursor?.next, 'Should have next cursor')
|
|
236
|
+
|
|
237
237
|
// The cursor should be parseable (not throwing an error)
|
|
238
|
-
const cursor = result.meta.pagination.cursor.next
|
|
239
|
-
assert(typeof cursor === 'string', 'Cursor should be a string')
|
|
240
|
-
|
|
238
|
+
const cursor = result.meta.pagination.cursor.next
|
|
239
|
+
assert(typeof cursor === 'string', 'Cursor should be a string')
|
|
240
|
+
|
|
241
241
|
// Cursor should encode multiple field values
|
|
242
242
|
// Since we're using the simplified format, it should contain field:value pairs
|
|
243
|
-
assert(cursor.includes('category:'), 'Cursor should include category field')
|
|
244
|
-
assert(cursor.includes('name:'), 'Cursor should include name field')
|
|
245
|
-
})
|
|
243
|
+
assert(cursor.includes('category:'), 'Cursor should include category field')
|
|
244
|
+
assert(cursor.includes('name:'), 'Cursor should include name field')
|
|
245
|
+
})
|
|
246
246
|
|
|
247
247
|
it.skip('should handle page[before] with multi-field sorting', async () => {
|
|
248
248
|
// First, get to the middle of the dataset
|
|
@@ -252,45 +252,45 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
252
252
|
sort: ['category', 'name']
|
|
253
253
|
},
|
|
254
254
|
simplified: false
|
|
255
|
-
})
|
|
255
|
+
})
|
|
256
256
|
|
|
257
257
|
const secondPage = await api.resources.products.query({
|
|
258
258
|
queryParams: {
|
|
259
|
-
page: {
|
|
259
|
+
page: {
|
|
260
260
|
size: 4,
|
|
261
261
|
after: firstPage.meta.pagination.cursor.next
|
|
262
262
|
},
|
|
263
263
|
sort: ['category', 'name']
|
|
264
264
|
},
|
|
265
265
|
simplified: false
|
|
266
|
-
})
|
|
266
|
+
})
|
|
267
267
|
|
|
268
268
|
// Now go backwards from the second page
|
|
269
269
|
const previousPage = await api.resources.products.query({
|
|
270
270
|
queryParams: {
|
|
271
|
-
page: {
|
|
271
|
+
page: {
|
|
272
272
|
size: 4,
|
|
273
|
-
before: secondPage.meta.pagination.cursor?.next ||
|
|
273
|
+
before: secondPage.meta.pagination.cursor?.next ||
|
|
274
274
|
// If no next cursor, create one from the last record
|
|
275
275
|
`category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)}`
|
|
276
276
|
},
|
|
277
277
|
sort: ['category', 'name']
|
|
278
278
|
},
|
|
279
279
|
simplified: false
|
|
280
|
-
})
|
|
280
|
+
})
|
|
281
281
|
|
|
282
282
|
// Verify we got different records going backwards
|
|
283
|
-
const secondPageIds = secondPage.data.map(p => p.id)
|
|
284
|
-
const previousPageIds = previousPage.data.map(p => p.id)
|
|
285
|
-
assert(!secondPageIds.some(id => previousPageIds.includes(id)),
|
|
286
|
-
'Previous page should have different records than second page')
|
|
287
|
-
})
|
|
288
|
-
})
|
|
283
|
+
const secondPageIds = secondPage.data.map(p => p.id)
|
|
284
|
+
const previousPageIds = previousPage.data.map(p => p.id)
|
|
285
|
+
assert(!secondPageIds.some(id => previousPageIds.includes(id)),
|
|
286
|
+
'Previous page should have different records than second page')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
289
|
|
|
290
290
|
describe('Edge Cases and Special Scenarios', () => {
|
|
291
291
|
beforeEach(async () => {
|
|
292
|
-
await cleanTables(knex, ['cursor_products', 'cursor_items'])
|
|
293
|
-
})
|
|
292
|
+
await cleanTables(knex, ['cursor_products', 'cursor_items'])
|
|
293
|
+
})
|
|
294
294
|
|
|
295
295
|
it.skip('should handle all records having the same value in first sort field', async () => {
|
|
296
296
|
// Create products all in the same category
|
|
@@ -300,53 +300,53 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
300
300
|
{ name: 'Gamma', category: 'Electronics', brand: 'GadgetInc', price: 150, sku: 'GI-001' },
|
|
301
301
|
{ name: 'Delta', category: 'Electronics', brand: 'GadgetInc', price: 250, sku: 'GI-002' },
|
|
302
302
|
{ name: 'Epsilon', category: 'Electronics', brand: 'TechCo', price: 300, sku: 'TC-003' }
|
|
303
|
-
]
|
|
303
|
+
]
|
|
304
304
|
|
|
305
305
|
for (const product of products) {
|
|
306
306
|
await api.resources.products.post({
|
|
307
307
|
inputRecord: createJsonApiDocument('products', product),
|
|
308
308
|
simplified: false
|
|
309
|
-
})
|
|
309
|
+
})
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
// Sort by category (all same), then brand, then name
|
|
313
|
-
const pageSize = 2
|
|
314
|
-
const allProducts = []
|
|
315
|
-
let cursor = null
|
|
313
|
+
const pageSize = 2
|
|
314
|
+
const allProducts = []
|
|
315
|
+
let cursor = null
|
|
316
316
|
|
|
317
317
|
while (true) {
|
|
318
318
|
const result = await api.resources.products.query({
|
|
319
319
|
queryParams: {
|
|
320
|
-
page: {
|
|
320
|
+
page: {
|
|
321
321
|
size: pageSize,
|
|
322
322
|
...(cursor && { after: cursor })
|
|
323
323
|
},
|
|
324
324
|
sort: ['category', 'brand', 'name']
|
|
325
325
|
},
|
|
326
326
|
simplified: false
|
|
327
|
-
})
|
|
327
|
+
})
|
|
328
328
|
|
|
329
|
-
allProducts.push(...result.data)
|
|
329
|
+
allProducts.push(...result.data)
|
|
330
330
|
|
|
331
331
|
if (!result.meta.pagination.hasMore) {
|
|
332
|
-
break
|
|
332
|
+
break
|
|
333
333
|
}
|
|
334
|
-
cursor = result.meta.pagination.cursor.next
|
|
334
|
+
cursor = result.meta.pagination.cursor.next
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
assert.equal(allProducts.length, 5, 'Should get all 5 products')
|
|
338
|
-
|
|
337
|
+
assert.equal(allProducts.length, 5, 'Should get all 5 products')
|
|
338
|
+
|
|
339
339
|
// Check order: should be grouped by brand, then by name
|
|
340
340
|
const productInfo = allProducts.map(p => ({
|
|
341
341
|
brand: p.attributes.brand,
|
|
342
342
|
name: p.attributes.name
|
|
343
|
-
}))
|
|
344
|
-
|
|
343
|
+
}))
|
|
344
|
+
|
|
345
345
|
// GadgetInc should come before TechCo
|
|
346
|
-
const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc')
|
|
347
|
-
const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo')
|
|
348
|
-
assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
|
|
349
|
-
})
|
|
346
|
+
const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc')
|
|
347
|
+
const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo')
|
|
348
|
+
assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
|
|
349
|
+
})
|
|
350
350
|
|
|
351
351
|
it.skip('should handle empty results with multi-field cursor', async () => {
|
|
352
352
|
// Create one product
|
|
@@ -359,7 +359,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
359
359
|
sku: 'GEN-001'
|
|
360
360
|
}),
|
|
361
361
|
simplified: false
|
|
362
|
-
})
|
|
362
|
+
})
|
|
363
363
|
|
|
364
364
|
// Get the first (and only) page
|
|
365
365
|
const firstPage = await api.resources.products.query({
|
|
@@ -368,12 +368,12 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
368
368
|
sort: ['category', 'name']
|
|
369
369
|
},
|
|
370
370
|
simplified: false
|
|
371
|
-
})
|
|
371
|
+
})
|
|
372
372
|
|
|
373
|
-
assert.equal(firstPage.data.length, 1)
|
|
374
|
-
assert.equal(firstPage.meta.pagination.hasMore, false)
|
|
375
|
-
assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
|
|
376
|
-
})
|
|
373
|
+
assert.equal(firstPage.data.length, 1)
|
|
374
|
+
assert.equal(firstPage.meta.pagination.hasMore, false)
|
|
375
|
+
assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
|
|
376
|
+
})
|
|
377
377
|
|
|
378
378
|
it.skip('should handle special characters in multi-field cursors', async () => {
|
|
379
379
|
// Create products with special characters
|
|
@@ -381,13 +381,13 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
381
381
|
{ name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
|
|
382
382
|
{ name: 'Product: Extra', category: 'Cat:1', brand: 'Brand,B', price: 20, sku: 'SP-002' },
|
|
383
383
|
{ name: 'Product: Normal', category: 'Cat:2', brand: 'Brand,A', price: 15, sku: 'SP-003' }
|
|
384
|
-
]
|
|
384
|
+
]
|
|
385
385
|
|
|
386
386
|
for (const product of products) {
|
|
387
387
|
await api.resources.products.post({
|
|
388
388
|
inputRecord: createJsonApiDocument('products', product),
|
|
389
389
|
simplified: false
|
|
390
|
-
})
|
|
390
|
+
})
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
// Navigate through pages
|
|
@@ -397,26 +397,26 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
397
397
|
sort: ['category', 'brand', 'name']
|
|
398
398
|
},
|
|
399
399
|
simplified: false
|
|
400
|
-
})
|
|
400
|
+
})
|
|
401
401
|
|
|
402
|
-
assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor')
|
|
402
|
+
assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor')
|
|
403
403
|
|
|
404
404
|
// Use the cursor to get next page - should not throw
|
|
405
405
|
const secondPage = await api.resources.products.query({
|
|
406
406
|
queryParams: {
|
|
407
|
-
page: {
|
|
407
|
+
page: {
|
|
408
408
|
size: 1,
|
|
409
409
|
after: firstPage.meta.pagination.cursor.next
|
|
410
410
|
},
|
|
411
411
|
sort: ['category', 'brand', 'name']
|
|
412
412
|
},
|
|
413
413
|
simplified: false
|
|
414
|
-
})
|
|
414
|
+
})
|
|
415
415
|
|
|
416
|
-
assert.equal(secondPage.data.length, 1)
|
|
417
|
-
assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
|
|
418
|
-
'Should get different record on second page')
|
|
419
|
-
})
|
|
416
|
+
assert.equal(secondPage.data.length, 1)
|
|
417
|
+
assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
|
|
418
|
+
'Should get different record on second page')
|
|
419
|
+
})
|
|
420
420
|
|
|
421
421
|
it.skip('should maintain stable sorting with custom id property', async () => {
|
|
422
422
|
// Create items using the resource with custom ID property
|
|
@@ -425,13 +425,13 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
425
425
|
{ code: 'A002', name: 'Apricot', category: 'Fruit', type: 'Fresh' },
|
|
426
426
|
{ code: 'B001', name: 'Banana', category: 'Fruit', type: 'Fresh' },
|
|
427
427
|
{ code: 'C001', name: 'Carrot', category: 'Vegetable', type: 'Root' }
|
|
428
|
-
]
|
|
428
|
+
]
|
|
429
429
|
|
|
430
430
|
for (const item of items) {
|
|
431
431
|
await api.resources.items.post({
|
|
432
432
|
inputRecord: createJsonApiDocument('items', item),
|
|
433
433
|
simplified: false
|
|
434
|
-
})
|
|
434
|
+
})
|
|
435
435
|
}
|
|
436
436
|
|
|
437
437
|
// Query with multi-field sort
|
|
@@ -441,46 +441,46 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
441
441
|
sort: ['category', 'name']
|
|
442
442
|
},
|
|
443
443
|
simplified: false
|
|
444
|
-
})
|
|
444
|
+
})
|
|
445
445
|
|
|
446
446
|
// Should use 'item_id' as the ID field in responses
|
|
447
|
-
assert(result.data[0].id, 'Should have id field')
|
|
448
|
-
assert(typeof result.data[0].id === 'string', 'ID should be a string')
|
|
447
|
+
assert(result.data[0].id, 'Should have id field')
|
|
448
|
+
assert(typeof result.data[0].id === 'string', 'ID should be a string')
|
|
449
449
|
|
|
450
450
|
// Cursor should work with custom ID
|
|
451
451
|
if (result.meta.pagination.cursor?.next) {
|
|
452
452
|
const secondPage = await api.resources.items.query({
|
|
453
453
|
queryParams: {
|
|
454
|
-
page: {
|
|
454
|
+
page: {
|
|
455
455
|
size: 2,
|
|
456
456
|
after: result.meta.pagination.cursor.next
|
|
457
457
|
},
|
|
458
458
|
sort: ['category', 'name']
|
|
459
459
|
},
|
|
460
460
|
simplified: false
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
assert(secondPage.data.length > 0, 'Should get results on second page')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
assert(secondPage.data.length > 0, 'Should get results on second page')
|
|
464
464
|
}
|
|
465
|
-
})
|
|
465
|
+
})
|
|
466
466
|
|
|
467
467
|
it.skip('should handle the exact bug scenario - multiple fruits with same category', async () => {
|
|
468
468
|
// This is the exact scenario from the bug report
|
|
469
|
-
await cleanTables(knex, ['cursor_products'])
|
|
470
|
-
|
|
469
|
+
await cleanTables(knex, ['cursor_products'])
|
|
470
|
+
|
|
471
471
|
const products = [
|
|
472
472
|
{ name: 'Apple', category: 'Fruit', brand: 'Farm Fresh', price: 2.00, sku: 'FRUIT-001' },
|
|
473
473
|
{ name: 'Banana', category: 'Fruit', brand: 'Farm Fresh', price: 1.50, sku: 'FRUIT-002' },
|
|
474
474
|
{ name: 'Cherry', category: 'Fruit', brand: 'Farm Fresh', price: 3.00, sku: 'FRUIT-003' },
|
|
475
475
|
{ name: 'Date', category: 'Fruit', brand: 'Desert Farm', price: 4.00, sku: 'FRUIT-004' },
|
|
476
476
|
{ name: 'Asparagus', category: 'Vegetable', brand: 'Green Farm', price: 2.50, sku: 'VEG-001' }
|
|
477
|
-
]
|
|
477
|
+
]
|
|
478
478
|
|
|
479
479
|
for (const product of products) {
|
|
480
480
|
await api.resources.products.post({
|
|
481
481
|
inputRecord: createJsonApiDocument('products', product),
|
|
482
482
|
simplified: false
|
|
483
|
-
})
|
|
483
|
+
})
|
|
484
484
|
}
|
|
485
485
|
|
|
486
486
|
// Get first page with size 3 (should get Apple, Banana, Cherry)
|
|
@@ -490,29 +490,29 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
490
490
|
sort: ['category', 'name']
|
|
491
491
|
},
|
|
492
492
|
simplified: false
|
|
493
|
-
})
|
|
493
|
+
})
|
|
494
494
|
|
|
495
|
-
assert.equal(firstPage.data.length, 3)
|
|
496
|
-
const firstPageNames = firstPage.data.map(p => p.attributes.name)
|
|
497
|
-
assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry'])
|
|
495
|
+
assert.equal(firstPage.data.length, 3)
|
|
496
|
+
const firstPageNames = firstPage.data.map(p => p.attributes.name)
|
|
497
|
+
assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry'])
|
|
498
498
|
|
|
499
499
|
// Get second page - MUST include Date (the bug would skip it)
|
|
500
500
|
const secondPage = await api.resources.products.query({
|
|
501
501
|
queryParams: {
|
|
502
|
-
page: {
|
|
502
|
+
page: {
|
|
503
503
|
size: 3,
|
|
504
504
|
after: firstPage.meta.pagination.cursor.next
|
|
505
505
|
},
|
|
506
506
|
sort: ['category', 'name']
|
|
507
507
|
},
|
|
508
508
|
simplified: false
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
const secondPageNames = secondPage.data.map(p => p.attributes.name)
|
|
512
|
-
assert(secondPageNames.includes('Date'),
|
|
513
|
-
'Second page MUST include Date - this was the bug where it would be skipped')
|
|
514
|
-
assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
|
|
515
|
-
'Second page should have Date and Asparagus in that order')
|
|
516
|
-
})
|
|
517
|
-
})
|
|
518
|
-
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const secondPageNames = secondPage.data.map(p => p.attributes.name)
|
|
512
|
+
assert(secondPageNames.includes('Date'),
|
|
513
|
+
'Second page MUST include Date - this was the bug where it would be skipped')
|
|
514
|
+
assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
|
|
515
|
+
'Second page should have Date and Asparagus in that order')
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
})
|