@memberjunction/metadata-sync 2.46.0 → 2.47.0

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/README.md CHANGED
@@ -74,18 +74,29 @@ The Metadata Sync tool bridges the gap between database-stored metadata and file
74
74
 
75
75
  ## Supported Entities
76
76
 
77
- ### Phase 1: AI Prompts (Current)
78
- - Full support for all AI Prompt fields
79
- - Markdown files for prompt content
80
- - Category-based organization
81
- - AI Prompt Models as embedded collections
82
-
83
- ### Future Phases
84
- - Templates
85
- - AI Models
86
- - AI Vendors
87
- - Query definitions
88
- - Any MJ entity with metadata
77
+ The tool works with any MemberJunction entity - both core system entities and user-created entities. Each entity type can have its own directory structure, file naming conventions, and related entity configurations.
78
+
79
+ ### Important Limitation: Database-Reflected Metadata
80
+
81
+ **This tool should NOT be used to modify metadata that is reflected from the underlying database catalog.** Examples include:
82
+ - Entity field data types
83
+ - Column lengths/precision
84
+ - Primary key definitions
85
+ - Foreign key relationships
86
+ - Table/column existence
87
+
88
+ These properties are designed to flow **from** the database catalog **up** into MJ metadata, not the other way around. Attempting to modify these via file sync could create inconsistencies between the metadata and actual database schema.
89
+
90
+ The tool is intended for managing business-level metadata such as:
91
+ - Descriptions and documentation
92
+ - Display names and user-facing text
93
+ - Categories and groupings
94
+ - Custom properties and settings
95
+ - AI prompts, templates, and other content
96
+ - Permissions and security settings
97
+ - Any other data that is not reflected **up** from the underlying system database catalogs
98
+
99
+ For more information about how CodeGen reflects system-level data from the database into the MJ metadata layer, see the [CodeGen documentation](../CodeGen/README.md).
89
100
 
90
101
  ## File Structure
91
102
 
@@ -97,6 +108,37 @@ The tool uses a hierarchical directory structure with cascading defaults:
97
108
  - External files (`.md`, `.html`, etc.) are referenced from the JSON files
98
109
  - Defaults cascade down through the folder hierarchy
99
110
 
111
+ ### File Format Options
112
+
113
+ #### Single Record per File (Default)
114
+ Each JSON file contains one record:
115
+ ```json
116
+ {
117
+ "fields": { ... },
118
+ "relatedEntities": { ... }
119
+ }
120
+ ```
121
+
122
+ #### Multiple Records per File (NEW)
123
+ JSON files can contain arrays of records:
124
+ ```json
125
+ [
126
+ {
127
+ "fields": { ... },
128
+ "relatedEntities": { ... }
129
+ },
130
+ {
131
+ "fields": { ... },
132
+ "relatedEntities": { ... }
133
+ }
134
+ ]
135
+ ```
136
+
137
+ This is useful for:
138
+ - Grouping related records in a single file
139
+ - Reducing file clutter for entities with many small records
140
+ - Maintaining logical groupings while using `@file:` references for large content
141
+
100
142
  ### Example Structure
