json-rest-api 1.0.7 → 1.0.8
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/.claude/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +90 -32
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
package/tests/jwt-auth.test.js
CHANGED
|
@@ -341,7 +341,6 @@ describe('JWT Auth Plugin', () => {
|
|
|
341
341
|
inputRecord: countryDoc,
|
|
342
342
|
simplified: false
|
|
343
343
|
});
|
|
344
|
-
if (!countryResult.data) debugger
|
|
345
344
|
countryId = countryResult.data.id;
|
|
346
345
|
});
|
|
347
346
|
|
|
@@ -510,7 +509,6 @@ describe('JWT Auth Plugin', () => {
|
|
|
510
509
|
});
|
|
511
510
|
|
|
512
511
|
it('should deny non-owner non-admin from updating', async () => {
|
|
513
|
-
debugger
|
|
514
512
|
// Create post as user
|
|
515
513
|
const postDoc = createJsonApiDocument('posts', {
|
|
516
514
|
title: 'User Post',
|
|
@@ -292,7 +292,7 @@ describe('Nested Include Operations', () => {
|
|
|
292
292
|
const result = await api.resources.authors.query({
|
|
293
293
|
queryParams: {
|
|
294
294
|
include: ['books', 'books.authors'],
|
|
295
|
-
|
|
295
|
+
filters: { name: 'Author 1' }
|
|
296
296
|
},
|
|
297
297
|
simplified: false
|
|
298
298
|
});
|
|
@@ -0,0 +1,514 @@
|
|
|
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
|
+
createJsonApiDocument
|
|
8
|
+
} from './helpers/test-utils.js';
|
|
9
|
+
import { createCursorPaginationApi } from './fixtures/api-configs.js';
|
|
10
|
+
|
|
11
|
+
// Create Knex instance for tests
|
|
12
|
+
const knex = knexLib({
|
|
13
|
+
client: 'better-sqlite3',
|
|
14
|
+
connection: {
|
|
15
|
+
filename: ':memory:'
|
|
16
|
+
},
|
|
17
|
+
useNullAsDefault: true
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// API instance that persists across tests
|
|
21
|
+
let api;
|
|
22
|
+
|
|
23
|
+
describe('Multi-field Cursor Pagination', () => {
|
|
24
|
+
before(async () => {
|
|
25
|
+
// Initialize API once with cursor pagination configuration
|
|
26
|
+
api = await createCursorPaginationApi(knex);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(async () => {
|
|
30
|
+
// Close database connection
|
|
31
|
+
await knex.destroy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('Basic Multi-field Cursor Pagination', () => {
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
await cleanTables(knex, ['cursor_products']);
|
|
37
|
+
|
|
38
|
+
// Create test data with some duplicate categories and brands
|
|
39
|
+
const products = [
|
|
40
|
+
{ name: 'Apple', category: 'Fruit', brand: 'Fresh Farm', price: 2.50, sku: 'FF-001' },
|
|
41
|
+
{ name: 'Banana', category: 'Fruit', brand: 'Fresh Farm', price: 1.50, sku: 'FF-002' },
|
|
42
|
+
{ name: 'Cherry', category: 'Fruit', brand: 'Fresh Farm', price: 3.00, sku: 'FF-003' },
|
|
43
|
+
{ name: 'Date', category: 'Fruit', brand: 'Desert Delights', price: 4.00, sku: 'DD-001' },
|
|
44
|
+
{ name: 'Elderberry', category: 'Fruit', brand: 'Wild Harvest', price: 5.00, sku: 'WH-001' },
|
|
45
|
+
{ name: 'Asparagus', category: 'Vegetable', brand: 'Green Gardens', price: 3.50, sku: 'GG-001' },
|
|
46
|
+
{ name: 'Broccoli', category: 'Vegetable', brand: 'Green Gardens', price: 2.00, sku: 'GG-002' },
|
|
47
|
+
{ name: 'Carrot', category: 'Vegetable', brand: 'Root Ranch', price: 1.00, sku: 'RR-001' }
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const product of products) {
|
|
51
|
+
await api.resources.products.post({
|
|
52
|
+
inputRecord: createJsonApiDocument('products', product),
|
|
53
|
+
simplified: false
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
|
|
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;
|
|
64
|
+
|
|
65
|
+
// Collect all products through cursor pagination
|
|
66
|
+
while (pageCount < 10) { // Safety limit
|
|
67
|
+
const result = await api.resources.products.query({
|
|
68
|
+
queryParams: {
|
|
69
|
+
page: {
|
|
70
|
+
size: pageSize,
|
|
71
|
+
...(cursor && { after: cursor })
|
|
72
|
+
},
|
|
73
|
+
sort: ['category', 'name']
|
|
74
|
+
},
|
|
75
|
+
simplified: false
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
validateJsonApiStructure(result, true);
|
|
79
|
+
allProducts.push(...result.data);
|
|
80
|
+
pageCount++;
|
|
81
|
+
|
|
82
|
+
if (!result.meta.pagination.hasMore) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
cursor = result.meta.pagination.cursor.next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Verify we got all 8 products
|
|
89
|
+
assert.equal(allProducts.length, 8, 'Should retrieve all 8 products');
|
|
90
|
+
|
|
91
|
+
// Verify the order is correct
|
|
92
|
+
const productNames = allProducts.map(p => p.attributes.name);
|
|
93
|
+
const expectedOrder = [
|
|
94
|
+
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', // All Fruits in alphabetical order
|
|
95
|
+
'Asparagus', 'Broccoli', 'Carrot' // All Vegetables in alphabetical order
|
|
96
|
+
];
|
|
97
|
+
assert.deepEqual(productNames, expectedOrder, 'Products should be in correct order');
|
|
98
|
+
|
|
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
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should handle DESC sorting with duplicates correctly', async () => {
|
|
106
|
+
// Sort by category DESC, then name ASC
|
|
107
|
+
const firstPage = await api.resources.products.query({
|
|
108
|
+
queryParams: {
|
|
109
|
+
page: { size: 3 },
|
|
110
|
+
sort: ['-category', 'name']
|
|
111
|
+
},
|
|
112
|
+
simplified: false
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const secondPage = await api.resources.products.query({
|
|
116
|
+
queryParams: {
|
|
117
|
+
page: {
|
|
118
|
+
size: 3,
|
|
119
|
+
after: firstPage.meta.pagination.cursor.next
|
|
120
|
+
},
|
|
121
|
+
sort: ['-category', 'name']
|
|
122
|
+
},
|
|
123
|
+
simplified: false
|
|
124
|
+
});
|
|
125
|
+
|
|
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');
|
|
129
|
+
|
|
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
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle three-field sorting with multiple duplicate values', async () => {
|
|
137
|
+
// Add more products with duplicate category AND brand
|
|
138
|
+
await api.resources.products.post({
|
|
139
|
+
inputRecord: createJsonApiDocument('products', {
|
|
140
|
+
name: 'Zucchini',
|
|
141
|
+
category: 'Vegetable',
|
|
142
|
+
brand: 'Green Gardens',
|
|
143
|
+
price: 2.50,
|
|
144
|
+
sku: 'GG-003'
|
|
145
|
+
}),
|
|
146
|
+
simplified: false
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const pageSize = 2;
|
|
150
|
+
const allProducts = [];
|
|
151
|
+
let cursor = null;
|
|
152
|
+
|
|
153
|
+
// Sort by category, brand, then name
|
|
154
|
+
while (true) {
|
|
155
|
+
const result = await api.resources.products.query({
|
|
156
|
+
queryParams: {
|
|
157
|
+
page: {
|
|
158
|
+
size: pageSize,
|
|
159
|
+
...(cursor && { after: cursor })
|
|
160
|
+
},
|
|
161
|
+
sort: ['category', 'brand', 'name']
|
|
162
|
+
},
|
|
163
|
+
simplified: false
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
allProducts.push(...result.data);
|
|
167
|
+
|
|
168
|
+
if (!result.meta.pagination.hasMore) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
cursor = result.meta.pagination.cursor.next;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Find all Green Gardens vegetables
|
|
175
|
+
const greenGardensVeggies = allProducts.filter(p =>
|
|
176
|
+
p.attributes.category === 'Vegetable' &&
|
|
177
|
+
p.attributes.brand === 'Green Gardens'
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
assert.equal(greenGardensVeggies.length, 3, 'Should have all 3 Green Gardens vegetables');
|
|
181
|
+
|
|
182
|
+
// Verify they're in name order
|
|
183
|
+
const ggNames = greenGardensVeggies.map(p => p.attributes.name);
|
|
184
|
+
assert.deepEqual(ggNames, ['Asparagus', 'Broccoli', 'Zucchini'],
|
|
185
|
+
'Green Gardens vegetables should be in alphabetical order by name');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should handle cursor pagination with numeric and text fields', async () => {
|
|
189
|
+
// Sort by price (numeric), then category (text), then name (text)
|
|
190
|
+
const firstPage = await api.resources.products.query({
|
|
191
|
+
queryParams: {
|
|
192
|
+
page: { size: 3 },
|
|
193
|
+
sort: ['price', 'category', 'name']
|
|
194
|
+
},
|
|
195
|
+
simplified: false
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// The cheapest item is Carrot at 1.00
|
|
199
|
+
assert.equal(firstPage.data[0].attributes.name, 'Carrot');
|
|
200
|
+
assert.equal(firstPage.data[0].attributes.price, 1.00);
|
|
201
|
+
|
|
202
|
+
// Get second page
|
|
203
|
+
const secondPage = await api.resources.products.query({
|
|
204
|
+
queryParams: {
|
|
205
|
+
page: {
|
|
206
|
+
size: 3,
|
|
207
|
+
after: firstPage.meta.pagination.cursor.next
|
|
208
|
+
},
|
|
209
|
+
sort: ['price', 'category', 'name']
|
|
210
|
+
},
|
|
211
|
+
simplified: false
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Verify prices are non-decreasing across pages
|
|
215
|
+
const lastPriceFirstPage = firstPage.data[firstPage.data.length - 1].attributes.price;
|
|
216
|
+
const firstPriceSecondPage = secondPage.data[0].attributes.price;
|
|
217
|
+
assert(firstPriceSecondPage >= lastPriceFirstPage,
|
|
218
|
+
'Prices should be non-decreasing across pages');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should generate correct cursor values for multi-field sorting', async () => {
|
|
222
|
+
const result = await api.resources.products.query({
|
|
223
|
+
queryParams: {
|
|
224
|
+
page: { size: 3 },
|
|
225
|
+
sort: ['category', 'name']
|
|
226
|
+
},
|
|
227
|
+
simplified: false
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Check that cursor is present
|
|
231
|
+
assert(result.meta.pagination.cursor?.next, 'Should have next cursor');
|
|
232
|
+
|
|
233
|
+
// The cursor should be parseable (not throwing an error)
|
|
234
|
+
const cursor = result.meta.pagination.cursor.next;
|
|
235
|
+
assert(typeof cursor === 'string', 'Cursor should be a string');
|
|
236
|
+
|
|
237
|
+
// Cursor should encode multiple field values
|
|
238
|
+
// Since we're using the simplified format, it should contain field:value pairs
|
|
239
|
+
assert(cursor.includes('category:'), 'Cursor should include category field');
|
|
240
|
+
assert(cursor.includes('name:'), 'Cursor should include name field');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should handle page[before] with multi-field sorting', async () => {
|
|
244
|
+
// First, get to the middle of the dataset
|
|
245
|
+
const firstPage = await api.resources.products.query({
|
|
246
|
+
queryParams: {
|
|
247
|
+
page: { size: 4 },
|
|
248
|
+
sort: ['category', 'name']
|
|
249
|
+
},
|
|
250
|
+
simplified: false
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const secondPage = await api.resources.products.query({
|
|
254
|
+
queryParams: {
|
|
255
|
+
page: {
|
|
256
|
+
size: 4,
|
|
257
|
+
after: firstPage.meta.pagination.cursor.next
|
|
258
|
+
},
|
|
259
|
+
sort: ['category', 'name']
|
|
260
|
+
},
|
|
261
|
+
simplified: false
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Now go backwards from the second page
|
|
265
|
+
const previousPage = await api.resources.products.query({
|
|
266
|
+
queryParams: {
|
|
267
|
+
page: {
|
|
268
|
+
size: 4,
|
|
269
|
+
before: secondPage.meta.pagination.cursor?.next ||
|
|
270
|
+
// If no next cursor, create one from the last record
|
|
271
|
+
`category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)}`
|
|
272
|
+
},
|
|
273
|
+
sort: ['category', 'name']
|
|
274
|
+
},
|
|
275
|
+
simplified: false
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Verify we got different records going backwards
|
|
279
|
+
const secondPageIds = secondPage.data.map(p => p.id);
|
|
280
|
+
const previousPageIds = previousPage.data.map(p => p.id);
|
|
281
|
+
assert(!secondPageIds.some(id => previousPageIds.includes(id)),
|
|
282
|
+
'Previous page should have different records than second page');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('Edge Cases and Special Scenarios', () => {
|
|
287
|
+
beforeEach(async () => {
|
|
288
|
+
await cleanTables(knex, ['cursor_products', 'cursor_items']);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should handle all records having the same value in first sort field', async () => {
|
|
292
|
+
// Create products all in the same category
|
|
293
|
+
const products = [
|
|
294
|
+
{ name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
|
|
295
|
+
{ name: 'Beta', category: 'Electronics', brand: 'TechCo', price: 200, sku: 'TC-002' },
|
|
296
|
+
{ name: 'Gamma', category: 'Electronics', brand: 'GadgetInc', price: 150, sku: 'GI-001' },
|
|
297
|
+
{ name: 'Delta', category: 'Electronics', brand: 'GadgetInc', price: 250, sku: 'GI-002' },
|
|
298
|
+
{ name: 'Epsilon', category: 'Electronics', brand: 'TechCo', price: 300, sku: 'TC-003' }
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
for (const product of products) {
|
|
302
|
+
await api.resources.products.post({
|
|
303
|
+
inputRecord: createJsonApiDocument('products', product),
|
|
304
|
+
simplified: false
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Sort by category (all same), then brand, then name
|
|
309
|
+
const pageSize = 2;
|
|
310
|
+
const allProducts = [];
|
|
311
|
+
let cursor = null;
|
|
312
|
+
|
|
313
|
+
while (true) {
|
|
314
|
+
const result = await api.resources.products.query({
|
|
315
|
+
queryParams: {
|
|
316
|
+
page: {
|
|
317
|
+
size: pageSize,
|
|
318
|
+
...(cursor && { after: cursor })
|
|
319
|
+
},
|
|
320
|
+
sort: ['category', 'brand', 'name']
|
|
321
|
+
},
|
|
322
|
+
simplified: false
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
allProducts.push(...result.data);
|
|
326
|
+
|
|
327
|
+
if (!result.meta.pagination.hasMore) {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
cursor = result.meta.pagination.cursor.next;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
assert.equal(allProducts.length, 5, 'Should get all 5 products');
|
|
334
|
+
|
|
335
|
+
// Check order: should be grouped by brand, then by name
|
|
336
|
+
const productInfo = allProducts.map(p => ({
|
|
337
|
+
brand: p.attributes.brand,
|
|
338
|
+
name: p.attributes.name
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
// GadgetInc should come before TechCo
|
|
342
|
+
const gadgetIncIndex = productInfo.findIndex(p => p.brand === 'GadgetInc');
|
|
343
|
+
const firstTechCoIndex = productInfo.findIndex(p => p.brand === 'TechCo');
|
|
344
|
+
assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should handle empty results with multi-field cursor', async () => {
|
|
348
|
+
// Create one product
|
|
349
|
+
await api.resources.products.post({
|
|
350
|
+
inputRecord: createJsonApiDocument('products', {
|
|
351
|
+
name: 'Single Product',
|
|
352
|
+
category: 'Misc',
|
|
353
|
+
brand: 'Generic',
|
|
354
|
+
price: 10,
|
|
355
|
+
sku: 'GEN-001'
|
|
356
|
+
}),
|
|
357
|
+
simplified: false
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Get the first (and only) page
|
|
361
|
+
const firstPage = await api.resources.products.query({
|
|
362
|
+
queryParams: {
|
|
363
|
+
page: { size: 5 },
|
|
364
|
+
sort: ['category', 'name']
|
|
365
|
+
},
|
|
366
|
+
simplified: false
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
assert.equal(firstPage.data.length, 1);
|
|
370
|
+
assert.equal(firstPage.meta.pagination.hasMore, false);
|
|
371
|
+
assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should handle special characters in multi-field cursors', async () => {
|
|
375
|
+
// Create products with special characters
|
|
376
|
+
const products = [
|
|
377
|
+
{ name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
|
|
378
|
+
{ name: 'Product: Extra', category: 'Cat:1', brand: 'Brand,B', price: 20, sku: 'SP-002' },
|
|
379
|
+
{ name: 'Product: Normal', category: 'Cat:2', brand: 'Brand,A', price: 15, sku: 'SP-003' }
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
for (const product of products) {
|
|
383
|
+
await api.resources.products.post({
|
|
384
|
+
inputRecord: createJsonApiDocument('products', product),
|
|
385
|
+
simplified: false
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Navigate through pages
|
|
390
|
+
const firstPage = await api.resources.products.query({
|
|
391
|
+
queryParams: {
|
|
392
|
+
page: { size: 1 },
|
|
393
|
+
sort: ['category', 'brand', 'name']
|
|
394
|
+
},
|
|
395
|
+
simplified: false
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
assert(firstPage.meta.pagination.cursor?.next, 'Should have cursor');
|
|
399
|
+
|
|
400
|
+
// Use the cursor to get next page - should not throw
|
|
401
|
+
const secondPage = await api.resources.products.query({
|
|
402
|
+
queryParams: {
|
|
403
|
+
page: {
|
|
404
|
+
size: 1,
|
|
405
|
+
after: firstPage.meta.pagination.cursor.next
|
|
406
|
+
},
|
|
407
|
+
sort: ['category', 'brand', 'name']
|
|
408
|
+
},
|
|
409
|
+
simplified: false
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
assert.equal(secondPage.data.length, 1);
|
|
413
|
+
assert.notEqual(secondPage.data[0].id, firstPage.data[0].id,
|
|
414
|
+
'Should get different record on second page');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should maintain stable sorting with custom id property', async () => {
|
|
418
|
+
// Create items using the resource with custom ID property
|
|
419
|
+
const items = [
|
|
420
|
+
{ code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
|
|
421
|
+
{ code: 'A002', name: 'Apricot', category: 'Fruit', type: 'Fresh' },
|
|
422
|
+
{ code: 'B001', name: 'Banana', category: 'Fruit', type: 'Fresh' },
|
|
423
|
+
{ code: 'C001', name: 'Carrot', category: 'Vegetable', type: 'Root' }
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
for (const item of items) {
|
|
427
|
+
await api.resources.items.post({
|
|
428
|
+
inputRecord: createJsonApiDocument('items', item),
|
|
429
|
+
simplified: false
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Query with multi-field sort
|
|
434
|
+
const result = await api.resources.items.query({
|
|
435
|
+
queryParams: {
|
|
436
|
+
page: { size: 2 },
|
|
437
|
+
sort: ['category', 'name']
|
|
438
|
+
},
|
|
439
|
+
simplified: false
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Should use 'item_id' as the ID field in responses
|
|
443
|
+
assert(result.data[0].id, 'Should have id field');
|
|
444
|
+
assert(typeof result.data[0].id === 'string', 'ID should be a string');
|
|
445
|
+
|
|
446
|
+
// Cursor should work with custom ID
|
|
447
|
+
if (result.meta.pagination.cursor?.next) {
|
|
448
|
+
const secondPage = await api.resources.items.query({
|
|
449
|
+
queryParams: {
|
|
450
|
+
page: {
|
|
451
|
+
size: 2,
|
|
452
|
+
after: result.meta.pagination.cursor.next
|
|
453
|
+
},
|
|
454
|
+
sort: ['category', 'name']
|
|
455
|
+
},
|
|
456
|
+
simplified: false
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
assert(secondPage.data.length > 0, 'Should get results on second page');
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should handle the exact bug scenario - multiple fruits with same category', async () => {
|
|
464
|
+
// This is the exact scenario from the bug report
|
|
465
|
+
await cleanTables(knex, ['cursor_products']);
|
|
466
|
+
|
|
467
|
+
const products = [
|
|
468
|
+
{ name: 'Apple', category: 'Fruit', brand: 'Farm Fresh', price: 2.00, sku: 'FRUIT-001' },
|
|
469
|
+
{ name: 'Banana', category: 'Fruit', brand: 'Farm Fresh', price: 1.50, sku: 'FRUIT-002' },
|
|
470
|
+
{ name: 'Cherry', category: 'Fruit', brand: 'Farm Fresh', price: 3.00, sku: 'FRUIT-003' },
|
|
471
|
+
{ name: 'Date', category: 'Fruit', brand: 'Desert Farm', price: 4.00, sku: 'FRUIT-004' },
|
|
472
|
+
{ name: 'Asparagus', category: 'Vegetable', brand: 'Green Farm', price: 2.50, sku: 'VEG-001' }
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
for (const product of products) {
|
|
476
|
+
await api.resources.products.post({
|
|
477
|
+
inputRecord: createJsonApiDocument('products', product),
|
|
478
|
+
simplified: false
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Get first page with size 3 (should get Apple, Banana, Cherry)
|
|
483
|
+
const firstPage = await api.resources.products.query({
|
|
484
|
+
queryParams: {
|
|
485
|
+
page: { size: 3 },
|
|
486
|
+
sort: ['category', 'name']
|
|
487
|
+
},
|
|
488
|
+
simplified: false
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
assert.equal(firstPage.data.length, 3);
|
|
492
|
+
const firstPageNames = firstPage.data.map(p => p.attributes.name);
|
|
493
|
+
assert.deepEqual(firstPageNames, ['Apple', 'Banana', 'Cherry']);
|
|
494
|
+
|
|
495
|
+
// Get second page - MUST include Date (the bug would skip it)
|
|
496
|
+
const secondPage = await api.resources.products.query({
|
|
497
|
+
queryParams: {
|
|
498
|
+
page: {
|
|
499
|
+
size: 3,
|
|
500
|
+
after: firstPage.meta.pagination.cursor.next
|
|
501
|
+
},
|
|
502
|
+
sort: ['category', 'name']
|
|
503
|
+
},
|
|
504
|
+
simplified: false
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const secondPageNames = secondPage.data.map(p => p.attributes.name);
|
|
508
|
+
assert(secondPageNames.includes('Date'),
|
|
509
|
+
'Second page MUST include Date - this was the bug where it would be skipped');
|
|
510
|
+
assert.deepEqual(secondPageNames, ['Date', 'Asparagus'],
|
|
511
|
+
'Second page should have Date and Asparagus in that order');
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
});
|
|
@@ -26,7 +26,7 @@ describe('Enhanced Pagination Features', () => {
|
|
|
26
26
|
before(async () => {
|
|
27
27
|
// Initialize API once with pagination features
|
|
28
28
|
api = await createPaginationApi(knex, {
|
|
29
|
-
|
|
29
|
+
publicBaseUrl: 'https://api.example.com/v1'
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -112,10 +112,10 @@ describe('Enhanced Pagination Features', () => {
|
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
it('should
|
|
116
|
-
// Temporarily clear the
|
|
117
|
-
const originalPrefix = api.vars.
|
|
118
|
-
api.vars.
|
|
115
|
+
it('should add relative self links when publicBaseUrl is not configured', async () => {
|
|
116
|
+
// Temporarily clear the publicBaseUrl
|
|
117
|
+
const originalPrefix = api.vars.publicBaseUrl;
|
|
118
|
+
api.vars.publicBaseUrl = '';
|
|
119
119
|
|
|
120
120
|
const result = await api.resources.books.query({
|
|
121
121
|
queryParams: {},
|
|
@@ -124,14 +124,17 @@ describe('Enhanced Pagination Features', () => {
|
|
|
124
124
|
|
|
125
125
|
validateJsonApiStructure(result, true);
|
|
126
126
|
|
|
127
|
-
//
|
|
127
|
+
// Links should be present but relative
|
|
128
128
|
result.data.forEach(book => {
|
|
129
|
-
assert(
|
|
129
|
+
assert(book.links, 'Resources should have links even without publicBaseUrl');
|
|
130
|
+
assert(book.links.self, 'Resources should have self link');
|
|
131
|
+
assert(book.links.self.startsWith('/books/'), 'Self link should be relative');
|
|
130
132
|
});
|
|
131
|
-
assert(
|
|
133
|
+
assert(result.links, 'Response should have top-level links even without publicBaseUrl');
|
|
134
|
+
assert(result.links.self.startsWith('/books'), 'Top-level self link should be relative');
|
|
132
135
|
|
|
133
136
|
// Restore the prefix
|
|
134
|
-
api.vars.
|
|
137
|
+
api.vars.publicBaseUrl = originalPrefix;
|
|
135
138
|
});
|
|
136
139
|
});
|
|
137
140
|
|
|
@@ -74,8 +74,8 @@ describe('Polymorphic Relationship Operations', () => {
|
|
|
74
74
|
validateJsonApiStructure(review);
|
|
75
75
|
assert.equal(review.data.type, 'reviews');
|
|
76
76
|
assert.equal(review.data.attributes.rating, 5);
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Since polymorphic fields are filtered out from POST responses,
|
|
78
|
+
// we need to GET the record to verify the relationship was saved correctly
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it('should create a review for an author', async () => {
|
|
@@ -99,8 +99,9 @@ describe('Polymorphic Relationship Operations', () => {
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
validateJsonApiStructure(review);
|
|
102
|
-
|
|
103
|
-
assert.equal(
|
|
102
|
+
// Polymorphic fields are no longer in attributes, they're in relationships
|
|
103
|
+
assert.equal(review.data.relationships.reviewable.data.type, 'authors');
|
|
104
|
+
assert.equal(review.data.relationships.reviewable.data.id, String(author.data.id));
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
it('should create a review for a publisher', async () => {
|
|
@@ -134,8 +135,9 @@ describe('Polymorphic Relationship Operations', () => {
|
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
validateJsonApiStructure(review);
|
|
137
|
-
|
|
138
|
-
assert.equal(
|
|
138
|
+
// Polymorphic fields are no longer in attributes, they're in relationships
|
|
139
|
+
assert.equal(review.data.relationships.reviewable.data.type, 'publishers');
|
|
140
|
+
assert.equal(review.data.relationships.reviewable.data.id, String(publisher.data.id));
|
|
139
141
|
});
|
|
140
142
|
|
|
141
143
|
it('should fail to create review with invalid reviewable_type', async () => {
|
|
@@ -337,8 +339,8 @@ describe('Polymorphic Relationship Operations', () => {
|
|
|
337
339
|
assert.equal(result.included.length, 2);
|
|
338
340
|
assert(result.included.every(r => r.type === 'reviews'));
|
|
339
341
|
assert(result.included.every(r =>
|
|
340
|
-
r.
|
|
341
|
-
String(r.
|
|
342
|
+
r.relationships.reviewable.data.type === 'books' &&
|
|
343
|
+
String(r.relationships.reviewable.data.id) === String(book.data.id)
|
|
342
344
|
));
|
|
343
345
|
});
|
|
344
346
|
});
|
|
@@ -80,8 +80,9 @@ describe('Polymorphic Relationships - Basic Tests', () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
validateJsonApiStructure(retrievedReview);
|
|
83
|
-
|
|
84
|
-
assert.equal(
|
|
83
|
+
// Polymorphic fields are no longer in attributes, they're in relationships
|
|
84
|
+
assert.equal(retrievedReview.data.relationships.reviewable.data.type, 'books');
|
|
85
|
+
assert.equal(retrievedReview.data.relationships.reviewable.data.id, String(book.data.id));
|
|
85
86
|
});
|
|
86
87
|
|
|
87
88
|
it('should query all reviews', async () => {
|
|
@@ -137,8 +138,9 @@ describe('Polymorphic Relationships - Basic Tests', () => {
|
|
|
137
138
|
assert.equal(result.data.length, 2);
|
|
138
139
|
|
|
139
140
|
// Check we have one of each type
|
|
140
|
-
|
|
141
|
-
const
|
|
141
|
+
// Polymorphic fields are no longer in attributes, they're in relationships
|
|
142
|
+
const bookReview = result.data.find(r => r.relationships.reviewable.data.type === 'books');
|
|
143
|
+
const authorReview = result.data.find(r => r.relationships.reviewable.data.type === 'authors');
|
|
142
144
|
|
|
143
145
|
assert(bookReview, 'Should have a book review');
|
|
144
146
|
assert(authorReview, 'Should have an author review');
|