json-rest-api 1.0.6 → 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 +99 -33
- 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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { getSchemaStructure } from '../querying-writing/knex-constants.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Processes belongsTo relationships from JSON:API input to foreign key updates
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} scope - The scope object containing schema info
|
|
7
|
+
* @param {Object} deps - Dependencies object containing context with inputRecord
|
|
8
|
+
* @returns {Object} Object mapping foreign key fields to their values
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // Input: JSON:API document with relationships
|
|
12
|
+
* const inputRecord = {
|
|
13
|
+
* data: {
|
|
14
|
+
* type: 'articles',
|
|
15
|
+
* attributes: { title: 'My Article' },
|
|
16
|
+
* relationships: {
|
|
17
|
+
* author: {
|
|
18
|
+
* data: { type: 'users', id: '123' }
|
|
19
|
+
* },
|
|
20
|
+
* category: {
|
|
21
|
+
* data: { type: 'categories', id: '456' }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* };
|
|
26
|
+
*
|
|
27
|
+
* // Schema has: author_id field with belongsTo: 'users', as: 'author'
|
|
28
|
+
* // Schema has: category_id field with belongsTo: 'categories', as: 'category'
|
|
29
|
+
*
|
|
30
|
+
* const updates = processBelongsToRelationships(scope, { context: { inputRecord } });
|
|
31
|
+
*
|
|
32
|
+
* // Output: Foreign key values extracted
|
|
33
|
+
* // {
|
|
34
|
+
* // author_id: '123',
|
|
35
|
+
* // category_id: '456'
|
|
36
|
+
* // }
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // Input: Removing a relationship (setting to null)
|
|
40
|
+
* const inputRecord = {
|
|
41
|
+
* data: {
|
|
42
|
+
* type: 'articles',
|
|
43
|
+
* id: '1',
|
|
44
|
+
* relationships: {
|
|
45
|
+
* author: {
|
|
46
|
+
* data: null // Remove author
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
* };
|
|
51
|
+
*
|
|
52
|
+
* const updates = processBelongsToRelationships(scope, { context: { inputRecord } });
|
|
53
|
+
*
|
|
54
|
+
* // Output: Foreign key set to null
|
|
55
|
+
* // {
|
|
56
|
+
* // author_id: null
|
|
57
|
+
* // }
|
|
58
|
+
*
|
|
59
|
+
* @description
|
|
60
|
+
* Used by:
|
|
61
|
+
* - rest-api-knex-plugin's dataPut and dataPatch methods
|
|
62
|
+
* - Called before saving to extract foreign keys from JSON:API relationships
|
|
63
|
+
* - Works in conjunction with relationship-processor for complete handling
|
|
64
|
+
*
|
|
65
|
+
* Purpose:
|
|
66
|
+
* - Converts JSON:API relationship format to database foreign keys
|
|
67
|
+
* - Maps from relationship names (e.g., 'author') to field names (e.g., 'author_id')
|
|
68
|
+
* - Handles both setting relationships (with id) and removing them (null)
|
|
69
|
+
* - Only processes belongsTo relationships, not hasMany
|
|
70
|
+
*
|
|
71
|
+
* Data flow:
|
|
72
|
+
* 1. Receives JSON:API document with relationships section
|
|
73
|
+
* 2. Looks up schema to find foreign key fields
|
|
74
|
+
* 3. For each belongsTo field, checks if relationship exists in input
|
|
75
|
+
* 4. Extracts id from relationship data or sets null
|
|
76
|
+
* 5. Returns object ready to merge with attributes for database update
|
|
77
|
+
*/
|
|
78
|
+
export const processBelongsToRelationships = (scope, deps) => {
|
|
79
|
+
const foreignKeyUpdates = {};
|
|
80
|
+
|
|
81
|
+
// Extract values from deps
|
|
82
|
+
const { context } = deps;
|
|
83
|
+
const inputRecord = context.inputRecord;
|
|
84
|
+
|
|
85
|
+
if (!inputRecord.data.relationships) {
|
|
86
|
+
return foreignKeyUpdates;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract schema from scope
|
|
90
|
+
const {
|
|
91
|
+
vars: {
|
|
92
|
+
schemaInfo: { schema }
|
|
93
|
+
}
|
|
94
|
+
} = scope;
|
|
95
|
+
|
|
96
|
+
// Schema might be a Schema object or plain object
|
|
97
|
+
const schemaStructure = getSchemaStructure(schema);
|
|
98
|
+
|
|
99
|
+
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
100
|
+
if (fieldDef.belongsTo && fieldDef.as) {
|
|
101
|
+
const relName = fieldDef.as;
|
|
102
|
+
if (inputRecord.data.relationships[relName] !== undefined) {
|
|
103
|
+
const relData = inputRecord.data.relationships[relName];
|
|
104
|
+
foreignKeyUpdates[fieldName] = relData.data?.id || null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return foreignKeyUpdates;
|
|
110
|
+
};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js';
|
|
2
|
+
import { transformSimplifiedToJsonApi } from '../querying-writing/simplified-helpers.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Updates many-to-many relationships intelligently by synchronizing pivot table records
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} scope - The scope object (not used directly, for consistency)
|
|
8
|
+
* @param {Object} deps - Dependencies object
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Input: Article currently has tags [1, 2], want to change to [1, 3]
|
|
13
|
+
* const deps = {
|
|
14
|
+
* api,
|
|
15
|
+
* context: {
|
|
16
|
+
* resourceId: '100',
|
|
17
|
+
* relDef: {
|
|
18
|
+
* through: 'article_tags', // Pivot table
|
|
19
|
+
* foreignKey: 'article_id', // Points to article
|
|
20
|
+
* otherKey: 'tag_id' // Points to tag
|
|
21
|
+
* },
|
|
22
|
+
* relData: [
|
|
23
|
+
* { type: 'tags', id: '1' }, // Keep this
|
|
24
|
+
* { type: 'tags', id: '3' } // Add this
|
|
25
|
+
* ],
|
|
26
|
+
* transaction: trx
|
|
27
|
+
* }
|
|
28
|
+
* };
|
|
29
|
+
*
|
|
30
|
+
* // Before: article_tags table
|
|
31
|
+
* // article_id | tag_id | created_at
|
|
32
|
+
* // 100 | 1 | 2024-01-01
|
|
33
|
+
* // 100 | 2 | 2024-01-02
|
|
34
|
+
*
|
|
35
|
+
* await updateManyToManyRelationship(null, deps);
|
|
36
|
+
*
|
|
37
|
+
* // After: article_tags table
|
|
38
|
+
* // article_id | tag_id | created_at
|
|
39
|
+
* // 100 | 1 | 2024-01-01 (preserved!)
|
|
40
|
+
* // 100 | 3 | 2024-12-01 (new)
|
|
41
|
+
* // Tag 2 was deleted, Tag 1 kept its metadata
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* // Input: Pivot table has extra fields to preserve
|
|
45
|
+
* // article_tags has: article_id, tag_id, display_order, featured
|
|
46
|
+
*
|
|
47
|
+
* // Current data:
|
|
48
|
+
* // article_id | tag_id | display_order | featured
|
|
49
|
+
* // 100 | 1 | 1 | true
|
|
50
|
+
* // 100 | 2 | 2 | false
|
|
51
|
+
*
|
|
52
|
+
* const deps = {
|
|
53
|
+
* context: {
|
|
54
|
+
* resourceId: '100',
|
|
55
|
+
* relData: [
|
|
56
|
+
* { type: 'tags', id: '1' }, // Keep tag 1
|
|
57
|
+
* { type: 'tags', id: '5' } // Add tag 5
|
|
58
|
+
* ]
|
|
59
|
+
* }
|
|
60
|
+
* };
|
|
61
|
+
*
|
|
62
|
+
* await updateManyToManyRelationship(null, deps);
|
|
63
|
+
*
|
|
64
|
+
* // Result:
|
|
65
|
+
* // article_id | tag_id | display_order | featured
|
|
66
|
+
* // 100 | 1 | 1 | true (preserved!)
|
|
67
|
+
* // 100 | 5 | NULL | NULL (new with defaults)
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // Input: Clear all relationships
|
|
71
|
+
* const deps = {
|
|
72
|
+
* context: {
|
|
73
|
+
* resourceId: '100',
|
|
74
|
+
* relData: [] // Empty array means remove all
|
|
75
|
+
* }
|
|
76
|
+
* };
|
|
77
|
+
*
|
|
78
|
+
* await updateManyToManyRelationship(null, deps);
|
|
79
|
+
* // All article_tags records for article 100 are deleted
|
|
80
|
+
*
|
|
81
|
+
* @description
|
|
82
|
+
* Used by:
|
|
83
|
+
* - relationship-processor.js calls this for many-to-many updates
|
|
84
|
+
* - Used in PATCH operations to sync relationships
|
|
85
|
+
* - Also used in PUT operations (replaces deleteExistingPivotRecords pattern)
|
|
86
|
+
*
|
|
87
|
+
* Purpose:
|
|
88
|
+
* - Intelligently syncs pivot table to match desired state
|
|
89
|
+
* - Preserves existing pivot records that should remain (with their metadata)
|
|
90
|
+
* - Only deletes records that should be removed
|
|
91
|
+
* - Only creates records that are new
|
|
92
|
+
* - Much better than delete-all-then-recreate pattern
|
|
93
|
+
*
|
|
94
|
+
* Data flow:
|
|
95
|
+
* 1. Queries existing pivot records for the resource
|
|
96
|
+
* 2. Compares existing IDs with desired IDs
|
|
97
|
+
* 3. Calculates which to delete and which to add
|
|
98
|
+
* 4. Validates new related resources exist (optional)
|
|
99
|
+
* 5. Performs bulk delete for removed relationships
|
|
100
|
+
* 6. Performs bulk insert for new relationships
|
|
101
|
+
* 7. Records that exist in both are untouched (metadata preserved)
|
|
102
|
+
*/
|
|
103
|
+
export const updateManyToManyRelationship = async (scope, deps) => {
|
|
104
|
+
// Extract values from deps
|
|
105
|
+
const { api, context } = deps;
|
|
106
|
+
const { resourceId, relDef, relData, transaction: trx } = context;
|
|
107
|
+
|
|
108
|
+
// Get the knex instance from the pivot scope
|
|
109
|
+
const pivotScope = api.resources[relDef.through];
|
|
110
|
+
if (!pivotScope) {
|
|
111
|
+
throw new Error(`Pivot table resource '${relDef.through}' not found`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Get the actual database table name (might be different from scope name)
|
|
115
|
+
const tableName = pivotScope.vars.schemaInfo.tableName || relDef.through;
|
|
116
|
+
|
|
117
|
+
// Get existing pivot records directly from database
|
|
118
|
+
const existingRecords = await trx(tableName)
|
|
119
|
+
.where(relDef.foreignKey, resourceId)
|
|
120
|
+
.select(relDef.otherKey);
|
|
121
|
+
|
|
122
|
+
// Create sets for efficient comparison
|
|
123
|
+
const existingIds = new Set(existingRecords.map(r => String(r[relDef.otherKey])));
|
|
124
|
+
const newIds = new Set(relData.map(r => String(r.id)));
|
|
125
|
+
|
|
126
|
+
// Determine what to delete and add
|
|
127
|
+
const toDelete = [...existingIds].filter(id => !newIds.has(id));
|
|
128
|
+
const toAdd = [...newIds].filter(id => !existingIds.has(id));
|
|
129
|
+
|
|
130
|
+
// Validate related resources exist if needed (do this before any changes)
|
|
131
|
+
if (relDef.validateExists !== false && toAdd.length > 0) {
|
|
132
|
+
for (const relatedId of toAdd) {
|
|
133
|
+
const related = relData.find(r => String(r.id) === relatedId);
|
|
134
|
+
try {
|
|
135
|
+
await api.resources[related.type].get({
|
|
136
|
+
id: related.id,
|
|
137
|
+
transaction: trx
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new RestApiResourceError(
|
|
141
|
+
`Related ${related.type} with id ${related.id} not found`,
|
|
142
|
+
{
|
|
143
|
+
subtype: 'not_found',
|
|
144
|
+
resourceType: related.type,
|
|
145
|
+
resourceId: related.id
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Bulk delete records that should be removed
|
|
153
|
+
if (toDelete.length > 0) {
|
|
154
|
+
await trx(tableName)
|
|
155
|
+
.where(relDef.foreignKey, resourceId)
|
|
156
|
+
.whereIn(relDef.otherKey, toDelete)
|
|
157
|
+
.delete();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Bulk insert new records
|
|
161
|
+
if (toAdd.length > 0) {
|
|
162
|
+
const recordsToInsert = toAdd.map(relatedId => ({
|
|
163
|
+
[relDef.foreignKey]: resourceId,
|
|
164
|
+
[relDef.otherKey]: relatedId
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
await trx(tableName).insert(recordsToInsert);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Records that exist in both are automatically preserved with their pivot data
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Note: deleteExistingPivotRecords has been removed in favor of using
|
|
174
|
+
// updateManyToManyRelationship for all sync operations (including PUT).
|
|
175
|
+
// This aligns with industry standards where ORMs use intelligent sync
|
|
176
|
+
// rather than delete-all-then-recreate patterns.
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Creates new pivot table records for many-to-many relationships
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} api - The API instance with access to resources
|
|
182
|
+
* @param {string|number} resourceId - The ID of the primary resource
|
|
183
|
+
* @param {Object} relDef - The relationship definition
|
|
184
|
+
* @param {Array} relData - Array of related resources to link
|
|
185
|
+
* @param {Object} trx - Database transaction object
|
|
186
|
+
* @returns {Promise<void>}
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* // Input: Create article-tag relationships
|
|
190
|
+
* const relDef = {
|
|
191
|
+
* through: 'article_tags',
|
|
192
|
+
* foreignKey: 'article_id',
|
|
193
|
+
* otherKey: 'tag_id'
|
|
194
|
+
* };
|
|
195
|
+
* const relData = [
|
|
196
|
+
* { type: 'tags', id: '10' },
|
|
197
|
+
* { type: 'tags', id: '20' },
|
|
198
|
+
* { type: 'tags', id: '30' }
|
|
199
|
+
* ];
|
|
200
|
+
*
|
|
201
|
+
* await createPivotRecords(api, '100', relDef, relData, trx);
|
|
202
|
+
*
|
|
203
|
+
* // Result: 3 new records in article_tags table
|
|
204
|
+
* // article_id | tag_id
|
|
205
|
+
* // 100 | 10
|
|
206
|
+
* // 100 | 20
|
|
207
|
+
* // 100 | 30
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* // Input: Validation ensures related resources exist
|
|
211
|
+
* const relData = [
|
|
212
|
+
* { type: 'tags', id: '999' } // Non-existent tag
|
|
213
|
+
* ];
|
|
214
|
+
*
|
|
215
|
+
* try {
|
|
216
|
+
* await createPivotRecords(api, '100', relDef, relData, trx);
|
|
217
|
+
* } catch (error) {
|
|
218
|
+
* console.log(error.message);
|
|
219
|
+
* // "Related tags with id 999 not found"
|
|
220
|
+
* // Transaction rolled back, no records created
|
|
221
|
+
* }
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* // Input: Skip validation for performance
|
|
225
|
+
* const relDef = {
|
|
226
|
+
* through: 'user_permissions',
|
|
227
|
+
* foreignKey: 'user_id',
|
|
228
|
+
* otherKey: 'permission_id',
|
|
229
|
+
* validateExists: false // Skip GET requests
|
|
230
|
+
* };
|
|
231
|
+
*
|
|
232
|
+
* // With 100 permissions, saves 100 GET requests
|
|
233
|
+
* await createPivotRecords(api, userId, relDef, permissions, trx);
|
|
234
|
+
*
|
|
235
|
+
* // Risk: Could create orphaned relationships if permissions don't exist
|
|
236
|
+
* // Benefit: Much faster for bulk operations when you trust the data
|
|
237
|
+
*
|
|
238
|
+
* @description
|
|
239
|
+
* Used by:
|
|
240
|
+
* - relationship-processor.js for POST operations
|
|
241
|
+
* - updateManyToManyRelationship internally for new relationships
|
|
242
|
+
* - Any code that needs to create pivot records
|
|
243
|
+
*
|
|
244
|
+
* Purpose:
|
|
245
|
+
* - Creates pivot table records to link resources
|
|
246
|
+
* - Validates related resources exist by default (referential integrity)
|
|
247
|
+
* - Supports bulk insert for efficiency
|
|
248
|
+
* - Works within transactions for atomicity
|
|
249
|
+
* - Allows skipping validation when performance matters
|
|
250
|
+
*
|
|
251
|
+
* Data flow:
|
|
252
|
+
* 1. Validates pivot table resource exists
|
|
253
|
+
* 2. Gets actual database table name
|
|
254
|
+
* 3. Optionally validates each related resource exists (GET requests)
|
|
255
|
+
* 4. Prepares bulk insert data with foreign keys
|
|
256
|
+
* 5. Performs single INSERT with all records
|
|
257
|
+
* 6. Returns (no data returned, throws on error)
|
|
258
|
+
*/
|
|
259
|
+
export const createPivotRecords = async (api, resourceId, relDef, relData, trx) => {
|
|
260
|
+
if (relData.length === 0) return; // Early exit if nothing to create
|
|
261
|
+
|
|
262
|
+
// Get pivot table info
|
|
263
|
+
const pivotScope = api.resources[relDef.through];
|
|
264
|
+
if (!pivotScope) {
|
|
265
|
+
throw new Error(`Pivot table resource '${relDef.through}' not found`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Get the actual database table name (might be different from scope name)
|
|
269
|
+
const tableName = pivotScope.vars.schemaInfo.tableName || relDef.through;
|
|
270
|
+
|
|
271
|
+
// Validate all related resources exist if needed (do this before any inserts)
|
|
272
|
+
if (relDef.validateExists !== false) {
|
|
273
|
+
for (const related of relData) {
|
|
274
|
+
try {
|
|
275
|
+
await api.resources[related.type].get({
|
|
276
|
+
id: related.id,
|
|
277
|
+
transaction: trx
|
|
278
|
+
});
|
|
279
|
+
} catch (error) {
|
|
280
|
+
throw new RestApiResourceError(
|
|
281
|
+
`Related ${related.type} with id ${related.id} not found`,
|
|
282
|
+
{
|
|
283
|
+
subtype: 'not_found',
|
|
284
|
+
resourceType: related.type,
|
|
285
|
+
resourceId: related.id
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Prepare records for bulk insert
|
|
293
|
+
const recordsToInsert = relData.map(related => ({
|
|
294
|
+
[relDef.foreignKey]: resourceId,
|
|
295
|
+
[relDef.otherKey]: related.id
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
// Bulk insert all pivot records in a single query
|
|
299
|
+
await trx(tableName).insert(recordsToInsert);
|
|
300
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Processes JSON:API relationship data and converts to database operations
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} scope - The scope object containing schema info
|
|
7
|
+
* @param {Object} deps - Dependencies object
|
|
8
|
+
* @returns {Object} Object with belongsToUpdates and manyToManyRelationships arrays
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // Input: Simple belongsTo relationship
|
|
12
|
+
* const inputRecord = {
|
|
13
|
+
* data: {
|
|
14
|
+
* type: 'articles',
|
|
15
|
+
* attributes: { title: 'My Article' },
|
|
16
|
+
* relationships: {
|
|
17
|
+
* author: {
|
|
18
|
+
* data: { type: 'users', id: '123' }
|
|
19
|
+
* },
|
|
20
|
+
* category: {
|
|
21
|
+
* data: { type: 'categories', id: '5' }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* };
|
|
26
|
+
*
|
|
27
|
+
* // Schema has:
|
|
28
|
+
* // author_id: { belongsTo: 'users', as: 'author' }
|
|
29
|
+
* // category_id: { belongsTo: 'categories', as: 'category' }
|
|
30
|
+
*
|
|
31
|
+
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
32
|
+
*
|
|
33
|
+
* // Output: Foreign keys extracted
|
|
34
|
+
* // {
|
|
35
|
+
* // belongsToUpdates: {
|
|
36
|
+
* // author_id: '123',
|
|
37
|
+
* // category_id: '5'
|
|
38
|
+
* // },
|
|
39
|
+
* // manyToManyRelationships: []
|
|
40
|
+
* // }
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* // Input: Polymorphic relationship
|
|
44
|
+
* const inputRecord = {
|
|
45
|
+
* data: {
|
|
46
|
+
* type: 'comments',
|
|
47
|
+
* relationships: {
|
|
48
|
+
* commentable: {
|
|
49
|
+
* data: { type: 'posts', id: '456' }
|
|
50
|
+
* }
|
|
51
|
+
* }
|
|
52
|
+
* }
|
|
53
|
+
* };
|
|
54
|
+
*
|
|
55
|
+
* // Schema relationships has:
|
|
56
|
+
* // commentable: {
|
|
57
|
+
* // belongsToPolymorphic: {
|
|
58
|
+
* // types: ['posts', 'videos'],
|
|
59
|
+
* // typeField: 'commentable_type',
|
|
60
|
+
* // idField: 'commentable_id'
|
|
61
|
+
* // }
|
|
62
|
+
* // }
|
|
63
|
+
*
|
|
64
|
+
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
65
|
+
*
|
|
66
|
+
* // Output: Both type and id fields set
|
|
67
|
+
* // {
|
|
68
|
+
* // belongsToUpdates: {
|
|
69
|
+
* // commentable_type: 'posts',
|
|
70
|
+
* // commentable_id: '456'
|
|
71
|
+
* // },
|
|
72
|
+
* // manyToManyRelationships: []
|
|
73
|
+
* // }
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* // Input: Many-to-many relationship
|
|
77
|
+
* const inputRecord = {
|
|
78
|
+
* data: {
|
|
79
|
+
* type: 'articles',
|
|
80
|
+
* relationships: {
|
|
81
|
+
* tags: {
|
|
82
|
+
* data: [
|
|
83
|
+
* { type: 'tags', id: '10' },
|
|
84
|
+
* { type: 'tags', id: '20' }
|
|
85
|
+
* ]
|
|
86
|
+
* }
|
|
87
|
+
* }
|
|
88
|
+
* }
|
|
89
|
+
* };
|
|
90
|
+
*
|
|
91
|
+
* // Schema relationships has:
|
|
92
|
+
* // tags: {
|
|
93
|
+
* // hasMany: 'tags',
|
|
94
|
+
* // through: 'article_tags',
|
|
95
|
+
* // foreignKey: 'article_id',
|
|
96
|
+
* // otherKey: 'tag_id'
|
|
97
|
+
* // }
|
|
98
|
+
*
|
|
99
|
+
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
100
|
+
*
|
|
101
|
+
* // Output: Many-to-many data collected
|
|
102
|
+
* // {
|
|
103
|
+
* // belongsToUpdates: {},
|
|
104
|
+
* // manyToManyRelationships: [{
|
|
105
|
+
* // relName: 'tags',
|
|
106
|
+
* // relDef: {
|
|
107
|
+
* // through: 'article_tags',
|
|
108
|
+
* // foreignKey: 'article_id',
|
|
109
|
+
* // otherKey: 'tag_id'
|
|
110
|
+
* // },
|
|
111
|
+
* // relData: [
|
|
112
|
+
* // { type: 'tags', id: '10' },
|
|
113
|
+
* // { type: 'tags', id: '20' }
|
|
114
|
+
* // ]
|
|
115
|
+
* // }]
|
|
116
|
+
* // }
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // Input: Clearing relationships
|
|
120
|
+
* const inputRecord = {
|
|
121
|
+
* data: {
|
|
122
|
+
* relationships: {
|
|
123
|
+
* author: { data: null }, // Clear belongsTo
|
|
124
|
+
* tags: { data: [] } // Clear many-to-many
|
|
125
|
+
* }
|
|
126
|
+
* }
|
|
127
|
+
* };
|
|
128
|
+
*
|
|
129
|
+
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
130
|
+
*
|
|
131
|
+
* // Output: Null for belongsTo, empty array for many-to-many
|
|
132
|
+
* // {
|
|
133
|
+
* // belongsToUpdates: { author_id: null },
|
|
134
|
+
* // manyToManyRelationships: [{
|
|
135
|
+
* // relName: 'tags',
|
|
136
|
+
* // relDef: { ... },
|
|
137
|
+
* // relData: [] // Will delete all pivot records
|
|
138
|
+
* // }]
|
|
139
|
+
* // }
|
|
140
|
+
*
|
|
141
|
+
* @description
|
|
142
|
+
* Used by:
|
|
143
|
+
* - rest-api-knex-plugin's dataPut and dataPatch methods
|
|
144
|
+
* - Called before database writes to prepare relationship updates
|
|
145
|
+
*
|
|
146
|
+
* Purpose:
|
|
147
|
+
* - Converts JSON:API relationship format to database operations
|
|
148
|
+
* - Handles all relationship types: belongsTo, polymorphic, many-to-many
|
|
149
|
+
* - Validates polymorphic types against allowed values
|
|
150
|
+
* - Separates concerns: foreign keys vs pivot table operations
|
|
151
|
+
*
|
|
152
|
+
* Data flow:
|
|
153
|
+
* 1. Receives JSON:API document with relationships section
|
|
154
|
+
* 2. Analyzes schema to determine relationship types
|
|
155
|
+
* 3. For belongsTo: extracts foreign key values
|
|
156
|
+
* 4. For polymorphic: extracts both type and id fields
|
|
157
|
+
* 5. For many-to-many: collects data for pivot operations
|
|
158
|
+
* 6. Returns structured data for database updates
|
|
159
|
+
*/
|
|
160
|
+
export const processRelationships = (scope, deps) => {
|
|
161
|
+
// Extract values from scope
|
|
162
|
+
const {
|
|
163
|
+
vars: {
|
|
164
|
+
schemaInfo: { schemaStructure: schemaFields, schemaRelationships: relationships }
|
|
165
|
+
}
|
|
166
|
+
} = scope;
|
|
167
|
+
|
|
168
|
+
// Extract values from deps
|
|
169
|
+
const { context } = deps;
|
|
170
|
+
const { inputRecord } = context;
|
|
171
|
+
const belongsToUpdates = {};
|
|
172
|
+
const manyToManyRelationships = [];
|
|
173
|
+
|
|
174
|
+
if (!inputRecord.data.relationships) {
|
|
175
|
+
return { belongsToUpdates, manyToManyRelationships };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
|
|
179
|
+
const relDef = relationships?.[relName];
|
|
180
|
+
|
|
181
|
+
// Find the schema field that defines this relationship
|
|
182
|
+
const schemaField = Object.entries(schemaFields).find(([fieldName, fieldDef]) =>
|
|
183
|
+
fieldDef.as === relName
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (schemaField) {
|
|
187
|
+
const [fieldName, fieldDef] = schemaField;
|
|
188
|
+
|
|
189
|
+
// Handle regular belongsTo (1:1)
|
|
190
|
+
if (fieldDef.belongsTo) {
|
|
191
|
+
if (relData.data === null) {
|
|
192
|
+
belongsToUpdates[fieldName] = null;
|
|
193
|
+
} else if (relData.data?.id) {
|
|
194
|
+
belongsToUpdates[fieldName] = relData.data.id;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check for polymorphic belongsTo defined in relationships object (not as schema field)
|
|
200
|
+
if (!schemaField && relDef?.belongsToPolymorphic && relData.data !== undefined) {
|
|
201
|
+
if (relData.data === null) {
|
|
202
|
+
const { typeField, idField } = relDef.belongsToPolymorphic;
|
|
203
|
+
belongsToUpdates[typeField] = null;
|
|
204
|
+
belongsToUpdates[idField] = null;
|
|
205
|
+
} else if (relData.data) {
|
|
206
|
+
const { type, id } = relData.data;
|
|
207
|
+
const { types, typeField, idField } = relDef.belongsToPolymorphic;
|
|
208
|
+
|
|
209
|
+
// Validate type is allowed
|
|
210
|
+
if (!types.includes(type)) {
|
|
211
|
+
throw new RestApiValidationError(
|
|
212
|
+
`Invalid type '${type}' for polymorphic relationship '${relName}'. Allowed types: ${types.join(', ')}`,
|
|
213
|
+
{
|
|
214
|
+
fields: [`data.relationships.${relName}.data.type`],
|
|
215
|
+
violations: [{
|
|
216
|
+
field: `data.relationships.${relName}.data.type`,
|
|
217
|
+
rule: 'polymorphic_type',
|
|
218
|
+
message: `Type must be one of: ${types.join(', ')}`
|
|
219
|
+
}]
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
belongsToUpdates[typeField] = type;
|
|
225
|
+
belongsToUpdates[idField] = id;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check for many-to-many relationships defined in relationships object
|
|
230
|
+
if (relDef?.manyToMany && relData.data !== undefined) {
|
|
231
|
+
manyToManyRelationships.push({
|
|
232
|
+
relName,
|
|
233
|
+
relDef: relDef.manyToMany, // Pass the manyToMany object, not the whole relationship
|
|
234
|
+
relData: relData.data || [] // null means empty array for many-to-many
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// Also check for hasMany with through (alternative many-to-many syntax)
|
|
238
|
+
else if (relDef?.hasMany && relDef?.through && relData.data !== undefined) {
|
|
239
|
+
manyToManyRelationships.push({
|
|
240
|
+
relName,
|
|
241
|
+
relDef: {
|
|
242
|
+
through: relDef.through,
|
|
243
|
+
foreignKey: relDef.foreignKey,
|
|
244
|
+
otherKey: relDef.otherKey
|
|
245
|
+
},
|
|
246
|
+
relData: relData.data || [] // null means empty array for many-to-many
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { belongsToUpdates, manyToManyRelationships };
|
|
252
|
+
};
|