@memberjunction/codegen-lib 5.14.0 → 5.16.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.
@@ -313,6 +313,189 @@ export class ManageMetadataBase {
313
313
  .filter((e) => typeof e.BaseTable === 'string' && typeof e.SchemaName === 'string')
314
314
  .map((e) => ({ ...e, BaseTable: e.BaseTable, SchemaName: e.SchemaName }));
315
315
  }
316
+ /**
317
+ * Extracts organic key configurations from the additionalSchemaInfo config file.
318
+ * Walks all schema-keyed table arrays and collects OrganicKeys arrays with their
319
+ * owning schema and table name.
320
+ */
321
+ extractOrganicKeysFromConfig(config) {
322
+ const results = [];
323
+ for (const [key, value] of Object.entries(config)) {
324
+ // Skip top-level non-schema keys (ISARelationships, VirtualEntities, Entities)
325
+ if (!Array.isArray(value))
326
+ continue;
327
+ if (['ISARelationships', 'VirtualEntities', 'Entities'].includes(key))
328
+ continue;
329
+ const schemaName = key;
330
+ for (const tableConfig of value) {
331
+ if (!tableConfig.TableName || !Array.isArray(tableConfig.OrganicKeys))
332
+ continue;
333
+ const organicKeys = tableConfig.OrganicKeys.map((ok, okIndex) => ({
334
+ Name: ok.Name,
335
+ Description: ok.Description || undefined,
336
+ MatchFieldNames: ok.MatchFieldNames,
337
+ NormalizationStrategy: ok.NormalizationStrategy || 'LowerCaseTrim',
338
+ CustomNormalizationExpression: ok.CustomNormalizationExpression || undefined,
339
+ Sequence: ok.Sequence ?? okIndex,
340
+ RelatedEntities: Array.isArray(ok.RelatedEntities)
341
+ ? ok.RelatedEntities.map((re, reIndex) => ({
342
+ SchemaName: re.SchemaName,
343
+ TableName: re.TableName,
344
+ RelatedFieldNames: re.RelatedFieldNames,
345
+ TransitiveObject: re.TransitiveObject,
346
+ TransitiveMatchFieldNames: re.TransitiveMatchFieldNames,
347
+ TransitiveOutputFieldName: re.TransitiveOutputFieldName,
348
+ RelatedEntityJoinFieldName: re.RelatedEntityJoinFieldName,
349
+ TransitiveView: re.TransitiveView,
350
+ DisplayName: re.DisplayName,
351
+ DisplayLocation: re.DisplayLocation || 'After Field Tabs',
352
+ Sequence: re.Sequence ?? reIndex,
353
+ }))
354
+ : [],
355
+ }));
356
+ results.push({ SchemaName: schemaName, TableName: tableConfig.TableName, OrganicKeys: organicKeys });
357
+ }
358
+ }
359
+ return results;
360
+ }
361
+ /**
362
+ * Processes organic key configurations from additionalSchemaInfo.
363
+ * For each configured organic key:
364
+ * 1. Creates transitive bridge views if TransitiveView is defined
365
+ * 2. Upserts EntityOrganicKey records (matched on EntityID + Name)
366
+ * 3. Upserts EntityOrganicKeyRelatedEntity records (matched on EntityOrganicKeyID + RelatedEntityID)
367
+ *
368
+ * All SQL is executed AND logged via LogSQLAndExecute for complete CI/CD traceability.
369
+ * Must run AFTER entities are created.
370
+ */
371
+ async processOrganicKeyConfig(pool) {
372
+ const config = ManageMetadataBase.getSoftPKFKConfig();
373
+ if (!config)
374
+ return { success: true, createdCount: 0, updatedCount: 0 };
375
+ const allOrganicKeys = this.extractOrganicKeysFromConfig(config);
376
+ if (allOrganicKeys.length === 0)
377
+ return { success: true, createdCount: 0, updatedCount: 0 };
378
+ const schema = mj_core_schema();
379
+ let createdCount = 0;
380
+ let updatedCount = 0;
381
+ for (const tableConfig of allOrganicKeys) {
382
+ // Resolve the owning entity
383
+ const ownerResult = await this.runQueryWithParams(pool, `
384
+ ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
385
+ WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
386
+ OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
387
+ `, { 'TableName': tableConfig.TableName, 'SchemaName': tableConfig.SchemaName });
388
+ if (ownerResult.recordset.length === 0) {
389
+ logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping`);
390
+ continue;
391
+ }
392
+ const ownerEntityId = ownerResult.recordset[0].ID;
393
+ const ownerEntityName = ownerResult.recordset[0].Name;
394
+ for (const okConfig of tableConfig.OrganicKeys) {
395
+ try {
396
+ // Step 1: Create transitive views if defined
397
+ for (const re of okConfig.RelatedEntities) {
398
+ if (re.TransitiveView) {
399
+ const viewSchema = re.TransitiveView.SchemaName || re.SchemaName;
400
+ const viewFullName = `${viewSchema}.${re.TransitiveView.Name}`;
401
+ const viewSQL = `CREATE OR ALTER VIEW ${this.qs(viewSchema, re.TransitiveView.Name)} AS\n${re.TransitiveView.SQL}`;
402
+ await this.LogSQLAndExecute(pool, viewSQL, `Create transitive bridge view ${viewFullName} for organic key "${okConfig.Name}" on ${ownerEntityName}`);
403
+ // Auto-populate TransitiveObject from the view definition
404
+ re.TransitiveObject = viewFullName;
405
+ }
406
+ }
407
+ // Step 2: Upsert EntityOrganicKey (match on EntityID + Name)
408
+ const existingKey = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'EntityOrganicKey')} WHERE EntityID = @EntityID AND Name = @Name`, { 'EntityID': ownerEntityId, 'Name': okConfig.Name });
409
+ let organicKeyId;
410
+ const matchFieldNames = okConfig.MatchFieldNames.join(',');
411
+ if (existingKey.recordset.length > 0) {
412
+ // Update existing
413
+ organicKeyId = existingKey.recordset[0].ID;
414
+ const updateSQL = `UPDATE ${this.qs(schema, 'EntityOrganicKey')} SET
415
+ MatchFieldNames = '${matchFieldNames}',
416
+ NormalizationStrategy = '${okConfig.NormalizationStrategy || 'LowerCaseTrim'}',
417
+ ${okConfig.CustomNormalizationExpression ? `CustomNormalizationExpression = '${okConfig.CustomNormalizationExpression.replace(/'/g, "''")}',` : ''}
418
+ ${okConfig.Description ? `Description = '${okConfig.Description.replace(/'/g, "''")}',` : ''}
419
+ Sequence = ${okConfig.Sequence ?? 0},
420
+ Status = 'Active'
421
+ WHERE ID = '${organicKeyId}'`;
422
+ await this.LogSQLAndExecute(pool, updateSQL, `Update organic key "${okConfig.Name}" on ${ownerEntityName}`);
423
+ updatedCount++;
424
+ logStatus(` > Organic key: Updated "${okConfig.Name}" on ${ownerEntityName}`);
425
+ }
426
+ else {
427
+ // Insert new — generate a deterministic UUID based on entity+name for idempotency
428
+ const insertSQL = `INSERT INTO ${this.qs(schema, 'EntityOrganicKey')}
429
+ (EntityID, Name, ${okConfig.Description ? 'Description, ' : ''}MatchFieldNames, NormalizationStrategy, ${okConfig.CustomNormalizationExpression ? 'CustomNormalizationExpression, ' : ''}Sequence, Status)
430
+ VALUES ('${ownerEntityId}', '${okConfig.Name}', ${okConfig.Description ? `'${okConfig.Description.replace(/'/g, "''")}', ` : ''}'${matchFieldNames}', '${okConfig.NormalizationStrategy || 'LowerCaseTrim'}', ${okConfig.CustomNormalizationExpression ? `'${okConfig.CustomNormalizationExpression.replace(/'/g, "''")}', ` : ''}${okConfig.Sequence ?? 0}, 'Active')`;
431
+ await this.LogSQLAndExecute(pool, insertSQL, `Insert organic key "${okConfig.Name}" on ${ownerEntityName}`);
432
+ createdCount++;
433
+ logStatus(` > Organic key: Created "${okConfig.Name}" on ${ownerEntityName}`);
434
+ // Fetch the new ID
435
+ const newKey = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'EntityOrganicKey')} WHERE EntityID = @EntityID AND Name = @Name`, { 'EntityID': ownerEntityId, 'Name': okConfig.Name });
436
+ organicKeyId = newKey.recordset[0].ID;
437
+ }
438
+ // Step 3: Upsert EntityOrganicKeyRelatedEntity for each related entity
439
+ for (const reConfig of okConfig.RelatedEntities) {
440
+ const relResult = await this.runQueryWithParams(pool, `
441
+ ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
442
+ WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
443
+ OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
444
+ `, { 'TableName': reConfig.TableName, 'SchemaName': reConfig.SchemaName });
445
+ if (relResult.recordset.length === 0) {
446
+ logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — skipping`);
447
+ continue;
448
+ }
449
+ const relEntityId = relResult.recordset[0].ID;
450
+ const relEntityName = relResult.recordset[0].Name;
451
+ // Check if this related entity mapping already exists
452
+ const existingRel = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'EntityOrganicKeyRelatedEntity')} WHERE EntityOrganicKeyID = @KeyID AND RelatedEntityID = @RelEntityID`, { 'KeyID': organicKeyId, 'RelEntityID': relEntityId });
453
+ // Build field values
454
+ const isDirect = reConfig.RelatedFieldNames && reConfig.RelatedFieldNames.length > 0;
455
+ const relFieldNames = isDirect ? reConfig.RelatedFieldNames.join(',') : null;
456
+ const transitiveObject = reConfig.TransitiveObject || null;
457
+ const transitiveMatchFields = reConfig.TransitiveMatchFieldNames ? reConfig.TransitiveMatchFieldNames.join(',') : null;
458
+ if (existingRel.recordset.length > 0) {
459
+ const relId = existingRel.recordset[0].ID;
460
+ const updateRelSQL = `UPDATE ${this.qs(schema, 'EntityOrganicKeyRelatedEntity')} SET
461
+ RelatedEntityFieldNames = ${relFieldNames ? `'${relFieldNames}'` : 'NULL'},
462
+ TransitiveObjectName = ${transitiveObject ? `'${transitiveObject}'` : 'NULL'},
463
+ TransitiveObjectMatchFieldNames = ${transitiveMatchFields ? `'${transitiveMatchFields}'` : 'NULL'},
464
+ TransitiveObjectOutputFieldName = ${reConfig.TransitiveOutputFieldName ? `'${reConfig.TransitiveOutputFieldName}'` : 'NULL'},
465
+ RelatedEntityJoinFieldName = ${reConfig.RelatedEntityJoinFieldName ? `'${reConfig.RelatedEntityJoinFieldName}'` : 'NULL'},
466
+ DisplayName = ${reConfig.DisplayName ? `'${reConfig.DisplayName.replace(/'/g, "''")}'` : 'NULL'},
467
+ DisplayLocation = '${reConfig.DisplayLocation || 'After Field Tabs'}',
468
+ Sequence = ${reConfig.Sequence ?? 0}
469
+ WHERE ID = '${relId}'`;
470
+ await this.LogSQLAndExecute(pool, updateRelSQL, `Update organic key related entity: "${okConfig.Name}" → ${relEntityName}`);
471
+ }
472
+ else {
473
+ const insertRelSQL = `INSERT INTO ${this.qs(schema, 'EntityOrganicKeyRelatedEntity')}
474
+ (EntityOrganicKeyID, RelatedEntityID, RelatedEntityFieldNames,
475
+ TransitiveObjectName, TransitiveObjectMatchFieldNames, TransitiveObjectOutputFieldName, RelatedEntityJoinFieldName,
476
+ DisplayName, DisplayLocation, Sequence)
477
+ VALUES ('${organicKeyId}', '${relEntityId}',
478
+ ${relFieldNames ? `'${relFieldNames}'` : 'NULL'},
479
+ ${transitiveObject ? `'${transitiveObject}'` : 'NULL'},
480
+ ${transitiveMatchFields ? `'${transitiveMatchFields}'` : 'NULL'},
481
+ ${reConfig.TransitiveOutputFieldName ? `'${reConfig.TransitiveOutputFieldName}'` : 'NULL'},
482
+ ${reConfig.RelatedEntityJoinFieldName ? `'${reConfig.RelatedEntityJoinFieldName}'` : 'NULL'},
483
+ ${reConfig.DisplayName ? `'${reConfig.DisplayName.replace(/'/g, "''")}'` : 'NULL'},
484
+ '${reConfig.DisplayLocation || 'After Field Tabs'}',
485
+ ${reConfig.Sequence ?? 0})`;
486
+ await this.LogSQLAndExecute(pool, insertRelSQL, `Insert organic key related entity: "${okConfig.Name}" → ${relEntityName}`);
487
+ }
488
+ logStatus(` > Organic key "${okConfig.Name}": ${existingRel.recordset.length > 0 ? 'updated' : 'created'} → ${relEntityName} (${isDirect ? 'direct' : 'transitive'})`);
489
+ }
490
+ }
491
+ catch (err) {
492
+ const errMessage = err instanceof Error ? err.message : String(err);
493
+ logError(` > Organic key config: Failed to process "${okConfig.Name}" on ${ownerEntityName}: ${errMessage}`);
494
+ }
495
+ }
496
+ }
497
+ return { success: true, createdCount, updatedCount };
498
+ }
316
499
  /**
317
500
  * Processes IS-A relationship configurations from the additionalSchemaInfo config.
318
501
  * For each configured relationship, looks up both entities by name (or by table name
@@ -626,6 +809,12 @@ export class ManageMetadataBase {
626
809
  if (entityConfigResult.updatedCount > 0) {
627
810
  logStatus(` > Updated attributes on ${entityConfigResult.updatedCount} entit${entityConfigResult.updatedCount === 1 ? 'y' : 'ies'} from config`);
628
811
  }
812
+ // Config-driven organic key setup — create bridge views and upsert organic key metadata
813
+ // Must run AFTER entities exist
814
+ const organicKeyResult = await this.processOrganicKeyConfig(pool);
815
+ if (organicKeyResult.createdCount > 0 || organicKeyResult.updatedCount > 0) {
816
+ logStatus(` > Organic keys: ${organicKeyResult.createdCount} created, ${organicKeyResult.updatedCount} updated from config`);
817
+ }
629
818
  start = new Date();
630
819
  logStatus(' Syncing schema info from database...');
631
820
  if (!await this.updateSchemaInfoFromDatabase(pool, excludeSchemas)) {
@@ -880,11 +1069,11 @@ export class ManageMetadataBase {
880
1069
  * for the LLM to use when decorating virtual entity fields.
881
1070
  */
882
1071
  buildSourceEntityContext(viewDefinition) {
883
- const parseResult = SQLParser.Parse(viewDefinition);
1072
+ const tableRefs = SQLParser.ExtractTableRefs(viewDefinition);
884
1073
  const md = new Metadata();
885
1074
  const sourceEntities = [];
886
1075
  const seen = new Set();
887
- for (const tableRef of parseResult.Tables) {
1076
+ for (const tableRef of tableRefs) {
888
1077
  // Match against MJ entities by BaseTable/BaseView + SchemaName
889
1078
  const matchingEntity = md.Entities.find(e => (e.BaseTable.toLowerCase() === tableRef.TableName.toLowerCase() ||
890
1079
  e.BaseView.toLowerCase() === tableRef.TableName.toLowerCase()) &&