101
143
  ```
102
144
  metadata/
@@ -112,7 +154,12 @@ metadata/
112
154
  │ ├── .mj-folder.json # Folder metadata (CategoryID, etc.)
113
155
  │ ├── daily-report.json # AI Prompt record
114
156
  │ └── daily-report.prompt.md # Prompt content (referenced)
115
- └── templates/
157
+ ├── templates/ # Reusable JSON templates
158
+ │ ├── standard-prompt-settings.json # Common prompt configurations
159
+ │ ├── standard-ai-models.json # Standard model configurations
160
+ │ ├── high-performance-models.json # High-power model configurations
161
+ │ └── customer-service-defaults.json # CS-specific defaults
162
+ └── template-entities/
116
163
  ├── .mj-sync.json # Defines entity: "Templates"
117
164
  ├── email/
118
165
  │ ├── .mj-folder.json # Folder metadata
@@ -302,6 +349,66 @@ Support environment-specific values:
302
349
  - `@env:VARIABLE_NAME`
303
350
  - Useful for different environments (dev/staging/prod)
304
351
 
352
+ ### @template: References (NEW)
353
+ Enable JSON template composition for reusable configurations:
354
+
355
+ #### String Template Reference
356
+ Use `@template:` to replace any value with template content:
357
+ ```json
358
+ {
359
+ "relatedEntities": {
360
+ "MJ: AI Prompt Models": "@template:templates/standard-ai-models.json"
361
+ }
362
+ }
363
+ ```
364
+
365
+ #### Object Template Merging
366
+ Use `@template` field within objects to merge template content:
367
+ ```json
368
+ {
369
+ "fields": {
370
+ "Name": "My Prompt",
371
+ "@template": "templates/standard-prompt-settings.json",
372
+ "Temperature": 0.9 // Overrides template value
373
+ }
374
+ }
375
+ ```
376
+
377
+ #### Multiple Template Merging
378
+ Merge multiple templates in order (later templates override earlier ones):
379
+ ```json
380
+ {
381
+ "fields": {
382
+ "@template": [
383
+ "templates/base-settings.json",
384
+ "templates/customer-service-defaults.json"
385
+ ],
386
+ "Name": "Customer Bot" // Local fields override all templates
387
+ }
388
+ }
389
+ ```
390
+
391
+ #### Nested Templates
392
+ Templates can reference other templates:
393
+ ```json
394
+ // templates/high-performance-models.json
395
+ [
396
+ {
397
+ "fields": {
398
+ "@template": "../templates/model-defaults.json",
399
+ "ModelID": "@lookup:AI Models.Name=GPT 4o"
400
+ }
401
+ }
402
+ ]
403
+ ```
404
+
405
+ #### Template Benefits
406
+ - **DRY Principle**: Define configurations once, use everywhere
407
+ - **Maintainability**: Update template to affect all uses
408
+ - **Flexibility**: Use at any JSON level
409
+ - **Composability**: Build complex configurations from simple parts
410
+ - **Override Support**: Local values always override template values
411
+
305
412
  ## CLI Commands
306
413
 
307
414
  ```bash
@@ -314,6 +421,10 @@ mj-sync pull --entity="AI Prompts"
314
421
  # Pull specific records by filter
315
422
  mj-sync pull --entity="AI Prompts" --filter="CategoryID='customer-service-id'"
316
423
 
424
+ # Pull multiple records into a single file (NEW)
425
+ mj-sync pull --entity="AI Prompts" --multi-file="all-prompts"
426
+ mj-sync pull --entity="AI Prompts" --filter="Status='Active'" --multi-file="active-prompts.json"
427
+
317
428
  # Push all changes from current directory and subdirectories
318
429
  mj-sync push
319
430
 
@@ -1,4 +1,34 @@
1
+ /**
2
+ * @fileoverview Pull command implementation for MetadataSync
3
+ * @module commands/pull
4
+ *
5
+ * This module implements the pull command which retrieves metadata records from
6
+ * the MemberJunction database and saves them as local JSON files. It supports:
7
+ * - Filtering records with SQL expressions
8
+ * - Pulling related entities with foreign key relationships
9
+ * - Externalizing large text fields to separate files
10
+ * - Creating multi-record JSON files
11
+ * - Recursive directory search for entity configurations
12
+ */
1
13
  import { Command } from '@oclif/core';
