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
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates cross-table search helper functions that enable filtering across related database tables
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
|
|
5
|
+
* @param {Object} log - Logger instance for debug and trace output
|
|
6
|
+
* @returns {Object} Object containing validateCrossTableField, buildJoinChain, analyzeRequiredIndexes, and createRequiredIndexes functions
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // Input: API scopes with relationships defined
|
|
10
|
+
* const scopes = {
|
|
11
|
+
* articles: { vars: { schemaInfo: { schema: articlesSchema, tableName: 'articles' } } },
|
|
12
|
+
* authors: { vars: { schemaInfo: { schema: authorsSchema, tableName: 'authors' } } },
|
|
13
|
+
* companies: { vars: { schemaInfo: { schema: companiesSchema, tableName: 'companies' } } }
|
|
14
|
+
* };
|
|
15
|
+
*
|
|
16
|
+
* const helpers = createCrossTableSearchHelpers(scopes, logger);
|
|
17
|
+
*
|
|
18
|
+
* // Output: Object with helper functions
|
|
19
|
+
* // {
|
|
20
|
+
* // validateCrossTableField: [Function],
|
|
21
|
+
* // buildJoinChain: [Function],
|
|
22
|
+
* // analyzeRequiredIndexes: [Function],
|
|
23
|
+
* // createRequiredIndexes: [Function]
|
|
24
|
+
* // }
|
|
25
|
+
*
|
|
26
|
+
* @description
|
|
27
|
+
* Used by:
|
|
28
|
+
* - rest-api-knex-plugin uses this during dataQuery to enable cross-table filtering
|
|
29
|
+
* - Called when filter parameters reference fields from related tables (e.g., filter[author.name]=John)
|
|
30
|
+
*
|
|
31
|
+
* Purpose:
|
|
32
|
+
* - Enables powerful filtering across relationships without exposing SQL complexity to API consumers
|
|
33
|
+
* - Automatically builds optimal JOIN chains based on schema relationships
|
|
34
|
+
* - Prevents N+1 queries by using efficient SQL JOINs instead of multiple queries
|
|
35
|
+
*
|
|
36
|
+
* Data flow:
|
|
37
|
+
* 1. API receives filter like filter[author.company.name]=Acme
|
|
38
|
+
* 2. rest-api-knex-plugin detects cross-table reference (contains dots)
|
|
39
|
+
* 3. Calls buildJoinChain to construct SQL JOIN path from articles → authors → companies
|
|
40
|
+
* 4. Adds JOINs to main query, enabling filtering on company name while querying articles
|
|
41
|
+
* 5. Returns articles where author's company name matches 'Acme'
|
|
42
|
+
*/
|
|
43
|
+
export const createCrossTableSearchHelpers = (scopes, log) => {
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validates that a field exists and is indexed for cross-table search
|
|
47
|
+
*
|
|
48
|
+
* @async
|
|
49
|
+
* @param {string} targetScopeName - The scope containing the field to validate
|
|
50
|
+
* @param {string} fieldName - The field name to check
|
|
51
|
+
* @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
|
|
52
|
+
* @returns {Promise<{targetSchema: Object, fieldDef: Object}>} The validated schema and field definition
|
|
53
|
+
* @throws {Error} If scope not found, field not found, field not indexed, or circular reference detected
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* // Input: Validate indexed field
|
|
57
|
+
* // Schema has: companies.name with indexed: true
|
|
58
|
+
* const result = await validateCrossTableField('companies', 'name');
|
|
59
|
+
*
|
|
60
|
+
* // Output: Successfully returns schema and field definition
|
|
61
|
+
* // {
|
|
62
|
+
* // targetSchema: { structure: { name: { type: 'string', indexed: true }, ... } },
|
|
63
|
+
* // fieldDef: { type: 'string', indexed: true }
|
|
64
|
+
* // }
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* // Input: Non-indexed field
|
|
68
|
+
* // Schema has: companies.internal_code without indexed property
|
|
69
|
+
* try {
|
|
70
|
+
* await validateCrossTableField('companies', 'internal_code');
|
|
71
|
+
* } catch (error) {
|
|
72
|
+
* console.log(error.message);
|
|
73
|
+
* // "Field 'companies.internal_code' is not indexed. Add 'indexed: true' to allow cross-table search"
|
|
74
|
+
* }
|
|
75
|
+
*
|
|
76
|
+
* @description
|
|
77
|
+
* Used by:
|
|
78
|
+
* - buildJoinChain calls this to ensure target fields are properly indexed
|
|
79
|
+
* - analyzeRequiredIndexes uses this to identify missing indexes
|
|
80
|
+
*
|
|
81
|
+
* Purpose:
|
|
82
|
+
* - Cross-table searches can be slow without proper indexes
|
|
83
|
+
* - Forces developers to explicitly mark searchable fields with indexed: true
|
|
84
|
+
* - Provides clear error messages when configuration is missing
|
|
85
|
+
*
|
|
86
|
+
* Data flow:
|
|
87
|
+
* - Called during query building to validate field configuration
|
|
88
|
+
* - Ensures performance by requiring indexes on searchable fields
|
|
89
|
+
* - Part of the validation phase before expensive JOIN operations
|
|
90
|
+
*/
|
|
91
|
+
const validateCrossTableField = async (targetScopeName, fieldName, searchedScopes = new Set()) => {
|
|
92
|
+
log.trace('[VALIDATE] Starting validateCrossTableField:', { targetScopeName, fieldName, searchedScopes: Array.from(searchedScopes) });
|
|
93
|
+
|
|
94
|
+
if (searchedScopes.has(targetScopeName)) {
|
|
95
|
+
throw new Error(`Circular reference detected: ${targetScopeName} -> ${Array.from(searchedScopes).join(' -> ')}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log.trace('[VALIDATE] Getting target schema for:', targetScopeName);
|
|
99
|
+
let targetSchema;
|
|
100
|
+
try {
|
|
101
|
+
targetSchema = scopes[targetScopeName].vars.schemaInfo.schema;;
|
|
102
|
+
log.trace('[VALIDATE] Got target schema:', { scopeName: targetScopeName, schemaKeys: Object.keys(targetSchema || {}) });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
log.trace('[VALIDATE] Error getting target schema:', error.message);
|
|
105
|
+
throw new Error(`Target scope '${targetScopeName}' not found`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!targetSchema) {
|
|
109
|
+
log.trace('[VALIDATE] Target schema is null/undefined');
|
|
110
|
+
throw new Error(`Target scope '${targetScopeName}' has no schema`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fieldDef = targetSchema.structure[fieldName];
|
|
114
|
+
log.trace('[VALIDATE] Field lookup result:', { fieldName, fieldExists: !!fieldDef });
|
|
115
|
+
if (!fieldDef) {
|
|
116
|
+
throw new Error(`Field '${fieldName}' not found in scope '${targetScopeName}'`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log.trace('[VALIDATE] Checking indexed status:', { fieldName, indexed: fieldDef.indexed });
|
|
120
|
+
if (!fieldDef.indexed) {
|
|
121
|
+
throw new Error(`Field '${targetScopeName}.${fieldName}' is not indexed. Add 'indexed: true' to allow cross-table search`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = { targetSchema, fieldDef };
|
|
125
|
+
log.trace('[VALIDATE] Validation successful for field:', `${targetScopeName}.${fieldName}`);
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Builds a chain of SQL JOINs to reach a field in a related table
|
|
131
|
+
*
|
|
132
|
+
* @async
|
|
133
|
+
* @param {string} fromScopeName - Starting scope (e.g., 'articles')
|
|
134
|
+
* @param {string} targetPath - Dot-separated path to target field (e.g., 'author.company.name')
|
|
135
|
+
* @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
|
|
136
|
+
* @returns {Promise<Object>} JOIN information for query builder
|
|
137
|
+
* @throws {Error} If path is invalid or relationships not properly configured
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* // Input: Simple belongsTo relationship
|
|
141
|
+
* // articles table has author_id field, authors schema defines belongsTo relationship
|
|
142
|
+
* const joinInfo = await buildJoinChain('articles', 'author.name');
|
|
143
|
+
*
|
|
144
|
+
* // Output: Single JOIN configuration
|
|
145
|
+
* // {
|
|
146
|
+
* // joinAlias: 'articles_to_authors_authors',
|
|
147
|
+
* // targetTableName: 'authors',
|
|
148
|
+
* // sourceField: 'author_id',
|
|
149
|
+
* // targetField: 'name',
|
|
150
|
+
* // joinCondition: 'articles.author_id = articles_to_authors_authors.id',
|
|
151
|
+
* // isOneToMany: false,
|
|
152
|
+
* // isPolymorphic: false
|
|
153
|
+
* // }
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* // Input: Multi-level path through relationships
|
|
157
|
+
* // articles → authors → companies
|
|
158
|
+
* const joinInfo = await buildJoinChain('articles', 'author.company.name');
|
|
159
|
+
*
|
|
160
|
+
* // Output: Multi-level JOIN chain
|
|
161
|
+
* // {
|
|
162
|
+
* // targetTableName: 'companies',
|
|
163
|
+
* // joinAlias: 'authors_to_companies_companies',
|
|
164
|
+
* // joinCondition: 'articles.author_id = articles_to_authors_authors.id AND articles_to_authors_authors.company_id = authors_to_companies_companies.id',
|
|
165
|
+
* // targetField: 'name',
|
|
166
|
+
* // isOneToMany: false,
|
|
167
|
+
* // isMultiLevel: true,
|
|
168
|
+
* // joinChain: [
|
|
169
|
+
* // {
|
|
170
|
+
* // targetTableName: 'authors',
|
|
171
|
+
* // joinAlias: 'articles_to_authors_authors',
|
|
172
|
+
* // joinCondition: 'articles.author_id = articles_to_authors_authors.id',
|
|
173
|
+
* // isOneToMany: false,
|
|
174
|
+
* // relationshipField: 'author_id',
|
|
175
|
+
* // relationshipType: 'belongsTo'
|
|
176
|
+
* // },
|
|
177
|
+
* // {
|
|
178
|
+
* // targetTableName: 'companies',
|
|
179
|
+
* // joinAlias: 'authors_to_companies_companies',
|
|
180
|
+
* // joinCondition: 'articles_to_authors_authors.company_id = authors_to_companies_companies.id',
|
|
181
|
+
* // isOneToMany: false,
|
|
182
|
+
* // relationshipField: 'company_id',
|
|
183
|
+
* // relationshipType: 'belongsTo'
|
|
184
|
+
* // }
|
|
185
|
+
* // ]
|
|
186
|
+
* // }
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* // Input: One-to-many relationship (hasMany)
|
|
190
|
+
* // authors have many articles
|
|
191
|
+
* const joinInfo = await buildJoinChain('authors', 'articles.title');
|
|
192
|
+
*
|
|
193
|
+
* // Output: JOIN with isOneToMany flag
|
|
194
|
+
* // {
|
|
195
|
+
* // joinAlias: 'authors_to_articles_articles',
|
|
196
|
+
* // targetTableName: 'articles',
|
|
197
|
+
* // sourceField: 'author_id',
|
|
198
|
+
* // targetField: 'title',
|
|
199
|
+
* // joinCondition: 'authors.id = authors_to_articles_articles.author_id',
|
|
200
|
+
* // isOneToMany: true,
|
|
201
|
+
* // isPolymorphic: false
|
|
202
|
+
* // }
|
|
203
|
+
*
|
|
204
|
+
* @description
|
|
205
|
+
* Used by:
|
|
206
|
+
* - rest-api-knex-plugin's dataQuery method when processing cross-table filters
|
|
207
|
+
* - Called for each filter that references a related table field
|
|
208
|
+
*
|
|
209
|
+
* Purpose:
|
|
210
|
+
* - Automatically constructs complex SQL JOINs from simple dot notation
|
|
211
|
+
* - Supports many-to-one (belongsTo), one-to-many (hasMany), and many-to-many relationships
|
|
212
|
+
* - Handles polymorphic relationships and multi-level paths
|
|
213
|
+
*
|
|
214
|
+
* Data flow:
|
|
215
|
+
* 1. Filter parser identifies cross-table reference (contains dots)
|
|
216
|
+
* 2. buildJoinChain analyzes relationship path segment by segment
|
|
217
|
+
* 3. For each segment, finds the appropriate relationship definition
|
|
218
|
+
* 4. Constructs JOIN conditions based on foreign keys
|
|
219
|
+
* 5. Returns complete JOIN information for SQL query builder
|
|
220
|
+
* 6. Query builder adds these JOINs to enable filtering on related data
|
|
221
|
+
*/
|
|
222
|
+
const buildJoinChain = async (fromScopeName, targetPath, searchedScopes = new Set()) => {
|
|
223
|
+
log.trace('[BUILD-JOIN] Starting buildJoinChain:', { fromScopeName, targetPath });
|
|
224
|
+
|
|
225
|
+
const pathSegments = targetPath.split('.');
|
|
226
|
+
if (pathSegments.length < 2) {
|
|
227
|
+
throw new Error(`Invalid cross-table path '${targetPath}'. Must contain at least 'scope.field'`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const targetFieldName = pathSegments[pathSegments.length - 1];
|
|
231
|
+
const relationshipPath = pathSegments.slice(0, -1);
|
|
232
|
+
|
|
233
|
+
log.trace('[BUILD-JOIN] Parsed path:', { relationshipPath, targetFieldName });
|
|
234
|
+
|
|
235
|
+
const joinChain = [];
|
|
236
|
+
let currentScope = fromScopeName;
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < relationshipPath.length; i++) {
|
|
239
|
+
const targetScope = relationshipPath[i];
|
|
240
|
+
|
|
241
|
+
log.trace('[BUILD-JOIN] Looking for direct relationship:', { from: currentScope, to: targetScope });
|
|
242
|
+
|
|
243
|
+
if (searchedScopes.has(currentScope)) {
|
|
244
|
+
throw new Error(`Circular reference detected in path: ${Array.from(searchedScopes).join(' -> ')} -> ${currentScope}`);
|
|
245
|
+
}
|
|
246
|
+
searchedScopes.add(currentScope);
|
|
247
|
+
|
|
248
|
+
const schemaInfo = scopes[currentScope].vars.schemaInfo
|
|
249
|
+
const currentSchema = schemaInfo.schema;
|
|
250
|
+
const currentRelationships = schemaInfo.schemaRelationships;
|
|
251
|
+
|
|
252
|
+
let foundRelationship = null;
|
|
253
|
+
let relationshipType = null;
|
|
254
|
+
let relationshipField = null;
|
|
255
|
+
let relationshipDef = null;
|
|
256
|
+
|
|
257
|
+
if (currentRelationships) {
|
|
258
|
+
for (const [relName, relDef] of Object.entries(currentRelationships)) {
|
|
259
|
+
if (relDef.hasMany === targetScope) {
|
|
260
|
+
if (relDef.via) {
|
|
261
|
+
const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships;
|
|
262
|
+
const viaRel = targetRelationships?.[relDef.via];
|
|
263
|
+
|
|
264
|
+
if (viaRel?.belongsToPolymorphic) {
|
|
265
|
+
const { typeField, idField } = viaRel.belongsToPolymorphic;
|
|
266
|
+
foundRelationship = targetScope;
|
|
267
|
+
relationshipType = 'hasManyPolymorphic';
|
|
268
|
+
relationshipField = { typeField, idField, via: relDef.via };
|
|
269
|
+
relationshipDef = relDef;
|
|
270
|
+
log.trace('[BUILD-JOIN] Found polymorphic hasMany relationship:', {
|
|
271
|
+
relName, targetScope, via: relDef.via, typeField, idField
|
|
272
|
+
});
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (relDef.through) {
|
|
278
|
+
foundRelationship = targetScope;
|
|
279
|
+
relationshipType = 'manyToMany';
|
|
280
|
+
relationshipField = {
|
|
281
|
+
through: relDef.through,
|
|
282
|
+
foreignKey: relDef.foreignKey,
|
|
283
|
+
otherKey: relDef.otherKey
|
|
284
|
+
};
|
|
285
|
+
relationshipDef = relDef;
|
|
286
|
+
log.trace('[BUILD-JOIN] Found many-to-many relationship:', {
|
|
287
|
+
relName,
|
|
288
|
+
targetScope,
|
|
289
|
+
through: relDef.through,
|
|
290
|
+
foreignKey: relDef.foreignKey,
|
|
291
|
+
otherKey: relDef.otherKey
|
|
292
|
+
});
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
foundRelationship = targetScope;
|
|
297
|
+
relationshipType = 'hasMany';
|
|
298
|
+
relationshipField = relDef.foreignKey;
|
|
299
|
+
if (!relationshipField) {
|
|
300
|
+
log.error('[BUILD-JOIN] Missing foreignKey in hasMany relationship:', { relName, currentScope });
|
|
301
|
+
throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${currentScope}'`);
|
|
302
|
+
}
|
|
303
|
+
relationshipDef = relDef;
|
|
304
|
+
log.trace('[BUILD-JOIN] Found hasMany relationship:', { relName, targetScope, foreignKey: relationshipField });
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!foundRelationship) {
|
|
311
|
+
for (const [fieldName, fieldDef] of Object.entries(currentSchema.structure)) {
|
|
312
|
+
if (fieldDef.belongsTo === targetScope) {
|
|
313
|
+
foundRelationship = targetScope;
|
|
314
|
+
relationshipType = 'belongsTo';
|
|
315
|
+
relationshipField = fieldName;
|
|
316
|
+
relationshipDef = fieldDef;
|
|
317
|
+
log.trace('[BUILD-JOIN] Found belongsTo relationship:', { fieldName, targetScope });
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!foundRelationship) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`No searchable relationship from '${currentScope}' to '${targetScope}'. `
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sourceSchema = scopes[currentScope].vars.schemaInfo.schema;
|
|
330
|
+
const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
|
|
331
|
+
const targetSchema = scopes[targetScope].vars.schemaInfo.schema;
|
|
332
|
+
const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
|
|
333
|
+
|
|
334
|
+
if (relationshipType === 'manyToMany') {
|
|
335
|
+
const { through, foreignKey, otherKey } = relationshipField;
|
|
336
|
+
const pivotTableName = scopes[through].vars.schemaInfo.tableName || through;
|
|
337
|
+
const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
|
|
338
|
+
|
|
339
|
+
const pivotAlias = `${currentScope}_to_${through}_${through}`;
|
|
340
|
+
const pivotJoinCondition = `${previousAlias}.id = ${pivotAlias}.${foreignKey}`;
|
|
341
|
+
|
|
342
|
+
joinChain.push({
|
|
343
|
+
targetTableName: pivotTableName,
|
|
344
|
+
joinAlias: pivotAlias,
|
|
345
|
+
joinCondition: pivotJoinCondition,
|
|
346
|
+
isOneToMany: true,
|
|
347
|
+
isPolymorphic: false,
|
|
348
|
+
relationshipField: foreignKey,
|
|
349
|
+
relationshipType: 'manyToMany_pivot'
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const targetAlias = `${through}_to_${targetScope}_${targetScope}`;
|
|
353
|
+
const targetJoinCondition = `${pivotAlias}.${otherKey} = ${targetAlias}.id`;
|
|
354
|
+
|
|
355
|
+
joinChain.push({
|
|
356
|
+
targetTableName,
|
|
357
|
+
joinAlias: targetAlias,
|
|
358
|
+
joinCondition: targetJoinCondition,
|
|
359
|
+
isOneToMany: false,
|
|
360
|
+
isPolymorphic: false,
|
|
361
|
+
relationshipField: otherKey,
|
|
362
|
+
relationshipType: 'manyToMany_target'
|
|
363
|
+
});
|
|
364
|
+
} else {
|
|
365
|
+
const joinAlias = `${currentScope}_to_${targetScope}_${targetScope}`;
|
|
366
|
+
|
|
367
|
+
let joinCondition;
|
|
368
|
+
if (relationshipType === 'hasManyPolymorphic') {
|
|
369
|
+
const { typeField, idField } = relationshipField;
|
|
370
|
+
const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
|
|
371
|
+
joinCondition = `${joinAlias}.${typeField} = '${currentScope}' AND ${joinAlias}.${idField} = ${previousAlias}.id`;
|
|
372
|
+
} else if (relationshipType === 'hasMany') {
|
|
373
|
+
const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
|
|
374
|
+
joinCondition = `${previousAlias}.id = ${joinAlias}.${relationshipField}`;
|
|
375
|
+
} else {
|
|
376
|
+
const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
|
|
377
|
+
joinCondition = `${previousAlias}.${relationshipField} = ${joinAlias}.id`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
joinChain.push({
|
|
381
|
+
targetTableName,
|
|
382
|
+
joinAlias,
|
|
383
|
+
joinCondition,
|
|
384
|
+
isOneToMany: relationshipType === 'hasMany' || relationshipType === 'hasManyPolymorphic',
|
|
385
|
+
isPolymorphic: relationshipType === 'hasManyPolymorphic',
|
|
386
|
+
relationshipField,
|
|
387
|
+
relationshipType
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
currentScope = targetScope;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const finalScope = relationshipPath[relationshipPath.length - 1];
|
|
395
|
+
await validateCrossTableField(finalScope, targetFieldName, searchedScopes);
|
|
396
|
+
|
|
397
|
+
if (joinChain.length === 0) {
|
|
398
|
+
throw new Error(`Invalid path '${targetPath}': no relationships to traverse`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const lastJoin = joinChain[joinChain.length - 1];
|
|
402
|
+
|
|
403
|
+
if (joinChain.length > 1) {
|
|
404
|
+
return {
|
|
405
|
+
targetTableName: lastJoin.targetTableName,
|
|
406
|
+
joinAlias: lastJoin.joinAlias,
|
|
407
|
+
joinCondition: joinChain.map(j => j.joinCondition).join(' AND '),
|
|
408
|
+
targetField: targetFieldName,
|
|
409
|
+
isOneToMany: joinChain.some(j => j.isOneToMany),
|
|
410
|
+
isMultiLevel: true,
|
|
411
|
+
joinChain
|
|
412
|
+
};
|
|
413
|
+
} else {
|
|
414
|
+
return {
|
|
415
|
+
joinAlias: lastJoin.joinAlias,
|
|
416
|
+
targetTableName: lastJoin.targetTableName,
|
|
417
|
+
sourceField: lastJoin.relationshipField,
|
|
418
|
+
targetField: targetFieldName,
|
|
419
|
+
joinCondition: lastJoin.joinCondition,
|
|
420
|
+
isOneToMany: lastJoin.isOneToMany,
|
|
421
|
+
isPolymorphic: lastJoin.isPolymorphic
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Analyzes a search schema to identify which fields need database indexes
|
|
428
|
+
*
|
|
429
|
+
* @param {string} scopeName - The scope being analyzed
|
|
430
|
+
* @param {Object} searchSchema - Search schema definition with filter fields
|
|
431
|
+
* @returns {Array<Object>} Array of required indexes with scope, field, and reason
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* // Input: Search schema with cross-table references
|
|
435
|
+
* const searchSchema = {
|
|
436
|
+
* authorName: {
|
|
437
|
+
* type: 'string',
|
|
438
|
+
* actualField: 'authors.name',
|
|
439
|
+
* filterOperator: 'like'
|
|
440
|
+
* },
|
|
441
|
+
* companyName: {
|
|
442
|
+
* type: 'string',
|
|
443
|
+
* actualField: 'authors.company.name'
|
|
444
|
+
* },
|
|
445
|
+
* search: {
|
|
446
|
+
* type: 'string',
|
|
447
|
+
* oneOf: ['title', 'authors.name', 'authors.company.name']
|
|
448
|
+
* }
|
|
449
|
+
* };
|
|
450
|
+
*
|
|
451
|
+
* const requiredIndexes = analyzeRequiredIndexes('articles', searchSchema);
|
|
452
|
+
*
|
|
453
|
+
* // Output: List of fields that need indexes
|
|
454
|
+
* // [
|
|
455
|
+
* // { scope: 'authors', field: 'name', reason: 'Cross-table search from articles.authorName' },
|
|
456
|
+
* // { scope: 'authors', field: 'company', reason: 'Cross-table search from articles.companyName' },
|
|
457
|
+
* // { scope: 'authors', field: 'name', reason: 'Cross-table oneOf search from articles.search' },
|
|
458
|
+
* // { scope: 'authors', field: 'company', reason: 'Cross-table oneOf search from articles.search' }
|
|
459
|
+
* // ]
|
|
460
|
+
*
|
|
461
|
+
* @example
|
|
462
|
+
* // Input: No cross-table references
|
|
463
|
+
* const searchSchema = {
|
|
464
|
+
* title: { type: 'string' },
|
|
465
|
+
* status: { type: 'string' }
|
|
466
|
+
* };
|
|
467
|
+
*
|
|
468
|
+
* const requiredIndexes = analyzeRequiredIndexes('articles', searchSchema);
|
|
469
|
+
*
|
|
470
|
+
* // Output: Empty array - no cross-table indexes needed
|
|
471
|
+
* // []
|
|
472
|
+
*
|
|
473
|
+
* @description
|
|
474
|
+
* Used by:
|
|
475
|
+
* - Called during API initialization to identify missing indexes
|
|
476
|
+
* - Used by developers to understand performance requirements
|
|
477
|
+
*
|
|
478
|
+
* Purpose:
|
|
479
|
+
* - Cross-table searches require indexes for acceptable performance
|
|
480
|
+
* - Helps identify configuration issues before they cause slow queries
|
|
481
|
+
* - Provides clear documentation of index requirements
|
|
482
|
+
*
|
|
483
|
+
* Data flow:
|
|
484
|
+
* - Runs during schema compilation phase
|
|
485
|
+
* - Analyzes search schemas to find cross-table references
|
|
486
|
+
* - Output can be used to create indexes manually or automatically
|
|
487
|
+
* - Prevents performance issues before queries are executed
|
|
488
|
+
*/
|
|
489
|
+
const analyzeRequiredIndexes = (scopeName, searchSchema) => {
|
|
490
|
+
const requiredIndexes = [];
|
|
491
|
+
|
|
492
|
+
const schemaToAnalyze = searchSchema.structure || searchSchema;
|
|
493
|
+
|
|
494
|
+
Object.entries(schemaToAnalyze).forEach(([filterKey, fieldDef]) => {
|
|
495
|
+
if (fieldDef.actualField && fieldDef.actualField.includes('.')) {
|
|
496
|
+
const [targetScopeName, targetFieldName] = fieldDef.actualField.split('.');
|
|
497
|
+
requiredIndexes.push({
|
|
498
|
+
scope: targetScopeName,
|
|
499
|
+
field: targetFieldName,
|
|
500
|
+
reason: `Cross-table search from ${scopeName}.${filterKey}`
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (fieldDef.oneOf && Array.isArray(fieldDef.oneOf)) {
|
|
505
|
+
fieldDef.oneOf.forEach(field => {
|
|
506
|
+
if (field.includes('.')) {
|
|
507
|
+
const [targetScopeName, targetFieldName] = field.split('.');
|
|
508
|
+
requiredIndexes.push({
|
|
509
|
+
scope: targetScopeName,
|
|
510
|
+
field: targetFieldName,
|
|
511
|
+
reason: `Cross-table oneOf search from ${scopeName}.${filterKey}`
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return requiredIndexes;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Creates database indexes for fields identified by analyzeRequiredIndexes
|
|
523
|
+
*
|
|
524
|
+
* @async
|
|
525
|
+
* @param {Array<Object>} requiredIndexes - Index requirements from analyzeRequiredIndexes
|
|
526
|
+
* @param {Object} knex - Knex database connection instance
|
|
527
|
+
* @returns {Promise<Array<Object>>} Array of successfully created indexes
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* // Input: Required indexes from analysis
|
|
531
|
+
* const requiredIndexes = [
|
|
532
|
+
* { scope: 'authors', field: 'name', reason: 'Cross-table search' },
|
|
533
|
+
* { scope: 'companies', field: 'name', reason: 'Cross-table search' }
|
|
534
|
+
* ];
|
|
535
|
+
*
|
|
536
|
+
* const createdIndexes = await createRequiredIndexes(requiredIndexes, knex);
|
|
537
|
+
*
|
|
538
|
+
* // Output: Successfully created indexes
|
|
539
|
+
* // [
|
|
540
|
+
* // { tableName: 'authors', field: 'name', indexName: 'idx_authors_name_search' },
|
|
541
|
+
* // { tableName: 'companies', field: 'name', indexName: 'idx_companies_name_search' }
|
|
542
|
+
* // ]
|
|
543
|
+
*
|
|
544
|
+
* // Database effect: CREATE INDEX idx_authors_name_search ON authors(name);
|
|
545
|
+
* // Database effect: CREATE INDEX idx_companies_name_search ON companies(name);
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* // Input: Some indexes already exist
|
|
549
|
+
* const requiredIndexes = [
|
|
550
|
+
* { scope: 'authors', field: 'name', reason: 'Cross-table search' }, // Already exists
|
|
551
|
+
* { scope: 'authors', field: 'email', reason: 'Cross-table search' } // New
|
|
552
|
+
* ];
|
|
553
|
+
*
|
|
554
|
+
* const createdIndexes = await createRequiredIndexes(requiredIndexes, knex);
|
|
555
|
+
*
|
|
556
|
+
* // Output: Only newly created indexes
|
|
557
|
+
* // [
|
|
558
|
+
* // { tableName: 'authors', field: 'email', indexName: 'idx_authors_email_search' }
|
|
559
|
+
* // ]
|
|
560
|
+
*
|
|
561
|
+
* @description
|
|
562
|
+
* Used by:
|
|
563
|
+
* - Can be called during database migration or setup
|
|
564
|
+
* - Used by developers to automatically create performance indexes
|
|
565
|
+
*
|
|
566
|
+
* Purpose:
|
|
567
|
+
* - Automates index creation based on search schema requirements
|
|
568
|
+
* - Ensures consistent index naming across the application
|
|
569
|
+
* - Safely handles cases where indexes already exist
|
|
570
|
+
*
|
|
571
|
+
* Data flow:
|
|
572
|
+
* - Typically runs during database setup or migration
|
|
573
|
+
* - Creates indexes identified by analyzeRequiredIndexes
|
|
574
|
+
* - Improves query performance for cross-table searches
|
|
575
|
+
* - Part of the database optimization phase
|
|
576
|
+
*/
|
|
577
|
+
const createRequiredIndexes = async (requiredIndexes, knex) => {
|
|
578
|
+
const createdIndexes = [];
|
|
579
|
+
|
|
580
|
+
for (const indexInfo of requiredIndexes) {
|
|
581
|
+
const { scope, field } = indexInfo;
|
|
582
|
+
const schema = scopes[scope].vars.schemaInfo.schema;
|
|
583
|
+
const tableName = scopes[scope].vars.schemaInfo.tableName
|
|
584
|
+
const indexName = `idx_${tableName}_${field}_search`;
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const hasIndex = await knex.schema.hasIndex(tableName, [field]);
|
|
588
|
+
if (!hasIndex) {
|
|
589
|
+
await knex.schema.table(tableName, table => {
|
|
590
|
+
table.index([field], indexName);
|
|
591
|
+
});
|
|
592
|
+
createdIndexes.push({ tableName, field, indexName });
|
|
593
|
+
log.info(`Created index: ${indexName} on ${tableName}.${field}`);
|
|
594
|
+
}
|
|
595
|
+
} catch (error) {
|
|
596
|
+
log.warn(`Failed to create index on ${tableName}.${field}:`, error.message);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return createdIndexes;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
validateCrossTableField,
|
|
605
|
+
buildJoinChain,
|
|
606
|
+
analyzeRequiredIndexes,
|
|
607
|
+
createRequiredIndexes
|
|
608
|
+
};
|
|
609
|
+
};
|