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.
Files changed (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +90 -32
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -0,0 +1,891 @@
1
+ import { describe, it, before, beforeEach, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import knexLib from 'knex';
4
+ import { createCustomIdPropertyApi } from './fixtures/api-configs.js';
5
+ import {
6
+ validateJsonApiStructure,
7
+ resourceIdentifier,
8
+ cleanTables,
9
+ countRecords,
10
+ createJsonApiDocument,
11
+ createRelationship,
12
+ createToManyRelationship,
13
+ assertResourceAttributes,
14
+ assertResourceRelationship
15
+ } from './helpers/test-utils.js';
16
+
17
+ // Create Knex instance for tests
18
+ const knex = knexLib({
19
+ client: 'better-sqlite3',
20
+ connection: {
21
+ filename: ':memory:'
22
+ },
23
+ useNullAsDefault: true
24
+ });
25
+
26
+ // API instance that persists across tests
27
+ let api;
28
+
29
+ describe('Custom idProperty Relationship Operations', () => {
30
+ before(async () => {
31
+ // Initialize API with custom idProperty for all resources
32
+ api = await createCustomIdPropertyApi(knex);
33
+ });
34
+
35
+ after(async () => {
36
+ await knex.destroy();
37
+ });
38
+
39
+ describe('Basic CRUD with custom idProperty', () => {
40
+ beforeEach(async () => {
41
+ await cleanTables(knex, [
42
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
43
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
44
+ ]);
45
+ });
46
+
47
+ it('should create and retrieve resources with custom ID fields', async () => {
48
+ // Create country
49
+ const countryDoc = createJsonApiDocument('countries', {
50
+ name: 'United States',
51
+ code: 'US'
52
+ });
53
+
54
+ const countryResult = await api.resources.countries.post({
55
+ inputRecord: countryDoc
56
+ });
57
+
58
+ // Verify response has 'id' not 'country_id'
59
+ assert(countryResult.data.id, 'Should have id field');
60
+ assert(!countryResult.data.attributes.country_id, 'Should not expose country_id in attributes');
61
+
62
+ // Verify database has correct column
63
+ const dbCountry = await knex('custom_id_countries').first();
64
+ assert(dbCountry.country_id, 'Database should have country_id column');
65
+ assert.equal(String(dbCountry.country_id), countryResult.data.id);
66
+
67
+ // Test GET
68
+ const getResult = await api.resources.countries.get({
69
+ id: countryResult.data.id
70
+ });
71
+
72
+ assert.equal(getResult.data.id, countryResult.data.id);
73
+ assert.equal(getResult.data.attributes.name, 'United States');
74
+ });
75
+
76
+ it('should handle PATCH updates returning full record', async () => {
77
+ const country = await api.resources.countries.post({
78
+ inputRecord: createJsonApiDocument('countries', {
79
+ name: 'United States',
80
+ code: 'US'
81
+ })
82
+ });
83
+
84
+ const patchResult = await api.resources.countries.patch({
85
+ inputRecord: {
86
+ data: {
87
+ type: 'countries',
88
+ id: country.data.id,
89
+ attributes: {
90
+ name: 'USA'
91
+ }
92
+ }
93
+ }
94
+ });
95
+
96
+ // With returnRecordApi.patch = 'full', we should get the complete record
97
+ validateJsonApiStructure(patchResult, false);
98
+ assert.equal(patchResult.data.attributes.name, 'USA');
99
+ assert.equal(patchResult.data.attributes.code, 'US'); // Original value preserved
100
+ });
101
+ });
102
+
103
+ describe('One-to-One (belongsTo) Relationships with custom idProperty', () => {
104
+ beforeEach(async () => {
105
+ await cleanTables(knex, [
106
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
107
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
108
+ ]);
109
+ });
110
+
111
+ it('should create resource with belongsTo relationship using custom IDs', async () => {
112
+ // Create a country first
113
+ const countryDoc = createJsonApiDocument('countries', {
114
+ name: 'United States',
115
+ code: 'US'
116
+ });
117
+
118
+ const countryResult = await api.resources.countries.post({
119
+ inputRecord: countryDoc
120
+ });
121
+
122
+ // Create publisher with country relationship
123
+ const publisherDoc = createJsonApiDocument('publishers',
124
+ {
125
+ name: 'Random House'
126
+ },
127
+ {
128
+ country: createRelationship(resourceIdentifier('countries', countryResult.data.id))
129
+ }
130
+ );
131
+
132
+ const publisherResult = await api.resources.publishers.post({
133
+ inputRecord: publisherDoc
134
+ });
135
+
136
+ // Verify response
137
+ validateJsonApiStructure(publisherResult, false);
138
+ assert.equal(publisherResult.data.type, 'publishers');
139
+ assertResourceRelationship(publisherResult.data, 'country',
140
+ resourceIdentifier('countries', countryResult.data.id));
141
+
142
+ // Verify database foreign key uses country_id
143
+ const dbPublisher = await knex('custom_id_publishers').first();
144
+ assert.equal(String(dbPublisher.country_id), countryResult.data.id);
145
+
146
+ // Verify through GET with include
147
+ const getResult = await api.resources.publishers.get({
148
+ id: publisherResult.data.id,
149
+ queryParams: {
150
+ include: ['country']
151
+ }
152
+ });
153
+
154
+ validateJsonApiStructure(getResult, false);
155
+ assert(getResult.included, 'Should have included data');
156
+ assert.equal(getResult.included.length, 1);
157
+ assert.equal(getResult.included[0].type, 'countries');
158
+ assert.equal(getResult.included[0].attributes.name, 'United States');
159
+ });
160
+
161
+ it('should update belongsTo relationship via PATCH', async () => {
162
+ // Create two countries
163
+ const country1 = await api.resources.countries.post({
164
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
165
+ });
166
+
167
+ const country2 = await api.resources.countries.post({
168
+ inputRecord: createJsonApiDocument('countries', { name: 'UK', code: 'GB' })
169
+ });
170
+
171
+ // Create publisher with first country
172
+ const publisher = await api.resources.publishers.post({
173
+ inputRecord: createJsonApiDocument('publishers',
174
+ { name: 'Test Publisher' },
175
+ { country: createRelationship(resourceIdentifier('countries', country1.data.id)) }
176
+ )
177
+ });
178
+
179
+ // Update to second country
180
+ const patchDoc = {
181
+ data: {
182
+ type: 'publishers',
183
+ id: publisher.data.id,
184
+ relationships: {
185
+ country: createRelationship(resourceIdentifier('countries', country2.data.id))
186
+ }
187
+ }
188
+ };
189
+
190
+ const patchResult = await api.resources.publishers.patch({
191
+ inputRecord: patchDoc
192
+ });
193
+
194
+ // Verify we get full record back
195
+ assert.equal(patchResult.data.attributes.name, 'Test Publisher');
196
+ assertResourceRelationship(patchResult.data, 'country',
197
+ resourceIdentifier('countries', country2.data.id));
198
+
199
+ // Verify update via GET with include
200
+ const updated = await api.resources.publishers.get({
201
+ id: publisher.data.id,
202
+ queryParams: { include: ['country'] }
203
+ });
204
+
205
+ assert.equal(updated.included[0].id, country2.data.id);
206
+ assert.equal(updated.included[0].attributes.name, 'UK');
207
+ });
208
+ });
209
+
210
+ describe('Many-to-Many Relationships with custom idProperty', () => {
211
+ let countryId;
212
+ let publisherId;
213
+ let author1Id;
214
+ let author2Id;
215
+ let bookId;
216
+
217
+ beforeEach(async () => {
218
+ await cleanTables(knex, [
219
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
220
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
221
+ ]);
222
+
223
+ // Setup test data
224
+ const country = await api.resources.countries.post({
225
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
226
+ });
227
+ countryId = country.data.id;
228
+
229
+ const publisher = await api.resources.publishers.post({
230
+ inputRecord: createJsonApiDocument('publishers',
231
+ { name: 'Test Publisher' },
232
+ { country: createRelationship(resourceIdentifier('countries', countryId)) }
233
+ )
234
+ });
235
+ publisherId = publisher.data.id;
236
+
237
+ const author1 = await api.resources.authors.post({
238
+ inputRecord: createJsonApiDocument('authors', { name: 'Author One' })
239
+ });
240
+ author1Id = author1.data.id;
241
+
242
+ const author2 = await api.resources.authors.post({
243
+ inputRecord: createJsonApiDocument('authors', { name: 'Author Two' })
244
+ });
245
+ author2Id = author2.data.id;
246
+ });
247
+
248
+ it('should create many-to-many relationships with custom IDs', async () => {
249
+ // Create book with multiple authors
250
+ const bookDoc = createJsonApiDocument('books',
251
+ { title: 'Test Book' },
252
+ {
253
+ country: createRelationship(resourceIdentifier('countries', countryId)),
254
+ publisher: createRelationship(resourceIdentifier('publishers', publisherId)),
255
+ authors: createToManyRelationship([
256
+ resourceIdentifier('authors', author1Id),
257
+ resourceIdentifier('authors', author2Id)
258
+ ])
259
+ }
260
+ );
261
+
262
+ const bookResult = await api.resources.books.post({
263
+ inputRecord: bookDoc
264
+ });
265
+
266
+ bookId = bookResult.data.id;
267
+
268
+ // Verify pivot table has correct foreign keys
269
+ const pivotRecords = await knex('custom_id_book_authors').select('*');
270
+ assert.equal(pivotRecords.length, 2);
271
+
272
+ // Check that book_id and author_id columns contain the right values
273
+ const bookIds = pivotRecords.map(r => r.book_id);
274
+ const authorIds = pivotRecords.map(r => r.author_id).sort();
275
+
276
+ assert(bookIds.every(id => String(id) === bookId));
277
+ assert.deepEqual(authorIds.map(String).sort(), [author1Id, author2Id].sort());
278
+
279
+ // Verify through GET with include
280
+ const getResult = await api.resources.books.get({
281
+ id: bookId,
282
+ queryParams: { include: ['authors'] }
283
+ });
284
+
285
+ assert(getResult.included);
286
+ assert.equal(getResult.included.length, 2);
287
+ const includedNames = getResult.included.map(a => a.attributes.name).sort();
288
+ assert.deepEqual(includedNames, ['Author One', 'Author Two']);
289
+ });
290
+
291
+ it('should handle PATCH updates to many-to-many relationships', async () => {
292
+ // Create book with one author
293
+ const book = await api.resources.books.post({
294
+ inputRecord: createJsonApiDocument('books',
295
+ { title: 'Test Book' },
296
+ {
297
+ country: createRelationship(resourceIdentifier('countries', countryId)),
298
+ authors: createToManyRelationship([resourceIdentifier('authors', author1Id)])
299
+ }
300
+ )
301
+ });
302
+
303
+ // Update to have both authors
304
+ const patchDoc = {
305
+ data: {
306
+ type: 'books',
307
+ id: book.data.id,
308
+ relationships: {
309
+ authors: createToManyRelationship([
310
+ resourceIdentifier('authors', author1Id),
311
+ resourceIdentifier('authors', author2Id)
312
+ ])
313
+ }
314
+ }
315
+ };
316
+
317
+ const patchResult = await api.resources.books.patch({
318
+ inputRecord: patchDoc,
319
+ queryParams: {
320
+ include: ['authors']
321
+ }
322
+ });
323
+
324
+ // Verify full record returned
325
+ assert.equal(patchResult.data.attributes.title, 'Test Book');
326
+ // With include, we should get the authors relationship data
327
+ assertResourceRelationship(patchResult.data, 'authors',
328
+ [resourceIdentifier('authors', author1Id), resourceIdentifier('authors', author2Id)]);
329
+
330
+ // Verify update
331
+ const updated = await api.resources.books.get({
332
+ id: book.data.id,
333
+ queryParams: { include: ['authors'] }
334
+ });
335
+
336
+ assert.equal(updated.included.length, 2);
337
+ });
338
+ });
339
+
340
+ describe('Polymorphic Relationships with custom idProperty', () => {
341
+ let bookId;
342
+ let authorId;
343
+ let publisherId;
344
+
345
+ beforeEach(async () => {
346
+ await cleanTables(knex, [
347
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
348
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
349
+ ]);
350
+
351
+ // Create test resources
352
+ const country = await api.resources.countries.post({
353
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
354
+ });
355
+
356
+ const book = await api.resources.books.post({
357
+ inputRecord: createJsonApiDocument('books',
358
+ { title: 'Test Book' },
359
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
360
+ )
361
+ });
362
+ bookId = book.data.id;
363
+
364
+ const author = await api.resources.authors.post({
365
+ inputRecord: createJsonApiDocument('authors', { name: 'Test Author' })
366
+ });
367
+ authorId = author.data.id;
368
+
369
+ const publisher = await api.resources.publishers.post({
370
+ inputRecord: createJsonApiDocument('publishers', { name: 'Test Publisher' })
371
+ });
372
+ publisherId = publisher.data.id;
373
+ });
374
+
375
+ it('should create polymorphic review for book with custom IDs', async () => {
376
+ const review = await api.resources.reviews.post({
377
+ inputRecord: createJsonApiDocument('reviews', {
378
+ rating: 5,
379
+ title: 'Great book!',
380
+ content: 'Excellent read.',
381
+ reviewer_name: 'John Doe',
382
+ reviewable_type: 'books',
383
+ reviewable_id: bookId
384
+ })
385
+ });
386
+
387
+ validateJsonApiStructure(review);
388
+ assert.equal(review.data.attributes.reviewable_type, 'books');
389
+ assert.equal(String(review.data.attributes.reviewable_id), bookId);
390
+
391
+ // Verify database
392
+ const dbReview = await knex('custom_id_reviews').first();
393
+ assert.equal(dbReview.reviewable_type, 'books');
394
+ assert.equal(String(dbReview.reviewable_id), bookId);
395
+ assert(dbReview.review_id, 'Should have custom review_id column');
396
+ });
397
+
398
+ it('should create polymorphic review for author with custom IDs', async () => {
399
+ const review = await api.resources.reviews.post({
400
+ inputRecord: createJsonApiDocument('reviews', {
401
+ rating: 4,
402
+ title: 'Talented author',
403
+ content: 'Great writing style.',
404
+ reviewer_name: 'Jane Doe',
405
+ reviewable_type: 'authors',
406
+ reviewable_id: authorId
407
+ })
408
+ });
409
+
410
+ assert.equal(review.data.attributes.reviewable_type, 'authors');
411
+ assert.equal(String(review.data.attributes.reviewable_id), authorId);
412
+ });
413
+
414
+ it('should query polymorphic reviews by type', async () => {
415
+ // Create reviews for different types
416
+ await api.resources.reviews.post({
417
+ inputRecord: createJsonApiDocument('reviews', {
418
+ rating: 5,
419
+ title: 'Book review',
420
+ content: 'Great book',
421
+ reviewer_name: 'Reader',
422
+ reviewable_type: 'books',
423
+ reviewable_id: bookId
424
+ })
425
+ });
426
+
427
+ await api.resources.reviews.post({
428
+ inputRecord: createJsonApiDocument('reviews', {
429
+ rating: 4,
430
+ title: 'Author review',
431
+ content: 'Great author',
432
+ reviewer_name: 'Fan',
433
+ reviewable_type: 'authors',
434
+ reviewable_id: authorId
435
+ })
436
+ });
437
+
438
+ // Query only book reviews
439
+ const bookReviews = await api.resources.reviews.query({
440
+ queryParams: {
441
+ filters: { reviewable_type: 'books' }
442
+ }
443
+ });
444
+
445
+ debugger
446
+ assert.equal(bookReviews.data.length, 1, `Expected 1 book review, got ${bookReviews.data.length}`);
447
+ assert.equal(bookReviews.data[0].attributes.reviewable_type, 'books');
448
+ });
449
+ });
450
+
451
+ describe('Nested Includes with custom idProperty', () => {
452
+ beforeEach(async () => {
453
+ await cleanTables(knex, [
454
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
455
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
456
+ ]);
457
+ });
458
+
459
+ it('should handle nested includes through custom ID relationships', async () => {
460
+ // Create hierarchy: Country -> Publisher -> Book -> Authors
461
+ const country = await api.resources.countries.post({
462
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
463
+ });
464
+
465
+ const publisher = await api.resources.publishers.post({
466
+ inputRecord: createJsonApiDocument('publishers',
467
+ { name: 'Test Publisher' },
468
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
469
+ )
470
+ });
471
+
472
+ const author = await api.resources.authors.post({
473
+ inputRecord: createJsonApiDocument('authors', { name: 'Test Author' })
474
+ });
475
+
476
+ const book = await api.resources.books.post({
477
+ inputRecord: createJsonApiDocument('books',
478
+ { title: 'Test Book' },
479
+ {
480
+ country: createRelationship(resourceIdentifier('countries', country.data.id)),
481
+ publisher: createRelationship(resourceIdentifier('publishers', publisher.data.id)),
482
+ authors: createToManyRelationship([resourceIdentifier('authors', author.data.id)])
483
+ }
484
+ )
485
+ });
486
+
487
+ // Query with nested includes
488
+ const result = await api.resources.books.query({
489
+ queryParams: {
490
+ include: ['publisher.country', 'authors']
491
+ }
492
+ });
493
+
494
+ validateJsonApiStructure(result, true);
495
+ assert(result.included);
496
+
497
+ // Find each type in included
498
+ const includedPublisher = result.included.find(r => r.type === 'publishers');
499
+ const includedCountry = result.included.find(r => r.type === 'countries');
500
+ const includedAuthor = result.included.find(r => r.type === 'authors');
501
+
502
+ assert(includedPublisher, 'Should include publisher');
503
+ assert(includedCountry, 'Should include country');
504
+ assert(includedAuthor, 'Should include author');
505
+
506
+ // Verify nested relationship
507
+ assert.equal(includedPublisher.relationships.country.data.id, country.data.id);
508
+ });
509
+
510
+ it('should handle 3+ levels of nested includes', async () => {
511
+ // Create deeper hierarchy
512
+ const country = await api.resources.countries.post({
513
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
514
+ });
515
+
516
+ const author = await api.resources.authors.post({
517
+ inputRecord: createJsonApiDocument('authors',
518
+ { name: 'Test Author' },
519
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
520
+ )
521
+ });
522
+
523
+ const publisher = await api.resources.publishers.post({
524
+ inputRecord: createJsonApiDocument('publishers',
525
+ { name: 'Test Publisher' },
526
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
527
+ )
528
+ });
529
+
530
+ const book = await api.resources.books.post({
531
+ inputRecord: createJsonApiDocument('books',
532
+ { title: 'Test Book' },
533
+ {
534
+ country: createRelationship(resourceIdentifier('countries', country.data.id)),
535
+ publisher: createRelationship(resourceIdentifier('publishers', publisher.data.id)),
536
+ authors: createToManyRelationship([resourceIdentifier('authors', author.data.id)])
537
+ }
538
+ )
539
+ });
540
+
541
+ // Query with 3 levels: books -> authors -> country
542
+ const result = await api.resources.books.query({
543
+ queryParams: {
544
+ include: ['authors.country', 'publisher.country']
545
+ }
546
+ });
547
+
548
+ validateJsonApiStructure(result, true);
549
+ assert(result.included);
550
+
551
+ const types = result.included.map(r => r.type);
552
+ assert(types.includes('authors'), 'Should include authors');
553
+ assert(types.includes('publishers'), 'Should include publishers');
554
+ assert(types.includes('countries'), 'Should include countries');
555
+ });
556
+ });
557
+
558
+ describe('Include with Sparse Fieldsets and custom idProperty', () => {
559
+ let testData = {};
560
+
561
+ beforeEach(async () => {
562
+ await cleanTables(knex, [
563
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
564
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
565
+ ]);
566
+
567
+ // Create test data
568
+ const country = await api.resources.countries.post({
569
+ inputRecord: createJsonApiDocument('countries', { name: 'Test Country', code: 'TC' })
570
+ });
571
+ testData.country = country.data;
572
+
573
+ const publisher = await api.resources.publishers.post({
574
+ inputRecord: createJsonApiDocument('publishers',
575
+ { name: 'Test Publisher' },
576
+ { country: createRelationship(resourceIdentifier('countries', testData.country.id)) }
577
+ )
578
+ });
579
+ testData.publisher = publisher.data;
580
+
581
+ const author = await api.resources.authors.post({
582
+ inputRecord: createJsonApiDocument('authors', { name: 'Test Author', biography: 'A great author' })
583
+ });
584
+ testData.author = author.data;
585
+
586
+ const book = await api.resources.books.post({
587
+ inputRecord: createJsonApiDocument('books',
588
+ { title: 'Test Book', isbn: '1234567890', pages: 300 },
589
+ {
590
+ country: createRelationship(resourceIdentifier('countries', testData.country.id)),
591
+ publisher: createRelationship(resourceIdentifier('publishers', testData.publisher.id)),
592
+ authors: createToManyRelationship([resourceIdentifier('authors', testData.author.id)])
593
+ }
594
+ )
595
+ });
596
+ testData.book = book.data;
597
+ });
598
+
599
+ it('should combine sparse fieldsets with includes using custom IDs', async () => {
600
+ const result = await api.resources.books.get({
601
+ id: testData.book.id,
602
+ queryParams: {
603
+ include: ['publisher', 'authors'],
604
+ fields: {
605
+ books: 'title',
606
+ publishers: 'name',
607
+ authors: 'name' // Exclude biography
608
+ }
609
+ }
610
+ });
611
+
612
+ // Check main resource has only requested fields
613
+ assert(result.data.attributes.title);
614
+ assert(!result.data.attributes.isbn);
615
+ assert(!result.data.attributes.pages);
616
+
617
+ // Check included resources have only requested fields
618
+ const publisher = result.included.find(r => r.type === 'publishers');
619
+ assert(publisher.attributes.name);
620
+ assert(publisher.id); // ID is always included
621
+
622
+ const author = result.included.find(r => r.type === 'authors');
623
+ assert(author.attributes.name);
624
+ assert(!author.attributes.biography);
625
+ assert(author.id);
626
+ });
627
+ });
628
+
629
+ describe('Include Depth Validation with custom idProperty', () => {
630
+ beforeEach(async () => {
631
+ await cleanTables(knex, [
632
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
633
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
634
+ ]);
635
+ });
636
+
637
+ it('should enforce include depth limits', async () => {
638
+ // Create deep hierarchy
639
+ const country = await api.resources.countries.post({
640
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
641
+ });
642
+
643
+ const publisher = await api.resources.publishers.post({
644
+ inputRecord: createJsonApiDocument('publishers',
645
+ { name: 'Test Publisher' },
646
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
647
+ )
648
+ });
649
+
650
+ const author = await api.resources.authors.post({
651
+ inputRecord: createJsonApiDocument('authors',
652
+ { name: 'Test Author' },
653
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
654
+ )
655
+ });
656
+
657
+ const book = await api.resources.books.post({
658
+ inputRecord: createJsonApiDocument('books',
659
+ { title: 'Test Book' },
660
+ {
661
+ country: createRelationship(resourceIdentifier('countries', country.data.id)),
662
+ publisher: createRelationship(resourceIdentifier('publishers', publisher.data.id)),
663
+ authors: createToManyRelationship([resourceIdentifier('authors', author.data.id)])
664
+ }
665
+ )
666
+ });
667
+
668
+ // Test depth limit (default is 3)
669
+ // This should work: books -> authors -> country (depth 2)
670
+ const validDepth = await api.resources.books.query({
671
+ queryParams: {
672
+ include: ['authors.country']
673
+ }
674
+ });
675
+ assert(validDepth.data);
676
+
677
+ // This should fail: trying to go too deep (if we had deeper relationships)
678
+ // Since our test data doesn't have 4+ levels, we test that 3 levels work
679
+ const maxDepth = await api.resources.books.query({
680
+ queryParams: {
681
+ include: ['publisher.country', 'authors.country']
682
+ }
683
+ });
684
+ assert(maxDepth.data);
685
+ });
686
+ });
687
+
688
+ describe('Complex Scenarios with custom idProperty', () => {
689
+ beforeEach(async () => {
690
+ await cleanTables(knex, [
691
+ 'custom_id_countries', 'custom_id_publishers', 'custom_id_authors',
692
+ 'custom_id_books', 'custom_id_book_authors', 'custom_id_reviews'
693
+ ]);
694
+ });
695
+
696
+ it('should handle complex queries with filters, includes, and custom IDs', async () => {
697
+ // Create multiple countries
698
+ const usa = await api.resources.countries.post({
699
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
700
+ });
701
+
702
+ const uk = await api.resources.countries.post({
703
+ inputRecord: createJsonApiDocument('countries', { name: 'UK', code: 'GB' })
704
+ });
705
+
706
+ // Create publishers in different countries
707
+ const usPublisher = await api.resources.publishers.post({
708
+ inputRecord: createJsonApiDocument('publishers',
709
+ { name: 'US Publisher' },
710
+ { country: createRelationship(resourceIdentifier('countries', usa.data.id)) }
711
+ )
712
+ });
713
+
714
+ const ukPublisher = await api.resources.publishers.post({
715
+ inputRecord: createJsonApiDocument('publishers',
716
+ { name: 'UK Publisher' },
717
+ { country: createRelationship(resourceIdentifier('countries', uk.data.id)) }
718
+ )
719
+ });
720
+
721
+ // Create authors
722
+ const author1 = await api.resources.authors.post({
723
+ inputRecord: createJsonApiDocument('authors', { name: 'Author One' })
724
+ });
725
+
726
+ const author2 = await api.resources.authors.post({
727
+ inputRecord: createJsonApiDocument('authors', { name: 'Author Two' })
728
+ });
729
+
730
+ // Create books
731
+ await api.resources.books.post({
732
+ inputRecord: createJsonApiDocument('books',
733
+ { title: 'US Book 1' },
734
+ {
735
+ country: createRelationship(resourceIdentifier('countries', usa.data.id)),
736
+ publisher: createRelationship(resourceIdentifier('publishers', usPublisher.data.id)),
737
+ authors: createToManyRelationship([resourceIdentifier('authors', author1.data.id)])
738
+ }
739
+ )
740
+ });
741
+
742
+ await api.resources.books.post({
743
+ inputRecord: createJsonApiDocument('books',
744
+ { title: 'UK Book 1' },
745
+ {
746
+ country: createRelationship(resourceIdentifier('countries', uk.data.id)),
747
+ publisher: createRelationship(resourceIdentifier('publishers', ukPublisher.data.id)),
748
+ authors: createToManyRelationship([
749
+ resourceIdentifier('authors', author1.data.id),
750
+ resourceIdentifier('authors', author2.data.id)
751
+ ])
752
+ }
753
+ )
754
+ });
755
+
756
+ // Complex query: US books with their publishers and authors
757
+ const result = await api.resources.books.query({
758
+ queryParams: {
759
+ filters: { country_id: usa.data.id },
760
+ include: ['publisher.country', 'authors'],
761
+ sort: ['title']
762
+ }
763
+ });
764
+
765
+ assert.equal(result.data.length, 1);
766
+ assert.equal(result.data[0].attributes.title, 'US Book 1');
767
+
768
+ // Verify includes
769
+ const includedTypes = result.included.map(r => r.type);
770
+ assert(includedTypes.includes('publishers'));
771
+ assert(includedTypes.includes('countries'));
772
+ assert(includedTypes.includes('authors'));
773
+ });
774
+
775
+ it('should handle bulk operations with custom IDs', async () => {
776
+ const country = await api.resources.countries.post({
777
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
778
+ });
779
+
780
+ // Create multiple authors
781
+ const authors = [];
782
+ for (let i = 1; i <= 3; i++) {
783
+ const author = await api.resources.authors.post({
784
+ inputRecord: createJsonApiDocument('authors', { name: `Author ${i}` })
785
+ });
786
+ authors.push(author.data);
787
+ }
788
+
789
+ // Create book with all authors
790
+ const book = await api.resources.books.post({
791
+ inputRecord: createJsonApiDocument('books',
792
+ { title: 'Multi-author Book' },
793
+ {
794
+ country: createRelationship(resourceIdentifier('countries', country.data.id)),
795
+ authors: createToManyRelationship(
796
+ authors.map(a => resourceIdentifier('authors', a.id))
797
+ )
798
+ }
799
+ )
800
+ });
801
+
802
+ // Verify all relationships were created
803
+ const bookWithAuthors = await api.resources.books.get({
804
+ id: book.data.id,
805
+ queryParams: { include: ['authors'] }
806
+ });
807
+
808
+ assert.equal(bookWithAuthors.included.length, 3);
809
+
810
+ // Verify pivot table
811
+ const pivotCount = await knex('custom_id_book_authors').count('* as count').first();
812
+ assert.equal(pivotCount.count, 3);
813
+ });
814
+
815
+ it('should handle DELETE operations with custom IDs', async () => {
816
+ const country = await api.resources.countries.post({
817
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
818
+ });
819
+
820
+ const author = await api.resources.authors.post({
821
+ inputRecord: createJsonApiDocument('authors', { name: 'Test Author' })
822
+ });
823
+
824
+ const book = await api.resources.books.post({
825
+ inputRecord: createJsonApiDocument('books',
826
+ { title: 'Test Book' },
827
+ {
828
+ country: createRelationship(resourceIdentifier('countries', country.data.id)),
829
+ authors: createToManyRelationship([resourceIdentifier('authors', author.data.id)])
830
+ }
831
+ )
832
+ });
833
+
834
+ // Delete the book
835
+ await api.resources.books.delete({ id: book.data.id });
836
+
837
+ // Verify book is deleted
838
+ try {
839
+ await api.resources.books.get({ id: book.data.id });
840
+ assert.fail('Should have thrown error');
841
+ } catch (error) {
842
+ assert(error.message.includes('not found') || error.message.includes('404'));
843
+ }
844
+
845
+ // The REST API correctly does NOT cascade delete pivot records
846
+ // This is intentional - automatic cascade deletion would be dangerous
847
+ // Pivot records remain and should be cleaned up by application logic if needed
848
+ const pivotCount = await knex('custom_id_book_authors').count('* as count').first();
849
+ assert.equal(pivotCount.count, 1, 'Pivot record should still exist');
850
+
851
+ // Verify author still exists
852
+ const authorStillExists = await api.resources.authors.get({ id: author.data.id });
853
+ assert(authorStillExists.data);
854
+ });
855
+
856
+ it('should handle PUT operations with custom IDs', async () => {
857
+ const country = await api.resources.countries.post({
858
+ inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
859
+ });
860
+
861
+ const publisher = await api.resources.publishers.post({
862
+ inputRecord: createJsonApiDocument('publishers',
863
+ { name: 'Original Publisher' },
864
+ { country: createRelationship(resourceIdentifier('countries', country.data.id)) }
865
+ )
866
+ });
867
+
868
+ // PUT to completely replace the publisher
869
+ const putResult = await api.resources.publishers.put({
870
+ inputRecord: {
871
+ data: {
872
+ type: 'publishers',
873
+ id: publisher.data.id,
874
+ attributes: {
875
+ name: 'Replaced Publisher'
876
+ },
877
+ relationships: {
878
+ country: createRelationship(resourceIdentifier('countries', country.data.id))
879
+ }
880
+ }
881
+ }
882
+ });
883
+
884
+ // Verify full record returned
885
+ validateJsonApiStructure(putResult, false);
886
+ assert.equal(putResult.data.attributes.name, 'Replaced Publisher');
887
+ assertResourceRelationship(putResult.data, 'country',
888
+ resourceIdentifier('countries', country.data.id));
889
+ });
890
+ });
891
+ });