14
+ /**
15
+ * Pull metadata records from database to local files
16
+ *
17
+ * @class Pull
18
+ * @extends Command
19
+ *
20
+ * @example
21
+ * ```bash
22
+ * # Pull all records for an entity
23
+ * mj-sync pull --entity="AI Prompts"
24
+ *
25
+ * # Pull with filter
26
+ * mj-sync pull --entity="AI Prompts" --filter="CategoryID='123'"
27
+ *
28
+ * # Pull to multi-record file
29
+ * mj-sync pull --entity="AI Prompts" --multi-file="all-prompts.json"
30
+ * ```
31
+ */
2
32
  export default class Pull extends Command {
3
33
  static description: string;
4
34
  static examples: string[];
@@ -6,13 +36,118 @@ export default class Pull extends Command {
6
36
  entity: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
7
37
  filter: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
8
38
  'dry-run': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
39
+ 'multi-file': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
9
40
  };
10
41
  run(): Promise<void>;
42
+ /**
43
+ * Find directories containing configuration for the specified entity
44
+ *
45
+ * Recursively searches the current working directory for .mj-sync.json files
46
+ * that specify the given entity name. Returns all matching directories to
47
+ * allow user selection when multiple locations exist.
48
+ *
49
+ * @param entityName - Name of the entity to search for
50
+ * @returns Promise resolving to array of directory paths
51
+ * @private
52
+ */
11
53
  private findEntityDirectories;
54
+ /**
55
+ * Process a single record and save to file
56
+ *
57
+ * Converts a database record into the file format and writes it to disk.
58
+ * This is a wrapper around processRecordData that handles file writing.
59
+ *
60
+ * @param record - Raw database record
61
+ * @param primaryKey - Primary key fields and values
62
+ * @param targetDir - Directory to save the file
63
+ * @param entityConfig - Entity configuration with pull settings
64
+ * @param syncEngine - Sync engine instance
65
+ * @returns Promise that resolves when file is written
66
+ * @private
67
+ */
12
68
  private processRecord;
69
+ /**
70
+ * Process record data for storage
71
+ *
72
+ * Transforms a raw database record into the RecordData format used for file storage.
73
+ * Handles field externalization, related entity pulling, and checksum calculation.
74
+ *
75
+ * @param record - Raw database record
76
+ * @param primaryKey - Primary key fields and values
77
+ * @param targetDir - Directory where files will be saved
78
+ * @param entityConfig - Entity configuration with defaults and settings
79
+ * @param syncEngine - Sync engine for checksum calculation
80
+ * @returns Promise resolving to formatted RecordData
81
+ * @private
82
+ */
83
+ private processRecordData;
84
+ /**
85
+ * Determine if a field should be saved to an external file
86
+ *
87
+ * Checks if a field contains substantial text content that would be better
88
+ * stored in a separate file rather than inline in the JSON. Uses heuristics
89
+ * based on field name and content length.
90
+ *
91
+ * @param fieldName - Name of the field to check
92
+ * @param fieldValue - Value of the field
93
+ * @param entityConfig - Entity configuration (for future extension)
94
+ * @returns Promise resolving to true if field should be externalized
95
+ * @private
96
+ */
13
97
  private shouldExternalizeField;
98
+ /**
99
+ * Create an external file for a field value
100
+ *
101
+ * Saves large text content to a separate file and returns the filename.
102
+ * Automatically determines appropriate file extension based on field name
103
+ * and content type (e.g., .md for prompts, .html for templates).
104
+ *
105
+ * @param targetDir - Directory to save the file
106
+ * @param primaryKey - Primary key for filename generation
107
+ * @param fieldName - Name of the field being externalized
108
+ * @param content - Content to write to the file
109
+ * @returns Promise resolving to the created filename
110
+ * @private
111
+ */
14
112
  private createExternalFile;
113
+ /**
114
+ * Build a filename from primary key values
115
+ *
116
+ * Creates a safe filename based on the entity's primary key values.
117
+ * Handles GUIDs by using first 8 characters, sanitizes special characters,
118
+ * and creates composite names for multi-field keys.
119
+ *
120
+ * @param primaryKey - Primary key fields and values
121
+ * @param entityConfig - Entity configuration (for future extension)
122
+ * @returns Filename with .json extension
123
+ * @private
124
+ */
15
125
  private buildFileName;
126
+ /**
127
+ * Pull related entities for a parent record
128
+ *
129
+ * Retrieves child records that have foreign key relationships to the parent.
130
+ * Converts foreign key values to @parent references and supports nested
131
+ * related entities for deep object graphs.
132
+ *
133
+ * @param parentRecord - Parent entity record
134
+ * @param relatedConfig - Configuration for related entities to pull
135
+ * @param syncEngine - Sync engine instance
136
+ * @returns Promise resolving to map of entity names to related records
137
+ * @private
138
+ */
16
139
  private pullRelatedEntities;
140
+ /**
141
+ * Find which field in the parent record contains a specific value
142
+ *
143
+ * Used to convert foreign key references to @parent references by finding
144
+ * the parent field that contains the foreign key value. Typically finds
145
+ * the primary key field but can match any field.
146
+ *
147
+ * @param parentRecord - Parent record to search
148
+ * @param value - Value to search for
149
+ * @returns Field name containing the value, or null if not found
150
+ * @private
151
+ */
17
152
  private findParentField;
18
153
  }
@@ -1,4 +1,16 @@
1
1
  "use strict";
2
+ /**
3
+ * @fileoverview Pull command implementation for MetadataSync
4
+ * @module commands/pull
5
+ *
6
+ * This module implements the pull command which retrieves metadata records from
7
+ * the MemberJunction database and saves them as local JSON files. It supports:
8
+ * - Filtering records with SQL expressions
9
+ * - Pulling related entities with foreign key relationships
10
+ * - Externalizing large text fields to separate files
11
+ * - Creating multi-record JSON files
12
+ * - Recursive directory search for entity configurations
13
+ */
2
14
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
16
  };
@@ -12,6 +24,24 @@ const config_1 = require("../../config");
12
24
  const sync_engine_1 = require("../../lib/sync-engine");
13
25
  const core_2 = require("@memberjunction/core");
14
26
  const provider_utils_1 = require("../../lib/provider-utils");
27
+ /**
28
+ * Pull metadata records from database to local files
29
+ *
30
+ * @class Pull
31
+ * @extends Command
32
+ *
33
+ * @example
34
+ * ```bash
35
+ * # Pull all records for an entity
36
+ * mj-sync pull --entity="AI Prompts"
37
+ *
38
+ * # Pull with filter
39
+ * mj-sync pull --entity="AI Prompts" --filter="CategoryID='123'"
40
+ *
41
+ * # Pull to multi-record file
42
+ * mj-sync pull --entity="AI Prompts" --multi-file="all-prompts.json"
43
+ * ```
44
+ */
15
45
  class Pull extends core_1.Command {
16
46
  static description = 'Pull metadata from database to local files';
17
47
  static examples = [
@@ -22,6 +52,7 @@ class Pull extends core_1.Command {
22
52
  entity: core_1.Flags.string({ description: 'Entity name to pull', required: true }),
23
53
  filter: core_1.Flags.string({ description: 'Additional filter for pulling specific records' }),
24
54
  'dry-run': core_1.Flags.boolean({ description: 'Show what would be pulled without actually pulling' }),
55
+ 'multi-file': core_1.Flags.string({ description: 'Create a single file with multiple records (provide filename)' }),
25
56
  };
26
57
  async run() {
27
58
  const { flags } = await this.parse(Pull);
@@ -88,23 +119,54 @@ class Pull extends core_1.Command {
88
119
  }
89
120
  spinner.start('Processing records');
90
121
  let processed = 0;
91
- for (const record of result.Results) {
92
- try {
93
- // Build primary key
94
- const primaryKey = {};
95
- for (const pk of entityInfo.PrimaryKeys) {
96
- primaryKey[pk.Name] = record[pk.Name];
122
+ // If multi-file flag is set, collect all records
123
+ if (flags['multi-file']) {
124
+ const allRecords = [];
125
+ for (const record of result.Results) {
126
+ try {
127
+ // Build primary key
128
+ const primaryKey = {};
129
+ for (const pk of entityInfo.PrimaryKeys) {
130
+ primaryKey[pk.Name] = record[pk.Name];
131
+ }
132
+ // Process record for multi-file
133
+ const recordData = await this.processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine);
134
+ allRecords.push(recordData);
135
+ processed++;
136
+ spinner.text = `Processing records (${processed}/${result.Results.length})`;
137
+ }
138
+ catch (error) {
139
+ this.warn(`Failed to process record: ${error.message || error}`);
97
140
  }
98
- // Process record
99
- await this.processRecord(record, primaryKey, targetDir, entityConfig, syncEngine);
100
- processed++;
101
- spinner.text = `Processing records (${processed}/${result.Results.length})`;
102
141
  }
103
- catch (error) {
104
- this.warn(`Failed to process record: ${error.message || error}`);
142
+ // Write all records to single file
143
+ if (allRecords.length > 0) {
144
+ const fileName = flags['multi-file'].endsWith('.json') ? flags['multi-file'] : `${flags['multi-file']}.json`;
145
+ const filePath = path_1.default.join(targetDir, fileName);
146
+ await fs_extra_1.default.writeJson(filePath, allRecords, { spaces: 2 });
147
+ spinner.succeed(`Pulled ${processed} records to ${filePath}`);
148
+ }
149
+ }
150
+ else {
151
+ // Original single-file-per-record logic
152
+ for (const record of result.Results) {
153
+ try {
154
+ // Build primary key
155
+ const primaryKey = {};
156
+ for (const pk of entityInfo.PrimaryKeys) {
157
+ primaryKey[pk.Name] = record[pk.Name];
158
+ }
159
+ // Process record
160
+ await this.processRecord(record, primaryKey, targetDir, entityConfig, syncEngine);
161
+ processed++;
162
+ spinner.text = `Processing records (${processed}/${result.Results.length})`;
163
+ }
164
+ catch (error) {
165
+ this.warn(`Failed to process record: ${error.message || error}`);
166
+ }
105
167
  }
168
+ spinner.succeed(`Pulled ${processed} records to ${targetDir}`);
106
169
  }
107
- spinner.succeed(`Pulled ${processed} records to ${targetDir}`);
108
170
  }
109
171
  catch (error) {
110
172
  spinner.fail('Pull failed');
@@ -115,6 +177,17 @@ class Pull extends core_1.Command {
115
177
  await (0, provider_utils_1.cleanupProvider)();
116
178
  }
117
179
  }
180
+ /**
181
+ * Find directories containing configuration for the specified entity
182
+ *
183
+ * Recursively searches the current working directory for .mj-sync.json files
184
+ * that specify the given entity name. Returns all matching directories to
185
+ * allow user selection when multiple locations exist.
186
+ *
187
+ * @param entityName - Name of the entity to search for
188
+ * @returns Promise resolving to array of directory paths
189
+ * @private
190
+ */
118
191
  async findEntityDirectories(entityName) {
119
192
  const dirs = [];
120
193
  // Search for directories with matching entity config
@@ -137,7 +210,43 @@ class Pull extends core_1.Command {
137
210
  await searchDirs(process.cwd());
138
211
  return dirs;
139
212
  }
213
+ /**
214
+ * Process a single record and save to file
215
+ *
216
+ * Converts a database record into the file format and writes it to disk.
217
+ * This is a wrapper around processRecordData that handles file writing.
218
+ *
219
+ * @param record - Raw database record
220
+ * @param primaryKey - Primary key fields and values
221
+ * @param targetDir - Directory to save the file
222
+ * @param entityConfig - Entity configuration with pull settings
223
+ * @param syncEngine - Sync engine instance
224
+ * @returns Promise that resolves when file is written
225
+ * @private
226
+ */
140
227
  async processRecord(record, primaryKey, targetDir, entityConfig, syncEngine) {
228
+ const recordData = await this.processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine);
229
+ // Determine file path
230
+ const fileName = this.buildFileName(primaryKey, entityConfig);
231
+ const filePath = path_1.default.join(targetDir, fileName);
232
+ // Write JSON file
233
+ await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
234
+ }
235
+ /**
236
+ * Process record data for storage
237
+ *
238
+ * Transforms a raw database record into the RecordData format used for file storage.
239
+ * Handles field externalization, related entity pulling, and checksum calculation.
240
+ *
241
+ * @param record - Raw database record
242
+ * @param primaryKey - Primary key fields and values
243
+ * @param targetDir - Directory where files will be saved
244
+ * @param entityConfig - Entity configuration with defaults and settings
245
+ * @param syncEngine - Sync engine for checksum calculation
246
+ * @returns Promise resolving to formatted RecordData
247
+ * @private
248
+ */
249
+ async processRecordData(record, primaryKey, targetDir, entityConfig, syncEngine) {
141
250
  // Build record data
142
251
  const recordData = {
143
252
  primaryKey: primaryKey,
@@ -172,12 +281,21 @@ class Pull extends core_1.Command {
172
281
  }
173
282
  // Calculate checksum
174
283
  recordData.sync.checksum = syncEngine.calculateChecksum(recordData.fields);
175
- // Determine file path
176
- const fileName = this.buildFileName(primaryKey, entityConfig);
177
- const filePath = path_1.default.join(targetDir, fileName);
178
- // Write JSON file
179
- await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
284
+ return recordData;
180
285
  }
286
+ /**
287
+ * Determine if a field should be saved to an external file
288
+ *
289
+ * Checks if a field contains substantial text content that would be better
290
+ * stored in a separate file rather than inline in the JSON. Uses heuristics
291
+ * based on field name and content length.
292
+ *
293
+ * @param fieldName - Name of the field to check
294
+ * @param fieldValue - Value of the field
295
+ * @param entityConfig - Entity configuration (for future extension)
296
+ * @returns Promise resolving to true if field should be externalized
297
+ * @private
298
+ */
181
299
  async shouldExternalizeField(fieldName, fieldValue, entityConfig) {
182
300
  // Only externalize string fields with significant content
183
301
  if (typeof fieldValue !== 'string') {
@@ -192,6 +310,20 @@ class Pull extends core_1.Command {
192
310
  }
193
311
  return false;
194
312
  }
313
+ /**
314
+ * Create an external file for a field value
315
+ *
316
+ * Saves large text content to a separate file and returns the filename.
317
+ * Automatically determines appropriate file extension based on field name
318
+ * and content type (e.g., .md for prompts, .html for templates).
319
+ *
320
+ * @param targetDir - Directory to save the file
321
+ * @param primaryKey - Primary key for filename generation
322
+ * @param fieldName - Name of the field being externalized
323
+ * @param content - Content to write to the file
324
+ * @returns Promise resolving to the created filename
325
+ * @private
326
+ */
195
327
  async createExternalFile(targetDir, primaryKey, fieldName, content) {
196
328
  // Determine file extension based on field name and content
197
329
  let extension = '.txt';
@@ -218,6 +350,18 @@ class Pull extends core_1.Command {
218
350
  await fs_extra_1.default.writeFile(filePath, content, 'utf-8');
219
351
  return fileName;
220
352
  }
353
+ /**
354
+ * Build a filename from primary key values
355
+ *
356
+ * Creates a safe filename based on the entity's primary key values.
357
+ * Handles GUIDs by using first 8 characters, sanitizes special characters,
358
+ * and creates composite names for multi-field keys.
359
+ *
360
+ * @param primaryKey - Primary key fields and values
361
+ * @param entityConfig - Entity configuration (for future extension)
362
+ * @returns Filename with .json extension
363
+ * @private
364
+ */
221
365
  buildFileName(primaryKey, entityConfig) {
222
366
  // Use primary key values to build filename
223
367
  const keys = Object.values(primaryKey);
@@ -236,6 +380,19 @@ class Pull extends core_1.Command {
236
380
  // Multiple keys or numeric - create composite name
237
381
  return keys.map(k => String(k).replace(/[^a-zA-Z0-9-_]/g, '_')).join('-') + '.json';
238
382
  }
383
+ /**
384
+ * Pull related entities for a parent record
385
+ *
386
+ * Retrieves child records that have foreign key relationships to the parent.
387
+ * Converts foreign key values to @parent references and supports nested
388
+ * related entities for deep object graphs.
389
+ *
390
+ * @param parentRecord - Parent entity record
391
+ * @param relatedConfig - Configuration for related entities to pull
392
+ * @param syncEngine - Sync engine instance
393
+ * @returns Promise resolving to map of entity names to related records
394
+ * @private
395
+ */
239
396
  async pullRelatedEntities(parentRecord, relatedConfig, syncEngine) {
240
397
  const relatedEntities = {};
241
398
  for (const [key, config] of Object.entries(relatedConfig)) {
@@ -298,6 +455,18 @@ class Pull extends core_1.Command {
298
455
  }
299
456
  return relatedEntities;
300
457
  }
458
+ /**
459
+ * Find which field in the parent record contains a specific value
460
+ *
461
+ * Used to convert foreign key references to @parent references by finding
462
+ * the parent field that contains the foreign key value. Typically finds
463
+ * the primary key field but can match any field.
464
+ *
465
+ * @param parentRecord - Parent record to search
466
+ * @param value - Value to search for
467
+ * @returns Field name containing the value, or null if not found
468
+ * @private
469
+ */
301
470
  findParentField(parentRecord, value) {
302
471
  // Find which field in the parent contains this value
303
472
  // Typically this will be the primary key field