@memberjunction/codegen-lib 4.0.0 → 4.2.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 +826 -639
- package/dist/Angular/angular-codegen.d.ts +9 -2
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +84 -62
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Angular/entity-data-grid-related-entity-component.d.ts.map +1 -1
- package/dist/Angular/entity-data-grid-related-entity-component.js +2 -1
- package/dist/Angular/entity-data-grid-related-entity-component.js.map +1 -1
- package/dist/Config/config.d.ts.map +1 -1
- package/dist/Config/config.js +10 -0
- package/dist/Config/config.js.map +1 -1
- package/dist/Database/manage-metadata.d.ts +301 -5
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +1041 -156
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/sql.d.ts.map +1 -1
- package/dist/Database/sql.js +4 -0
- package/dist/Database/sql.js.map +1 -1
- package/dist/Database/sql_codegen.d.ts +13 -0
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +99 -28
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Misc/advanced_generation.d.ts +68 -6
- package/dist/Misc/advanced_generation.d.ts.map +1 -1
- package/dist/Misc/advanced_generation.js +94 -49
- package/dist/Misc/advanced_generation.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts +5 -0
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +24 -6
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +3 -1
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/__tests__/metadataConfig.test.d.ts +12 -0
- package/dist/__tests__/metadataConfig.test.d.ts.map +1 -0
- package/dist/__tests__/metadataConfig.test.js +604 -0
- package/dist/__tests__/metadataConfig.test.js.map +1 -0
- package/package.json +21 -17
- package/dist/Angular/user-view-grid-related-entity-component.d.ts +0 -43
- package/dist/Angular/user-view-grid-related-entity-component.d.ts.map +0 -1
- package/dist/Angular/user-view-grid-related-entity-component.js +0 -85
- package/dist/Angular/user-view-grid-related-entity-component.js.map +0 -1
|
@@ -4,6 +4,7 @@ import { CodeNameFromString, EntityInfo, ExtractActualDefaultValue, LogError, Lo
|
|
|
4
4
|
import { logError, logMessage, logStatus } from "../Misc/status_logging.js";
|
|
5
5
|
import { SQLUtilityBase } from "./sql.js";
|
|
6
6
|
import { AdvancedGeneration } from "../Misc/advanced_generation.js";
|
|
7
|
+
import { SQLParser } from "@memberjunction/core-entities-server";
|
|
7
8
|
import { convertCamelCaseToHaveSpaces, generatePluralName, MJGlobal, stripTrailingChars } from "@memberjunction/global";
|
|
8
9
|
import { v4 as uuidv4 } from 'uuid';
|
|
9
10
|
import * as fs from 'fs';
|
|
@@ -92,6 +93,239 @@ export class ManageMetadataBase {
|
|
|
92
93
|
return null;
|
|
93
94
|
}
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Extracts a flat array of table configs from the config file, handling both formats:
|
|
98
|
+
* 1. Schema-as-key (template format): { "dbo": [{ "TableName": "Orders", ... }] }
|
|
99
|
+
* 2. Flat tables array (legacy format): { "Tables": [{ "SchemaName": "dbo", "TableName": "Orders", ... }] }
|
|
100
|
+
* Returns a normalized array where each entry has SchemaName, TableName, PrimaryKey[], and ForeignKeys[].
|
|
101
|
+
*/
|
|
102
|
+
extractTablesFromConfig(config) {
|
|
103
|
+
const results = [];
|
|
104
|
+
// Check for flat "Tables" array format first
|
|
105
|
+
if (Array.isArray(config.Tables)) {
|
|
106
|
+
for (const table of config.Tables) {
|
|
107
|
+
const t = table;
|
|
108
|
+
results.push({
|
|
109
|
+
SchemaName: t.SchemaName || 'dbo',
|
|
110
|
+
TableName: t.TableName,
|
|
111
|
+
PrimaryKey: t.PrimaryKey || [],
|
|
112
|
+
ForeignKeys: t.ForeignKeys || [],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
// Schema-as-key format: iterate over keys, skip metadata and special section keys
|
|
118
|
+
const metadataKeys = new Set(['$schema', 'description', 'version', 'VirtualEntities', 'ISARelationships', 'Tables']);
|
|
119
|
+
for (const key of Object.keys(config)) {
|
|
120
|
+
if (metadataKeys.has(key))
|
|
121
|
+
continue;
|
|
122
|
+
const schemaName = key;
|
|
123
|
+
const tables = config[key];
|
|
124
|
+
if (!Array.isArray(tables))
|
|
125
|
+
continue;
|
|
126
|
+
for (const table of tables) {
|
|
127
|
+
const t = table;
|
|
128
|
+
results.push({
|
|
129
|
+
SchemaName: schemaName,
|
|
130
|
+
TableName: t.TableName,
|
|
131
|
+
PrimaryKey: t.PrimaryKey || [],
|
|
132
|
+
ForeignKeys: t.ForeignKeys || [],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extracts VirtualEntities array from the additionalSchemaInfo config file.
|
|
140
|
+
* The config may contain a top-level "VirtualEntities" key with an array of
|
|
141
|
+
* virtual entity definitions.
|
|
142
|
+
*/
|
|
143
|
+
extractVirtualEntitiesFromConfig(config) {
|
|
144
|
+
const virtualEntities = config.VirtualEntities;
|
|
145
|
+
if (!Array.isArray(virtualEntities))
|
|
146
|
+
return [];
|
|
147
|
+
return virtualEntities.map((ve) => ({
|
|
148
|
+
ViewName: ve.ViewName,
|
|
149
|
+
SchemaName: ve.SchemaName || undefined,
|
|
150
|
+
EntityName: ve.EntityName || undefined,
|
|
151
|
+
Description: ve.Description || undefined,
|
|
152
|
+
PrimaryKey: Array.isArray(ve.PrimaryKey) ? ve.PrimaryKey : undefined,
|
|
153
|
+
ForeignKeys: Array.isArray(ve.ForeignKeys) ? ve.ForeignKeys : undefined,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Extracts ISARelationships array from the additionalSchemaInfo config file.
|
|
158
|
+
* The config may contain a top-level "ISARelationships" key with an array of
|
|
159
|
+
* parent-child relationship definitions.
|
|
160
|
+
*/
|
|
161
|
+
extractISARelationshipsFromConfig(config) {
|
|
162
|
+
const relationships = config.ISARelationships;
|
|
163
|
+
if (!Array.isArray(relationships))
|
|
164
|
+
return [];
|
|
165
|
+
return relationships.map((rel) => ({
|
|
166
|
+
ChildEntity: rel.ChildEntity,
|
|
167
|
+
ParentEntity: rel.ParentEntity,
|
|
168
|
+
SchemaName: rel.SchemaName || undefined,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Processes IS-A relationship configurations from the additionalSchemaInfo config.
|
|
173
|
+
* For each configured relationship, looks up both entities by name (or by table name
|
|
174
|
+
* within the given schema) and sets Entity.ParentID on the child entity.
|
|
175
|
+
* Must run AFTER entities are created but BEFORE manageParentEntityFields().
|
|
176
|
+
*/
|
|
177
|
+
async processISARelationshipConfig(pool) {
|
|
178
|
+
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
179
|
+
if (!config)
|
|
180
|
+
return { success: true, updatedCount: 0 };
|
|
181
|
+
const relationships = this.extractISARelationshipsFromConfig(config);
|
|
182
|
+
if (relationships.length === 0)
|
|
183
|
+
return { success: true, updatedCount: 0 };
|
|
184
|
+
let updatedCount = 0;
|
|
185
|
+
const schema = mj_core_schema();
|
|
186
|
+
for (const rel of relationships) {
|
|
187
|
+
try {
|
|
188
|
+
// Look up the parent entity — try by Name first, then by BaseTable within the given schema
|
|
189
|
+
const parentResult = await pool.request()
|
|
190
|
+
.input('ParentName', rel.ParentEntity)
|
|
191
|
+
.input('SchemaName', rel.SchemaName || null)
|
|
192
|
+
.query(`
|
|
193
|
+
SELECT TOP 1 ID, Name
|
|
194
|
+
FROM [${schema}].vwEntities
|
|
195
|
+
WHERE Name = @ParentName
|
|
196
|
+
OR (BaseTable = @ParentName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))
|
|
197
|
+
ORDER BY CASE WHEN Name = @ParentName THEN 0 ELSE 1 END
|
|
198
|
+
`);
|
|
199
|
+
if (parentResult.recordset.length === 0) {
|
|
200
|
+
logError(` > IS-A config: parent entity "${rel.ParentEntity}" not found — skipping`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const parentId = parentResult.recordset[0].ID;
|
|
204
|
+
const parentName = parentResult.recordset[0].Name;
|
|
205
|
+
// Look up the child entity — same strategy
|
|
206
|
+
const childResult = await pool.request()
|
|
207
|
+
.input('ChildName', rel.ChildEntity)
|
|
208
|
+
.input('SchemaName', rel.SchemaName || null)
|
|
209
|
+
.query(`
|
|
210
|
+
SELECT TOP 1 ID, Name, ParentID
|
|
211
|
+
FROM [${schema}].vwEntities
|
|
212
|
+
WHERE Name = @ChildName
|
|
213
|
+
OR (BaseTable = @ChildName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))
|
|
214
|
+
ORDER BY CASE WHEN Name = @ChildName THEN 0 ELSE 1 END
|
|
215
|
+
`);
|
|
216
|
+
if (childResult.recordset.length === 0) {
|
|
217
|
+
logError(` > IS-A config: child entity "${rel.ChildEntity}" not found — skipping`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const childId = childResult.recordset[0].ID;
|
|
221
|
+
const childName = childResult.recordset[0].Name;
|
|
222
|
+
const existingParentId = childResult.recordset[0].ParentID;
|
|
223
|
+
// Skip if already set correctly
|
|
224
|
+
if (existingParentId === parentId) {
|
|
225
|
+
logStatus(` > IS-A: "${childName}" already has ParentID set to "${parentName}", skipping`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Set ParentID on the child entity
|
|
229
|
+
await pool.request()
|
|
230
|
+
.input('ParentID', parentId)
|
|
231
|
+
.input('ChildID', childId)
|
|
232
|
+
.query(`UPDATE [${schema}].Entity SET ParentID = @ParentID WHERE ID = @ChildID`);
|
|
233
|
+
if (existingParentId) {
|
|
234
|
+
logStatus(` > IS-A: Updated "${childName}" ParentID from previous value to "${parentName}"`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
logStatus(` > IS-A: Set "${childName}" ParentID to "${parentName}"`);
|
|
238
|
+
}
|
|
239
|
+
updatedCount++;
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
243
|
+
logError(` > IS-A config: Failed to set ParentID for "${rel.ChildEntity}": ${errMessage}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { success: true, updatedCount };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Processes virtual entity configurations from the additionalSchemaInfo config.
|
|
250
|
+
* For each configured virtual entity, checks if it already exists and creates
|
|
251
|
+
* it if not. Uses the spCreateVirtualEntity stored procedure.
|
|
252
|
+
* Must run BEFORE manageVirtualEntities() so newly created entities get field-synced.
|
|
253
|
+
*/
|
|
254
|
+
async processVirtualEntityConfig(pool, currentUser) {
|
|
255
|
+
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
256
|
+
if (!config)
|
|
257
|
+
return { success: true, createdCount: 0 };
|
|
258
|
+
const virtualEntities = this.extractVirtualEntitiesFromConfig(config);
|
|
259
|
+
if (virtualEntities.length === 0)
|
|
260
|
+
return { success: true, createdCount: 0 };
|
|
261
|
+
let createdCount = 0;
|
|
262
|
+
const schema = mj_core_schema();
|
|
263
|
+
for (const ve of virtualEntities) {
|
|
264
|
+
const viewSchema = ve.SchemaName || schema;
|
|
265
|
+
const viewName = ve.ViewName;
|
|
266
|
+
const entityName = ve.EntityName || this.deriveEntityNameFromView(viewName);
|
|
267
|
+
const pkField = ve.PrimaryKey?.[0] || 'ID';
|
|
268
|
+
// Check if entity already exists for this view
|
|
269
|
+
const existsResult = await pool.request()
|
|
270
|
+
.input('ViewName', viewName)
|
|
271
|
+
.input('SchemaName', viewSchema)
|
|
272
|
+
.query(`SELECT ID FROM [${schema}].vwEntities WHERE BaseView = @ViewName AND SchemaName = @SchemaName`);
|
|
273
|
+
if (existsResult.recordset.length > 0) {
|
|
274
|
+
logStatus(` > Virtual entity "${entityName}" already exists for view [${viewSchema}].[${viewName}], skipping creation`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Verify the view actually exists in the database
|
|
278
|
+
const viewExistsResult = await pool.request()
|
|
279
|
+
.input('ViewName', viewName)
|
|
280
|
+
.input('SchemaName', viewSchema)
|
|
281
|
+
.query(`SELECT 1 FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME = @ViewName AND TABLE_SCHEMA = @SchemaName`);
|
|
282
|
+
if (viewExistsResult.recordset.length === 0) {
|
|
283
|
+
logError(` > View [${viewSchema}].[${viewName}] does not exist — skipping virtual entity creation for "${entityName}"`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// Create the virtual entity via the stored procedure
|
|
287
|
+
try {
|
|
288
|
+
const createResult = await pool.request()
|
|
289
|
+
.input('Name', entityName)
|
|
290
|
+
.input('BaseView', viewName)
|
|
291
|
+
.input('SchemaName', viewSchema)
|
|
292
|
+
.input('PrimaryKeyFieldName', pkField)
|
|
293
|
+
.input('Description', ve.Description || null)
|
|
294
|
+
.execute(`[${schema}].spCreateVirtualEntity`);
|
|
295
|
+
const newEntityId = createResult.recordset?.[0]?.['']
|
|
296
|
+
|| createResult.recordset?.[0]?.ID
|
|
297
|
+
|| createResult.recordset?.[0]?.Column0;
|
|
298
|
+
logStatus(` > Created virtual entity "${entityName}" (ID: ${newEntityId}) for view [${viewSchema}].[${viewName}]`);
|
|
299
|
+
createdCount++;
|
|
300
|
+
// Add virtual entity to the application for its schema and set default permissions
|
|
301
|
+
// (same logic as table-backed entities)
|
|
302
|
+
if (newEntityId) {
|
|
303
|
+
await this.addEntityToApplicationForSchema(pool, newEntityId, entityName, viewSchema, currentUser);
|
|
304
|
+
await this.addDefaultPermissionsForEntity(pool, newEntityId, entityName);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
309
|
+
logError(` > Failed to create virtual entity "${entityName}": ${errMessage}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { success: true, createdCount };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Derives an entity name from a view name by removing common prefixes (vw, v_)
|
|
316
|
+
* and converting to a human-friendly format.
|
|
317
|
+
*/
|
|
318
|
+
deriveEntityNameFromView(viewName) {
|
|
319
|
+
let name = viewName;
|
|
320
|
+
// Remove common view prefixes
|
|
321
|
+
if (name.startsWith('vw'))
|
|
322
|
+
name = name.substring(2);
|
|
323
|
+
else if (name.startsWith('v_'))
|
|
324
|
+
name = name.substring(2);
|
|
325
|
+
// Add spaces before capital letters (PascalCase → "Pascal Case")
|
|
326
|
+
name = name.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
327
|
+
return name.trim();
|
|
328
|
+
}
|
|
95
329
|
/**
|
|
96
330
|
* Primary function to manage metadata within the CodeGen system. This function will call a series of sub-functions to manage the metadata.
|
|
97
331
|
* @param pool - the ConnectionPool object to use for querying and updating the database
|
|
@@ -138,7 +372,10 @@ export class ManageMetadataBase {
|
|
|
138
372
|
start = new Date();
|
|
139
373
|
logStatus(' Managing entity fields...');
|
|
140
374
|
// note that we skip Advanced Generation here because we do it again later when the manageSQLScriptsAndExecution occurs in SQLCodeGen class
|
|
141
|
-
|
|
375
|
+
// Also skip deleting unneeded fields on this first pass — base views haven't been regenerated yet,
|
|
376
|
+
// so virtual fields (which come from view JOINs) would be incorrectly identified as orphaned and deleted.
|
|
377
|
+
// Deletion runs on the second pass (in sql_codegen.ts) after views are current.
|
|
378
|
+
if (!await this.manageEntityFields(pool, excludeSchemas, false, false, currentUser, true, true)) {
|
|
142
379
|
logError(' Error managing entity fields');
|
|
143
380
|
bSuccess = false;
|
|
144
381
|
}
|
|
@@ -153,11 +390,29 @@ export class ManageMetadataBase {
|
|
|
153
390
|
if (ManageMetadataBase.newEntityList.length > 0) {
|
|
154
391
|
await this.generateNewEntityDescriptions(pool, md, currentUser); // don't pass excludeSchemas becuase by definition this is the NEW entities we created
|
|
155
392
|
}
|
|
393
|
+
// Config-driven virtual entity creation — run BEFORE manageVirtualEntities
|
|
394
|
+
// so newly created entities get their fields synced in the next step
|
|
395
|
+
const vecResult = await this.processVirtualEntityConfig(pool, currentUser);
|
|
396
|
+
if (vecResult.createdCount > 0) {
|
|
397
|
+
logStatus(` > Created ${vecResult.createdCount} virtual entit${vecResult.createdCount === 1 ? 'y' : 'ies'} from config`);
|
|
398
|
+
// Refresh metadata so manageVirtualEntities can find the newly-created entities
|
|
399
|
+
// in the cache — otherwise EntityByName() returns null and field sync is silently skipped
|
|
400
|
+
const md = new Metadata();
|
|
401
|
+
await md.Refresh();
|
|
402
|
+
}
|
|
156
403
|
const veResult = await this.manageVirtualEntities(pool);
|
|
157
404
|
if (!veResult.success) {
|
|
158
405
|
logError(' Error managing virtual entities');
|
|
159
406
|
bSuccess = false;
|
|
160
407
|
}
|
|
408
|
+
// LLM-assisted virtual entity field decoration — identify PKs, FKs, and descriptions
|
|
409
|
+
await this.decorateVirtualEntitiesWithLLM(pool, currentUser);
|
|
410
|
+
// Config-driven IS-A relationship setup — set ParentID on child entities
|
|
411
|
+
// Must run AFTER entities exist but BEFORE manageEntityFields() which calls manageParentEntityFields()
|
|
412
|
+
const isaConfigResult = await this.processISARelationshipConfig(pool);
|
|
413
|
+
if (isaConfigResult.updatedCount > 0) {
|
|
414
|
+
logStatus(` > Set ParentID on ${isaConfigResult.updatedCount} IS-A child entit${isaConfigResult.updatedCount === 1 ? 'y' : 'ies'} from config`);
|
|
415
|
+
}
|
|
161
416
|
start = new Date();
|
|
162
417
|
logStatus(' Syncing schema info from database...');
|
|
163
418
|
if (!await this.updateSchemaInfoFromDatabase(pool, excludeSchemas)) {
|
|
@@ -307,6 +562,480 @@ export class ManageMetadataBase {
|
|
|
307
562
|
}
|
|
308
563
|
return { success: true, updatedField: didUpdate, newFieldID: newEntityFieldUUID };
|
|
309
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* Iterates over all virtual entities and applies LLM-assisted field decoration
|
|
567
|
+
* to identify primary keys, foreign keys, and field descriptions.
|
|
568
|
+
* Only runs if the VirtualEntityFieldDecoration advanced generation feature is enabled.
|
|
569
|
+
* Idempotent: skips entities that already have soft PK/FK annotations.
|
|
570
|
+
*/
|
|
571
|
+
async decorateVirtualEntitiesWithLLM(pool, currentUser) {
|
|
572
|
+
const ag = new AdvancedGeneration();
|
|
573
|
+
if (!ag.featureEnabled('VirtualEntityFieldDecoration')) {
|
|
574
|
+
return; // Feature not enabled, nothing to do
|
|
575
|
+
}
|
|
576
|
+
const md = new Metadata();
|
|
577
|
+
const virtualEntities = md.Entities.filter(e => e.VirtualEntity);
|
|
578
|
+
if (virtualEntities.length === 0) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
// Pre-build available entities list once (shared across all virtual entity decorations)
|
|
582
|
+
const availableEntities = md.Entities
|
|
583
|
+
.filter(e => !e.VirtualEntity && e.PrimaryKeys.length > 0)
|
|
584
|
+
.map(e => ({
|
|
585
|
+
Name: e.Name,
|
|
586
|
+
SchemaName: e.SchemaName,
|
|
587
|
+
BaseTable: e.BaseTable,
|
|
588
|
+
PrimaryKeyField: e.PrimaryKeys[0]?.Name || 'ID'
|
|
589
|
+
}));
|
|
590
|
+
logStatus(` Decorating virtual entity fields with LLM (${virtualEntities.length} entities)...`);
|
|
591
|
+
let decoratedCount = 0;
|
|
592
|
+
let skippedCount = 0;
|
|
593
|
+
// Process in batches of up to 5 in parallel for better throughput
|
|
594
|
+
const batchSize = 5;
|
|
595
|
+
for (let i = 0; i < virtualEntities.length; i += batchSize) {
|
|
596
|
+
const batch = virtualEntities.slice(i, i + batchSize);
|
|
597
|
+
const results = await Promise.all(batch.map(entity => this.decorateSingleVirtualEntityWithLLM(pool, entity, ag, currentUser, availableEntities)));
|
|
598
|
+
for (const result of results) {
|
|
599
|
+
if (result.decorated) {
|
|
600
|
+
decoratedCount++;
|
|
601
|
+
}
|
|
602
|
+
else if (result.skipped) {
|
|
603
|
+
skippedCount++;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (decoratedCount > 0 || skippedCount > 0) {
|
|
608
|
+
logStatus(` > LLM field decoration: ${decoratedCount} decorated, ${skippedCount} skipped (already annotated)`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Applies LLM-assisted field decoration to a single virtual entity.
|
|
613
|
+
* Parses the view SQL to identify source entities, enriches the LLM prompt with their
|
|
614
|
+
* field metadata (descriptions, categories), then applies PKs, FKs, descriptions, and categories.
|
|
615
|
+
* @returns Whether the entity was decorated, skipped, or encountered an error.
|
|
616
|
+
*/
|
|
617
|
+
async decorateSingleVirtualEntityWithLLM(pool, entity, ag, currentUser, availableEntities) {
|
|
618
|
+
try {
|
|
619
|
+
// Idempotency check: if entity already has soft PK or soft FK annotations, skip
|
|
620
|
+
// unless forceRegenerate option is enabled on this feature
|
|
621
|
+
const feature = ag.getFeature('VirtualEntityFieldDecoration');
|
|
622
|
+
const forceRegenerate = feature?.options?.find(o => o.name === 'forceRegenerate')?.value === true;
|
|
623
|
+
const hasSoftAnnotations = entity.Fields.some(f => f.IsSoftPrimaryKey || f.IsSoftForeignKey);
|
|
624
|
+
if (hasSoftAnnotations && !forceRegenerate) {
|
|
625
|
+
return { decorated: false, skipped: true };
|
|
626
|
+
}
|
|
627
|
+
// Get view definition from SQL Server
|
|
628
|
+
const viewDefSQL = `SELECT OBJECT_DEFINITION(OBJECT_ID('[${entity.SchemaName}].[${entity.BaseView}]')) AS ViewDef`;
|
|
629
|
+
const viewDefResult = await pool.request().query(viewDefSQL);
|
|
630
|
+
const viewDefinition = viewDefResult.recordset[0]?.ViewDef;
|
|
631
|
+
if (!viewDefinition) {
|
|
632
|
+
logStatus(` Could not get view definition for ${entity.SchemaName}.${entity.BaseView} — skipping LLM decoration`);
|
|
633
|
+
return { decorated: false, skipped: false };
|
|
634
|
+
}
|
|
635
|
+
// Parse the view SQL to identify referenced tables, then resolve to entities
|
|
636
|
+
const sourceEntities = this.buildSourceEntityContext(viewDefinition);
|
|
637
|
+
// Build field info for the prompt
|
|
638
|
+
const fields = entity.Fields.map(f => ({
|
|
639
|
+
Name: f.Name,
|
|
640
|
+
Type: f.Type,
|
|
641
|
+
Length: f.Length,
|
|
642
|
+
AllowsNull: f.AllowsNull,
|
|
643
|
+
IsPrimaryKey: f.IsPrimaryKey,
|
|
644
|
+
RelatedEntityName: f.RelatedEntity || null
|
|
645
|
+
}));
|
|
646
|
+
// Call the LLM with enriched source entity context
|
|
647
|
+
const result = await ag.decorateVirtualEntityFields(entity.Name, entity.SchemaName, entity.BaseView, viewDefinition, entity.Description || '', fields, availableEntities, sourceEntities, currentUser);
|
|
648
|
+
if (!result) {
|
|
649
|
+
return { decorated: false, skipped: false };
|
|
650
|
+
}
|
|
651
|
+
// Apply results to EntityField records
|
|
652
|
+
const schema = mj_core_schema();
|
|
653
|
+
let anyUpdated = false;
|
|
654
|
+
// Apply primary keys
|
|
655
|
+
anyUpdated = await this.applyLLMPrimaryKeys(pool, entity, result.primaryKeys, schema) || anyUpdated;
|
|
656
|
+
// Apply foreign keys
|
|
657
|
+
anyUpdated = await this.applyLLMForeignKeys(pool, entity, result.foreignKeys, schema) || anyUpdated;
|
|
658
|
+
// Apply field descriptions
|
|
659
|
+
anyUpdated = await this.applyLLMFieldDescriptions(pool, entity, result.fieldDescriptions, schema) || anyUpdated;
|
|
660
|
+
// Apply categories using the shared methods (same stability rules as regular entities)
|
|
661
|
+
anyUpdated = await this.applyVEFieldCategories(pool, entity, result) || anyUpdated;
|
|
662
|
+
if (anyUpdated) {
|
|
663
|
+
const sqlUpdate = `UPDATE [${schema}].Entity SET [${EntityInfo.UpdatedAtFieldName}]=GETUTCDATE() WHERE ID='${entity.ID}'`;
|
|
664
|
+
await this.LogSQLAndExecute(pool, sqlUpdate, `Update entity timestamp for ${entity.Name} after LLM decoration`);
|
|
665
|
+
}
|
|
666
|
+
return { decorated: anyUpdated, skipped: false };
|
|
667
|
+
}
|
|
668
|
+
catch (e) {
|
|
669
|
+
logError(` Error decorating virtual entity ${entity.Name} with LLM: ${e}`);
|
|
670
|
+
return { decorated: false, skipped: false };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Parses a view definition SQL and resolves referenced tables to MJ entities.
|
|
675
|
+
* Returns enriched source entity context (all fields with descriptions and categories)
|
|
676
|
+
* for the LLM to use when decorating virtual entity fields.
|
|
677
|
+
*/
|
|
678
|
+
buildSourceEntityContext(viewDefinition) {
|
|
679
|
+
const parseResult = SQLParser.Parse(viewDefinition);
|
|
680
|
+
const md = new Metadata();
|
|
681
|
+
const sourceEntities = [];
|
|
682
|
+
const seen = new Set();
|
|
683
|
+
for (const tableRef of parseResult.Tables) {
|
|
684
|
+
// Match against MJ entities by BaseTable/BaseView + SchemaName
|
|
685
|
+
const matchingEntity = md.Entities.find(e => (e.BaseTable.toLowerCase() === tableRef.TableName.toLowerCase() ||
|
|
686
|
+
e.BaseView.toLowerCase() === tableRef.TableName.toLowerCase()) &&
|
|
687
|
+
e.SchemaName.toLowerCase() === tableRef.SchemaName.toLowerCase());
|
|
688
|
+
if (matchingEntity && !seen.has(matchingEntity.ID)) {
|
|
689
|
+
seen.add(matchingEntity.ID);
|
|
690
|
+
sourceEntities.push({
|
|
691
|
+
Name: matchingEntity.Name,
|
|
692
|
+
Description: matchingEntity.Description || '',
|
|
693
|
+
Fields: matchingEntity.Fields.map(f => ({
|
|
694
|
+
Name: f.Name,
|
|
695
|
+
Type: f.Type,
|
|
696
|
+
Description: f.Description || '',
|
|
697
|
+
Category: f.Category || null,
|
|
698
|
+
IsPrimaryKey: f.IsPrimaryKey,
|
|
699
|
+
IsForeignKey: !!(f.RelatedEntityID)
|
|
700
|
+
}))
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return sourceEntities;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Applies category assignments from VE decoration results using the shared category methods.
|
|
708
|
+
* Loads field records from DB (needs ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName)
|
|
709
|
+
* then delegates to the shared methods.
|
|
710
|
+
*/
|
|
711
|
+
async applyVEFieldCategories(pool, entity, result) {
|
|
712
|
+
// Check if the LLM returned any category data
|
|
713
|
+
const hasCategories = result.fieldDescriptions?.some(fd => fd.category);
|
|
714
|
+
if (!hasCategories) {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
// Load VE EntityField rows from DB (we need the ID and auto-update flags)
|
|
718
|
+
const schema = mj_core_schema();
|
|
719
|
+
const fieldsSQL = `
|
|
720
|
+
SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName
|
|
721
|
+
FROM [${schema}].EntityField
|
|
722
|
+
WHERE EntityID = '${entity.ID}'
|
|
723
|
+
`;
|
|
724
|
+
const fieldsResult = await pool.request().query(fieldsSQL);
|
|
725
|
+
const dbFields = fieldsResult.recordset;
|
|
726
|
+
if (dbFields.length === 0)
|
|
727
|
+
return false;
|
|
728
|
+
// Convert VE decoration field descriptions into the format expected by applyFieldCategories
|
|
729
|
+
const fieldCategories = result.fieldDescriptions
|
|
730
|
+
.filter(fd => fd.category)
|
|
731
|
+
.map(fd => ({
|
|
732
|
+
fieldName: fd.fieldName,
|
|
733
|
+
category: fd.category,
|
|
734
|
+
displayName: fd.displayName || undefined,
|
|
735
|
+
extendedType: fd.extendedType,
|
|
736
|
+
codeType: fd.codeType
|
|
737
|
+
}));
|
|
738
|
+
if (fieldCategories.length === 0)
|
|
739
|
+
return false;
|
|
740
|
+
const existingCategories = this.buildExistingCategorySet(dbFields);
|
|
741
|
+
await this.applyFieldCategories(pool, entity.ID, dbFields, fieldCategories, existingCategories);
|
|
742
|
+
// Apply entity icon if provided
|
|
743
|
+
if (result.entityIcon) {
|
|
744
|
+
await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
|
|
745
|
+
}
|
|
746
|
+
// Apply category info settings if provided
|
|
747
|
+
if (result.categoryInfo && Object.keys(result.categoryInfo).length > 0) {
|
|
748
|
+
await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo);
|
|
749
|
+
}
|
|
750
|
+
logStatus(` Applied categories for VE ${entity.Name} (${fieldCategories.length} fields)`);
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Applies LLM-identified primary keys to entity fields.
|
|
755
|
+
* Sets IsPrimaryKey=1 and IsSoftPrimaryKey=1 for identified fields.
|
|
756
|
+
* First clears any default PK that was set by field-sync (field #1 fallback).
|
|
757
|
+
* All SQL updates are batched into a single execution for performance.
|
|
758
|
+
*/
|
|
759
|
+
async applyLLMPrimaryKeys(pool, entity, primaryKeys, schema) {
|
|
760
|
+
if (!primaryKeys || primaryKeys.length === 0) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
// Validate that all identified PK fields exist on the entity
|
|
764
|
+
const validPKs = primaryKeys.filter(pk => entity.Fields.some(f => f.Name.toLowerCase() === pk.toLowerCase()));
|
|
765
|
+
if (validPKs.length === 0) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
// Build batched SQL: clear default PK + set all LLM-identified PKs
|
|
769
|
+
const sqlStatements = [];
|
|
770
|
+
// Clear existing default PK (field #1 fallback) before applying LLM-identified PKs
|
|
771
|
+
sqlStatements.push(`UPDATE [${schema}].[EntityField]
|
|
772
|
+
SET IsPrimaryKey=0, IsUnique=0
|
|
773
|
+
WHERE EntityID='${entity.ID}' AND IsPrimaryKey=1 AND IsSoftPrimaryKey=0`);
|
|
774
|
+
// Set LLM-identified PKs
|
|
775
|
+
for (const pk of validPKs) {
|
|
776
|
+
sqlStatements.push(`UPDATE [${schema}].[EntityField]
|
|
777
|
+
SET IsPrimaryKey=1, IsUnique=1, IsSoftPrimaryKey=1
|
|
778
|
+
WHERE EntityID='${entity.ID}' AND Name='${pk}'`);
|
|
779
|
+
logStatus(` ✓ Set PK for ${entity.Name}.${pk} (LLM-identified)`);
|
|
780
|
+
}
|
|
781
|
+
await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set LLM-identified PKs for ${entity.Name}: ${validPKs.join(', ')}`);
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Applies LLM-identified foreign keys to entity fields.
|
|
786
|
+
* Sets RelatedEntityID, RelatedEntityFieldName, and IsSoftForeignKey=1.
|
|
787
|
+
* Only applies high and medium confidence FKs.
|
|
788
|
+
* All SQL updates are batched into a single execution for performance.
|
|
789
|
+
*/
|
|
790
|
+
async applyLLMForeignKeys(pool, entity, foreignKeys, schema) {
|
|
791
|
+
if (!foreignKeys || foreignKeys.length === 0) {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
const md = new Metadata();
|
|
795
|
+
const sqlStatements = [];
|
|
796
|
+
for (const fk of foreignKeys) {
|
|
797
|
+
// Only apply high/medium confidence
|
|
798
|
+
if (fk.confidence !== 'high' && fk.confidence !== 'medium') {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
// Validate that the field exists on this entity
|
|
802
|
+
const field = entity.Fields.find(f => f.Name.toLowerCase() === fk.fieldName.toLowerCase());
|
|
803
|
+
if (!field) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
// Skip if field already has a FK set (config-defined takes precedence)
|
|
807
|
+
if (field.RelatedEntityID) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
// Look up the related entity by name
|
|
811
|
+
const relatedEntity = md.EntityByName(fk.relatedEntityName);
|
|
812
|
+
if (!relatedEntity) {
|
|
813
|
+
logStatus(` ⚠️ LLM FK: related entity '${fk.relatedEntityName}' not found for ${entity.Name}.${fk.fieldName}`);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
sqlStatements.push(`UPDATE [${schema}].[EntityField]
|
|
817
|
+
SET RelatedEntityID='${relatedEntity.ID}',
|
|
818
|
+
RelatedEntityFieldName='${fk.relatedFieldName}',
|
|
819
|
+
IsSoftForeignKey=1
|
|
820
|
+
WHERE EntityID='${entity.ID}' AND Name='${field.Name}'`);
|
|
821
|
+
logStatus(` ✓ Set FK for ${entity.Name}.${field.Name} → ${fk.relatedEntityName}.${fk.relatedFieldName} (${fk.confidence}, LLM)`);
|
|
822
|
+
}
|
|
823
|
+
if (sqlStatements.length === 0) {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set LLM-identified FKs for ${entity.Name}`);
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Applies LLM-generated field descriptions to entity fields that lack descriptions.
|
|
831
|
+
* All SQL updates are batched into a single execution for performance.
|
|
832
|
+
*/
|
|
833
|
+
async applyLLMFieldDescriptions(pool, entity, fieldDescriptions, schema) {
|
|
834
|
+
if (!fieldDescriptions || fieldDescriptions.length === 0) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
const sqlStatements = [];
|
|
838
|
+
for (const fd of fieldDescriptions) {
|
|
839
|
+
const field = entity.Fields.find(f => f.Name.toLowerCase() === fd.fieldName.toLowerCase());
|
|
840
|
+
if (!field) {
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
// Only apply if field doesn't already have a description
|
|
844
|
+
if (field.Description && field.Description.trim().length > 0) {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const escapedDescription = fd.description.replace(/'/g, "''");
|
|
848
|
+
let setClauses = `Description='${escapedDescription}'`;
|
|
849
|
+
// Apply extended type if provided and valid
|
|
850
|
+
if (fd.extendedType) {
|
|
851
|
+
const validExtendedType = this.validateExtendedType(fd.extendedType);
|
|
852
|
+
if (validExtendedType) {
|
|
853
|
+
setClauses += `, ExtendedType='${validExtendedType}'`;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
sqlStatements.push(`UPDATE [${schema}].[EntityField]
|
|
857
|
+
SET ${setClauses}
|
|
858
|
+
WHERE EntityID='${entity.ID}' AND Name='${field.Name}'`);
|
|
859
|
+
}
|
|
860
|
+
if (sqlStatements.length === 0) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set LLM-generated descriptions for ${entity.Name} (${sqlStatements.length} fields)`);
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Valid values for EntityField.ExtendedType, plus common LLM aliases mapped to valid values.
|
|
868
|
+
*/
|
|
869
|
+
static { this.VALID_EXTENDED_TYPES = new Set([
|
|
870
|
+
'Code', 'Email', 'FaceTime', 'Geo', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
|
|
871
|
+
]); }
|
|
872
|
+
static { this.EXTENDED_TYPE_ALIASES = {
|
|
873
|
+
'phone': 'Tel',
|
|
874
|
+
'telephone': 'Tel',
|
|
875
|
+
'website': 'URL',
|
|
876
|
+
'link': 'URL',
|
|
877
|
+
'hyperlink': 'URL',
|
|
878
|
+
'mail': 'Email',
|
|
879
|
+
'e-mail': 'Email',
|
|
880
|
+
'text': 'SMS',
|
|
881
|
+
'location': 'Geo',
|
|
882
|
+
'address': 'Geo',
|
|
883
|
+
'teams': 'MSTeams',
|
|
884
|
+
'facetime': 'FaceTime',
|
|
885
|
+
'zoom': 'ZoomMtg',
|
|
886
|
+
'whatsapp': 'WhatsApp',
|
|
887
|
+
'skype': 'Skype',
|
|
888
|
+
}; }
|
|
889
|
+
/**
|
|
890
|
+
* Validates an LLM-suggested ExtendedType against the allowed values in EntityField.
|
|
891
|
+
* Returns the valid value (case-corrected) or null if invalid.
|
|
892
|
+
*/
|
|
893
|
+
validateExtendedType(suggested) {
|
|
894
|
+
// Direct match (case-insensitive)
|
|
895
|
+
for (const valid of ManageMetadataBase.VALID_EXTENDED_TYPES) {
|
|
896
|
+
if (valid.toLowerCase() === suggested.toLowerCase()) {
|
|
897
|
+
return valid;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Check aliases
|
|
901
|
+
const alias = ManageMetadataBase.EXTENDED_TYPE_ALIASES[suggested.toLowerCase()];
|
|
902
|
+
if (alias) {
|
|
903
|
+
return alias;
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Manages virtual EntityField records for IS-A parent entity fields.
|
|
909
|
+
* For each entity with ParentID set (IS-A child), creates/updates virtual field records
|
|
910
|
+
* that mirror the parent entity's base table fields (excluding PKs, timestamps, and virtual fields).
|
|
911
|
+
* Runs collision detection to prevent child table columns from shadowing parent fields.
|
|
912
|
+
*/
|
|
913
|
+
async manageParentEntityFields(pool) {
|
|
914
|
+
let bSuccess = true;
|
|
915
|
+
let anyUpdates = false;
|
|
916
|
+
const md = new Metadata();
|
|
917
|
+
const childEntities = md.Entities.filter(e => e.IsChildType);
|
|
918
|
+
if (childEntities.length === 0) {
|
|
919
|
+
return { success: true, anyUpdates: false };
|
|
920
|
+
}
|
|
921
|
+
logStatus(` Processing IS-A parent fields for ${childEntities.length} child entit${childEntities.length === 1 ? 'y' : 'ies'}...`);
|
|
922
|
+
for (const childEntity of childEntities) {
|
|
923
|
+
try {
|
|
924
|
+
const { success, updated } = await this.manageSingleEntityParentFields(pool, childEntity);
|
|
925
|
+
anyUpdates = anyUpdates || updated;
|
|
926
|
+
if (!success) {
|
|
927
|
+
logError(` Error managing IS-A parent fields for ${childEntity.Name}`);
|
|
928
|
+
bSuccess = false;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
catch (e) {
|
|
932
|
+
logError(` Exception managing IS-A parent fields for ${childEntity.Name}: ${e}`);
|
|
933
|
+
bSuccess = false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return { success: bSuccess, anyUpdates };
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Creates/updates virtual EntityField records for a single child entity's parent fields.
|
|
940
|
+
* Detects field name collisions between child's own base table columns and parent fields.
|
|
941
|
+
*/
|
|
942
|
+
async manageSingleEntityParentFields(pool, childEntity) {
|
|
943
|
+
let bUpdated = false;
|
|
944
|
+
// Get all parent fields: non-PK, non-__mj_, non-virtual from each parent in chain
|
|
945
|
+
const parentFields = childEntity.AllParentFields;
|
|
946
|
+
if (parentFields.length === 0) {
|
|
947
|
+
return { success: true, updated: false };
|
|
948
|
+
}
|
|
949
|
+
// Get child's own (non-virtual) field names for collision detection
|
|
950
|
+
const childOwnFieldNames = new Set(childEntity.Fields.filter(f => !f.IsVirtual).map(f => f.Name.toLowerCase()));
|
|
951
|
+
for (const parentField of parentFields) {
|
|
952
|
+
// Collision detection: child's own base table column has same name as parent field.
|
|
953
|
+
// This uses in-memory metadata which filters to non-virtual (base table) fields only.
|
|
954
|
+
if (childOwnFieldNames.has(parentField.Name.toLowerCase())) {
|
|
955
|
+
logError(` FIELD COLLISION: Entity '${childEntity.Name}' has its own column '${parentField.Name}' ` +
|
|
956
|
+
`that conflicts with IS-A parent field '${parentField.Name}' from '${parentField.Entity}'. ` +
|
|
957
|
+
`Rename the child column to resolve this collision. Skipping IS-A field sync for this entity.`);
|
|
958
|
+
return { success: false, updated: false };
|
|
959
|
+
}
|
|
960
|
+
// Check the DATABASE for existing field record — in-memory metadata may be stale
|
|
961
|
+
// (e.g. createNewEntityFieldsFromSchema may have already added this field from the view)
|
|
962
|
+
const existsResult = await pool.request()
|
|
963
|
+
.input('EntityID', childEntity.ID)
|
|
964
|
+
.input('FieldName', parentField.Name)
|
|
965
|
+
.query(`SELECT ID, IsVirtual, Type, Length, Precision, Scale, AllowsNull, AllowUpdateAPI
|
|
966
|
+
FROM [${mj_core_schema()}].EntityField
|
|
967
|
+
WHERE EntityID = @EntityID AND Name = @FieldName`);
|
|
968
|
+
if (existsResult.recordset.length > 0) {
|
|
969
|
+
// Field already exists — update it to ensure it's marked as a virtual IS-A field
|
|
970
|
+
const existingRow = existsResult.recordset[0];
|
|
971
|
+
const needsUpdate = !existingRow.IsVirtual ||
|
|
972
|
+
existingRow.Type?.trim().toLowerCase() !== parentField.Type.trim().toLowerCase() ||
|
|
973
|
+
existingRow.Length !== parentField.Length ||
|
|
974
|
+
existingRow.Precision !== parentField.Precision ||
|
|
975
|
+
existingRow.Scale !== parentField.Scale ||
|
|
976
|
+
existingRow.AllowsNull !== parentField.AllowsNull ||
|
|
977
|
+
!existingRow.AllowUpdateAPI;
|
|
978
|
+
if (needsUpdate) {
|
|
979
|
+
const sqlUpdate = `UPDATE [${mj_core_schema()}].EntityField
|
|
980
|
+
SET IsVirtual=1,
|
|
981
|
+
Type='${parentField.Type}',
|
|
982
|
+
Length=${parentField.Length},
|
|
983
|
+
Precision=${parentField.Precision},
|
|
984
|
+
Scale=${parentField.Scale},
|
|
985
|
+
AllowsNull=${parentField.AllowsNull ? 1 : 0},
|
|
986
|
+
AllowUpdateAPI=1
|
|
987
|
+
WHERE ID='${existingRow.ID}'`;
|
|
988
|
+
await this.LogSQLAndExecute(pool, sqlUpdate, `Update IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
989
|
+
bUpdated = true;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
// Create new virtual field record for this parent field
|
|
994
|
+
const newFieldID = this.createNewUUID();
|
|
995
|
+
// Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema
|
|
996
|
+
const sequence = 100000 + parentFields.indexOf(parentField);
|
|
997
|
+
const sqlInsert = `INSERT INTO [${mj_core_schema()}].EntityField (
|
|
998
|
+
ID, EntityID, Name, Type, AllowsNull,
|
|
999
|
+
Length, Precision, Scale,
|
|
1000
|
+
Sequence, IsVirtual, AllowUpdateAPI,
|
|
1001
|
+
IsPrimaryKey, IsUnique)
|
|
1002
|
+
VALUES (
|
|
1003
|
+
'${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
|
|
1004
|
+
'${parentField.Type}', ${parentField.AllowsNull ? 1 : 0},
|
|
1005
|
+
${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
|
|
1006
|
+
${sequence}, 1, 1, 0, 0)`;
|
|
1007
|
+
await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
1008
|
+
bUpdated = true;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// Remove stale IS-A parent virtual fields no longer in the parent chain.
|
|
1012
|
+
// IS-A parent fields are identified by IsVirtual=true AND AllowUpdateAPI=true.
|
|
1013
|
+
const currentParentFieldNames = new Set(parentFields.map(f => f.Name.toLowerCase()));
|
|
1014
|
+
const staleFields = childEntity.Fields.filter(f => f.IsVirtual && f.AllowUpdateAPI &&
|
|
1015
|
+
!f.IsPrimaryKey && !f.Name.startsWith('__mj_') &&
|
|
1016
|
+
!currentParentFieldNames.has(f.Name.toLowerCase()));
|
|
1017
|
+
for (const staleField of staleFields) {
|
|
1018
|
+
const sqlDelete = `DELETE FROM [${mj_core_schema()}].EntityField WHERE ID='${staleField.ID}'`;
|
|
1019
|
+
await this.LogSQLAndExecute(pool, sqlDelete, `Remove stale IS-A parent field ${staleField.Name} from ${childEntity.Name}`);
|
|
1020
|
+
bUpdated = true;
|
|
1021
|
+
}
|
|
1022
|
+
if (bUpdated) {
|
|
1023
|
+
const sqlUpdate = `UPDATE [${mj_core_schema()}].Entity SET [${EntityInfo.UpdatedAtFieldName}]=GETUTCDATE() WHERE ID='${childEntity.ID}'`;
|
|
1024
|
+
await this.LogSQLAndExecute(pool, sqlUpdate, `Update entity timestamp for ${childEntity.Name} after IS-A field sync`);
|
|
1025
|
+
}
|
|
1026
|
+
return { success: true, updated: bUpdated };
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Checks if an existing virtual parent field record needs to be updated to match the parent field.
|
|
1030
|
+
*/
|
|
1031
|
+
parentFieldNeedsUpdate(existing, parentField) {
|
|
1032
|
+
return existing.Type.trim().toLowerCase() !== parentField.Type.trim().toLowerCase() ||
|
|
1033
|
+
existing.Length !== parentField.Length ||
|
|
1034
|
+
existing.Precision !== parentField.Precision ||
|
|
1035
|
+
existing.Scale !== parentField.Scale ||
|
|
1036
|
+
existing.AllowsNull !== parentField.AllowsNull ||
|
|
1037
|
+
!existing.AllowUpdateAPI;
|
|
1038
|
+
}
|
|
310
1039
|
/**
|
|
311
1040
|
* This method creates and updates relationships in the metadata based on foreign key relationships in the database.
|
|
312
1041
|
* @param pool
|
|
@@ -496,7 +1225,7 @@ export class ManageMetadataBase {
|
|
|
496
1225
|
* @param excludeSchemas
|
|
497
1226
|
* @returns
|
|
498
1227
|
*/
|
|
499
|
-
async manageEntityFields(pool, excludeSchemas, skipCreatedAtUpdatedAtDeletedAtFieldValidation, skipEntityFieldValues, currentUser, skipAdvancedGeneration) {
|
|
1228
|
+
async manageEntityFields(pool, excludeSchemas, skipCreatedAtUpdatedAtDeletedAtFieldValidation, skipEntityFieldValues, currentUser, skipAdvancedGeneration, skipDeleteUnneededFields = false) {
|
|
500
1229
|
let bSuccess = true;
|
|
501
1230
|
const startTime = new Date();
|
|
502
1231
|
if (!skipCreatedAtUpdatedAtDeletedAtFieldValidation) {
|
|
@@ -508,11 +1237,16 @@ export class ManageMetadataBase {
|
|
|
508
1237
|
logStatus(` Ensured ${EntityInfo.CreatedAtFieldName}/${EntityInfo.UpdatedAtFieldName}/${EntityInfo.DeletedAtFieldName} fields exist in ${(new Date().getTime() - startTime.getTime()) / 1000} seconds`);
|
|
509
1238
|
}
|
|
510
1239
|
const step1StartTime = new Date();
|
|
511
|
-
if (
|
|
512
|
-
|
|
513
|
-
|
|
1240
|
+
if (skipDeleteUnneededFields) {
|
|
1241
|
+
logStatus(` Skipping deletion of unneeded entity fields (deferred to post-SQL pass)`);
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
if (!await this.deleteUnneededEntityFields(pool, excludeSchemas)) {
|
|
1245
|
+
logError('Error deleting unneeded entity fields');
|
|
1246
|
+
bSuccess = false;
|
|
1247
|
+
}
|
|
1248
|
+
logStatus(` Deleted unneeded entity fields in ${(new Date().getTime() - step1StartTime.getTime()) / 1000} seconds`);
|
|
514
1249
|
}
|
|
515
|
-
logStatus(` Deleted unneeded entity fields in ${(new Date().getTime() - step1StartTime.getTime()) / 1000} seconds`);
|
|
516
1250
|
// AN: 14-June-2025 - See note below about the new order of these steps, this must
|
|
517
1251
|
// happen before we update existing entity fields from schema.
|
|
518
1252
|
const step2StartTime = new Date();
|
|
@@ -537,6 +1271,24 @@ export class ManageMetadataBase {
|
|
|
537
1271
|
logError('Error applying soft PK/FK configuration');
|
|
538
1272
|
}
|
|
539
1273
|
logStatus(` Applied soft PK/FK configuration in ${(new Date().getTime() - stepConfigStartTime.getTime()) / 1000} seconds`);
|
|
1274
|
+
// CRITICAL: Refresh metadata to pick up soft PK/FK flags
|
|
1275
|
+
// Without this, downstream SQL and TypeScript generation will fail
|
|
1276
|
+
// because entity.Fields and entity.PrimaryKeys won't reflect the updated flags
|
|
1277
|
+
if (configInfo.additionalSchemaInfo) {
|
|
1278
|
+
logStatus(' Refreshing metadata after applying soft PK/FK configuration...');
|
|
1279
|
+
const md = new Metadata();
|
|
1280
|
+
await md.Refresh();
|
|
1281
|
+
logStatus(' Metadata refresh complete');
|
|
1282
|
+
}
|
|
1283
|
+
// IS-A parent field sync: create/update virtual EntityField records for parent chain fields
|
|
1284
|
+
// Must run AFTER metadata refresh so it sees current soft PK/FK flags
|
|
1285
|
+
const stepISAStartTime = new Date();
|
|
1286
|
+
const isaResult = await this.manageParentEntityFields(pool);
|
|
1287
|
+
if (!isaResult.success) {
|
|
1288
|
+
logError('Error managing IS-A parent entity fields');
|
|
1289
|
+
bSuccess = false;
|
|
1290
|
+
}
|
|
1291
|
+
logStatus(` Managed IS-A parent entity fields in ${(new Date().getTime() - stepISAStartTime.getTime()) / 1000} seconds`);
|
|
540
1292
|
const step4StartTime = new Date();
|
|
541
1293
|
if (!await this.setDefaultColumnWidthWhereNeeded(pool, excludeSchemas)) {
|
|
542
1294
|
logError('Error setting default column width where needed');
|
|
@@ -634,51 +1386,61 @@ export class ManageMetadataBase {
|
|
|
634
1386
|
let totalPKs = 0;
|
|
635
1387
|
let totalFKs = 0;
|
|
636
1388
|
const schema = mj_core_schema();
|
|
637
|
-
|
|
1389
|
+
// Config supports two formats:
|
|
1390
|
+
// 1. Schema-as-key (template format): { "dbo": [{ "TableName": "Orders", ... }] }
|
|
1391
|
+
// 2. Flat tables array (legacy format): { "tables": [{ "SchemaName": "dbo", "TableName": "Orders", ... }] }
|
|
1392
|
+
// Both use PascalCase property names.
|
|
1393
|
+
const tables = this.extractTablesFromConfig(config);
|
|
1394
|
+
for (const table of tables) {
|
|
1395
|
+
const tableSchema = table.SchemaName;
|
|
1396
|
+
const tableName = table.TableName;
|
|
638
1397
|
// Look up entity ID (SELECT query - no need to log to migration file)
|
|
639
|
-
const entityLookupSQL = `SELECT ID FROM [${schema}].[Entity] WHERE SchemaName = '${
|
|
1398
|
+
const entityLookupSQL = `SELECT ID FROM [${schema}].[Entity] WHERE SchemaName = '${tableSchema}' AND BaseTable = '${tableName}'`;
|
|
640
1399
|
const entityResult = await pool.request().query(entityLookupSQL);
|
|
641
1400
|
if (entityResult.recordset.length === 0) {
|
|
642
|
-
logStatus(` ⚠️ Entity not found for ${
|
|
1401
|
+
logStatus(` ⚠️ Entity not found for ${tableSchema}.${tableName} - skipping`);
|
|
643
1402
|
continue;
|
|
644
1403
|
}
|
|
645
1404
|
const entityId = entityResult.recordset[0].ID;
|
|
646
1405
|
// Process primary keys - set BOTH IsPrimaryKey = 1 AND IsSoftPrimaryKey = 1
|
|
647
1406
|
// IsPrimaryKey is the source of truth, IsSoftPrimaryKey protects it from schema sync
|
|
648
|
-
|
|
649
|
-
|
|
1407
|
+
const primaryKeys = table.PrimaryKey || [];
|
|
1408
|
+
if (primaryKeys.length > 0) {
|
|
1409
|
+
for (const pk of primaryKeys) {
|
|
650
1410
|
const sSQL = `UPDATE [${schema}].[EntityField]
|
|
651
1411
|
SET ${EntityInfo.UpdatedAtFieldName}=GETUTCDATE(),
|
|
652
1412
|
[IsPrimaryKey] = 1,
|
|
653
1413
|
[IsSoftPrimaryKey] = 1
|
|
654
|
-
WHERE [EntityID] = '${entityId}' AND [Name] = '${pk.
|
|
655
|
-
const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft PK for ${
|
|
1414
|
+
WHERE [EntityID] = '${entityId}' AND [Name] = '${pk.FieldName}'`;
|
|
1415
|
+
const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft PK for ${tableSchema}.${tableName}.${pk.FieldName}`);
|
|
656
1416
|
if (result !== null) {
|
|
657
|
-
logStatus(` ✓ Set IsPrimaryKey=1, IsSoftPrimaryKey=1 for ${
|
|
1417
|
+
logStatus(` ✓ Set IsPrimaryKey=1, IsSoftPrimaryKey=1 for ${tableName}.${pk.FieldName}`);
|
|
658
1418
|
totalPKs++;
|
|
659
1419
|
}
|
|
660
1420
|
}
|
|
661
1421
|
}
|
|
662
1422
|
// Process foreign keys - set RelatedEntityID, RelatedEntityFieldName, and IsSoftForeignKey = 1
|
|
663
|
-
|
|
664
|
-
|
|
1423
|
+
const foreignKeys = table.ForeignKeys || [];
|
|
1424
|
+
if (foreignKeys.length > 0) {
|
|
1425
|
+
for (const fk of foreignKeys) {
|
|
1426
|
+
const fkSchema = fk.SchemaName || tableSchema;
|
|
665
1427
|
// Look up related entity ID (SELECT query - no need to log to migration file)
|
|
666
|
-
const relatedLookupSQL = `SELECT ID FROM [${schema}].[Entity] WHERE SchemaName = '${
|
|
1428
|
+
const relatedLookupSQL = `SELECT ID FROM [${schema}].[Entity] WHERE SchemaName = '${fkSchema}' AND BaseTable = '${fk.RelatedTable}'`;
|
|
667
1429
|
const relatedEntityResult = await pool.request().query(relatedLookupSQL);
|
|
668
1430
|
if (relatedEntityResult.recordset.length === 0) {
|
|
669
|
-
logStatus(` ⚠️ Related entity not found for ${
|
|
1431
|
+
logStatus(` ⚠️ Related entity not found for ${fkSchema}.${fk.RelatedTable} - skipping FK ${fk.FieldName}`);
|
|
670
1432
|
continue;
|
|
671
1433
|
}
|
|
672
1434
|
const relatedEntityId = relatedEntityResult.recordset[0].ID;
|
|
673
1435
|
const sSQL = `UPDATE [${schema}].[EntityField]
|
|
674
1436
|
SET ${EntityInfo.UpdatedAtFieldName}=GETUTCDATE(),
|
|
675
1437
|
[RelatedEntityID] = '${relatedEntityId}',
|
|
676
|
-
[RelatedEntityFieldName] = '${fk.
|
|
1438
|
+
[RelatedEntityFieldName] = '${fk.RelatedField}',
|
|
677
1439
|
[IsSoftForeignKey] = 1
|
|
678
|
-
WHERE [EntityID] = '${entityId}' AND [Name] = '${fk.
|
|
679
|
-
const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${
|
|
1440
|
+
WHERE [EntityID] = '${entityId}' AND [Name] = '${fk.FieldName}'`;
|
|
1441
|
+
const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
|
|
680
1442
|
if (result !== null) {
|
|
681
|
-
logStatus(` ✓ Set soft FK for ${
|
|
1443
|
+
logStatus(` ✓ Set soft FK for ${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
|
|
682
1444
|
totalFKs++;
|
|
683
1445
|
}
|
|
684
1446
|
}
|
|
@@ -892,7 +1654,6 @@ export class ManageMetadataBase {
|
|
|
892
1654
|
ef.EntityID = e.ID
|
|
893
1655
|
WHERE
|
|
894
1656
|
ef.DisplayName IS NULL AND
|
|
895
|
-
ef.DisplayName <> ef.Name AND
|
|
896
1657
|
ef.Name <> \'ID\' AND
|
|
897
1658
|
e.SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})
|
|
898
1659
|
`;
|
|
@@ -1839,7 +2600,7 @@ NumberedRows AS (
|
|
|
1839
2600
|
}
|
|
1840
2601
|
else {
|
|
1841
2602
|
// this is an error condition, we should have an application for this schema, if we don't, log an error, non fatal, but should be logged
|
|
1842
|
-
LogError(` >>>> ERROR: Unable to add new entity ${newEntityName} to an application because an Application record
|
|
2603
|
+
LogError(` >>>> ERROR: Unable to add new entity ${newEntityName} to an application because no Application has SchemaAutoAddNewEntities='${newEntity.SchemaName}'. To fix this, update an existing Application record or create a new one with SchemaAutoAddNewEntities='${newEntity.SchemaName}'.`);
|
|
1843
2604
|
}
|
|
1844
2605
|
// next up, we need to check if we're configured to add permissions for new entities, and if so, add them
|
|
1845
2606
|
if (configInfo.newEntityDefaults.PermissionDefaults && configInfo.newEntityDefaults.PermissionDefaults.AutoAddPermissionsForNewEntities) {
|
|
@@ -1936,6 +2697,63 @@ NumberedRows AS (
|
|
|
1936
2697
|
return apps.map((a) => a.ID);
|
|
1937
2698
|
}
|
|
1938
2699
|
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Adds a newly created entity to the application(s) that match its schema name.
|
|
2702
|
+
* If no application exists for the schema and config allows it, creates one.
|
|
2703
|
+
* Shared by both table-backed entity creation and virtual entity creation.
|
|
2704
|
+
*/
|
|
2705
|
+
async addEntityToApplicationForSchema(pool, entityId, entityName, schemaName, currentUser) {
|
|
2706
|
+
let apps = await this.getApplicationIDForSchema(pool, schemaName);
|
|
2707
|
+
// If no app exists and config says to create one for new schemas, create it
|
|
2708
|
+
if ((!apps || apps.length === 0) && configInfo.newSchemaDefaults.CreateNewApplicationWithSchemaName) {
|
|
2709
|
+
const appUUID = this.createNewUUID();
|
|
2710
|
+
const newAppID = await this.createNewApplication(pool, appUUID, schemaName, schemaName, currentUser);
|
|
2711
|
+
if (newAppID) {
|
|
2712
|
+
apps = [newAppID];
|
|
2713
|
+
const md = new Metadata();
|
|
2714
|
+
await md.Refresh();
|
|
2715
|
+
}
|
|
2716
|
+
else {
|
|
2717
|
+
LogError(` >>>> ERROR: Unable to create new application for schema ${schemaName}`);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
if (apps && apps.length > 0) {
|
|
2721
|
+
if (configInfo.newEntityDefaults.AddToApplicationWithSchemaName) {
|
|
2722
|
+
for (const appUUID of apps) {
|
|
2723
|
+
const sSQLInsert = `INSERT INTO ${mj_core_schema()}.ApplicationEntity
|
|
2724
|
+
(ApplicationID, EntityID, Sequence) VALUES
|
|
2725
|
+
('${appUUID}', '${entityId}', (SELECT ISNULL(MAX(Sequence),0)+1 FROM ${mj_core_schema()}.ApplicationEntity WHERE ApplicationID = '${appUUID}'))`;
|
|
2726
|
+
await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add entity ${entityName} to application ID: '${appUUID}'`);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
else {
|
|
2731
|
+
LogError(` >>>> WARNING: No application found for schema ${schemaName} to add entity ${entityName}`);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Adds default permissions for a newly created entity based on config settings.
|
|
2736
|
+
* Shared by both table-backed entity creation and virtual entity creation.
|
|
2737
|
+
*/
|
|
2738
|
+
async addDefaultPermissionsForEntity(pool, entityId, entityName) {
|
|
2739
|
+
if (!configInfo.newEntityDefaults.PermissionDefaults?.AutoAddPermissionsForNewEntities) {
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
const md = new Metadata();
|
|
2743
|
+
const permissions = configInfo.newEntityDefaults.PermissionDefaults.Permissions;
|
|
2744
|
+
for (const p of permissions) {
|
|
2745
|
+
const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
|
|
2746
|
+
if (RoleID) {
|
|
2747
|
+
const sSQLInsert = `INSERT INTO ${mj_core_schema()}.EntityPermission
|
|
2748
|
+
(EntityID, RoleID, CanRead, CanCreate, CanUpdate, CanDelete) VALUES
|
|
2749
|
+
('${entityId}', '${RoleID}', ${p.CanRead ? 1 : 0}, ${p.CanCreate ? 1 : 0}, ${p.CanUpdate ? 1 : 0}, ${p.CanDelete ? 1 : 0})`;
|
|
2750
|
+
await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add permission for entity ${entityName} for role ${p.RoleName}`);
|
|
2751
|
+
}
|
|
2752
|
+
else {
|
|
2753
|
+
LogError(` >>>> ERROR: Unable to find Role ID for role ${p.RoleName} to add permissions for entity ${entityName}`);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
1939
2757
|
createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
|
|
1940
2758
|
const newEntityDefaults = configInfo.newEntityDefaults;
|
|
1941
2759
|
const newEntityDescriptionEscaped = newEntity.Description ? `'${newEntity.Description.replace(/'/g, "''")}` : null;
|
|
@@ -2029,7 +2847,8 @@ NumberedRows AS (
|
|
|
2029
2847
|
e.Name,
|
|
2030
2848
|
e.Description,
|
|
2031
2849
|
e.SchemaName,
|
|
2032
|
-
e.BaseTable
|
|
2850
|
+
e.BaseTable,
|
|
2851
|
+
e.ParentID
|
|
2033
2852
|
FROM
|
|
2034
2853
|
[${mj_core_schema()}].vwEntities e
|
|
2035
2854
|
WHERE
|
|
@@ -2062,7 +2881,9 @@ NumberedRows AS (
|
|
|
2062
2881
|
ef.AutoUpdateCategory,
|
|
2063
2882
|
ef.AutoUpdateDisplayName,
|
|
2064
2883
|
ef.EntityIDFieldName,
|
|
2065
|
-
ef.RelatedEntity
|
|
2884
|
+
ef.RelatedEntity,
|
|
2885
|
+
ef.IsVirtual,
|
|
2886
|
+
ef.AllowUpdateAPI
|
|
2066
2887
|
FROM
|
|
2067
2888
|
[${mj_core_schema()}].vwEntityFields ef
|
|
2068
2889
|
WHERE
|
|
@@ -2083,7 +2904,7 @@ NumberedRows AS (
|
|
|
2083
2904
|
[${mj_core_schema()}].EntitySetting es
|
|
2084
2905
|
WHERE
|
|
2085
2906
|
es.EntityID IN (${entityIds})
|
|
2086
|
-
AND es.Name
|
|
2907
|
+
AND es.Name = 'FieldCategoryInfo'
|
|
2087
2908
|
`;
|
|
2088
2909
|
const settingsResult = await pool.request().query(settingsSQL);
|
|
2089
2910
|
const allSettings = settingsResult.recordset;
|
|
@@ -2166,13 +2987,17 @@ NumberedRows AS (
|
|
|
2166
2987
|
}
|
|
2167
2988
|
// Form Layout Generation
|
|
2168
2989
|
// Only run if at least one field allows auto-update
|
|
2169
|
-
|
|
2990
|
+
const needsCategoryGeneration = fields.some((f) => f.AutoUpdateCategory && (!f.Category || f.Category.trim() === ''));
|
|
2991
|
+
if (needsCategoryGeneration) {
|
|
2992
|
+
// Build IS-A parent chain context if this entity has a parent
|
|
2993
|
+
const parentChainContext = this.buildParentChainContext(entity, fields);
|
|
2170
2994
|
const layoutAnalysis = await ag.generateFormLayout({
|
|
2171
2995
|
Name: entity.Name,
|
|
2172
2996
|
Description: entity.Description,
|
|
2173
2997
|
SchemaName: entity.SchemaName,
|
|
2174
2998
|
Settings: entity.Settings,
|
|
2175
|
-
Fields: fields
|
|
2999
|
+
Fields: fields,
|
|
3000
|
+
...parentChainContext
|
|
2176
3001
|
}, currentUser, isNewEntity);
|
|
2177
3002
|
if (layoutAnalysis) {
|
|
2178
3003
|
await this.applyFormLayout(pool, entity.ID, fields, layoutAnalysis, isNewEntity);
|
|
@@ -2180,6 +3005,67 @@ NumberedRows AS (
|
|
|
2180
3005
|
}
|
|
2181
3006
|
}
|
|
2182
3007
|
}
|
|
3008
|
+
/**
|
|
3009
|
+
* Builds IS-A parent chain context for an entity, computing which parent each
|
|
3010
|
+
* inherited field originates from. Used to provide the LLM with inheritance
|
|
3011
|
+
* awareness during form layout generation.
|
|
3012
|
+
*
|
|
3013
|
+
* Returns an empty object for entities without parents, so it can be safely spread
|
|
3014
|
+
* into the entity object passed to generateFormLayout().
|
|
3015
|
+
*/
|
|
3016
|
+
buildParentChainContext(entity, fields) {
|
|
3017
|
+
if (!entity.ParentID) {
|
|
3018
|
+
return {};
|
|
3019
|
+
}
|
|
3020
|
+
// Walk the IS-A chain using in-memory metadata
|
|
3021
|
+
const md = new Metadata();
|
|
3022
|
+
const allEntities = md.Entities;
|
|
3023
|
+
const parentChain = [];
|
|
3024
|
+
const visited = new Set();
|
|
3025
|
+
let currentParentID = entity.ParentID;
|
|
3026
|
+
while (currentParentID) {
|
|
3027
|
+
if (visited.has(currentParentID))
|
|
3028
|
+
break; // circular reference guard
|
|
3029
|
+
visited.add(currentParentID);
|
|
3030
|
+
const parentEntity = allEntities.find(e => e.ID === currentParentID);
|
|
3031
|
+
if (!parentEntity)
|
|
3032
|
+
break;
|
|
3033
|
+
parentChain.push({ entityID: parentEntity.ID, entityName: parentEntity.Name });
|
|
3034
|
+
currentParentID = parentEntity.ParentID ?? null;
|
|
3035
|
+
}
|
|
3036
|
+
if (parentChain.length === 0) {
|
|
3037
|
+
return {};
|
|
3038
|
+
}
|
|
3039
|
+
// Annotate each field with its source parent (if inherited)
|
|
3040
|
+
// An IS-A inherited field is: IsVirtual=true, AllowUpdateAPI=true, not PK, not __mj_
|
|
3041
|
+
for (const field of fields) {
|
|
3042
|
+
if (field.IsVirtual && field.AllowUpdateAPI && !field.IsPrimaryKey && !field.Name.startsWith('__mj_')) {
|
|
3043
|
+
const sourceParent = this.findFieldSourceParent(field.Name, parentChain, allEntities);
|
|
3044
|
+
if (sourceParent) {
|
|
3045
|
+
field.InheritedFromEntityID = sourceParent.entityID;
|
|
3046
|
+
field.InheritedFromEntityName = sourceParent.entityName;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
return { ParentChain: parentChain, IsChildEntity: true };
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* For an inherited field, walks the parent chain to find which specific parent entity
|
|
3054
|
+
* originally defines this field (by matching non-virtual fields on each parent).
|
|
3055
|
+
*/
|
|
3056
|
+
findFieldSourceParent(fieldName, parentChain, allEntities) {
|
|
3057
|
+
for (const parent of parentChain) {
|
|
3058
|
+
const parentEntity = allEntities.find(e => e.ID === parent.entityID);
|
|
3059
|
+
if (!parentEntity)
|
|
3060
|
+
continue;
|
|
3061
|
+
// Check if this parent has a non-virtual field with this name
|
|
3062
|
+
const hasField = parentEntity.Fields.some(f => f.Name === fieldName && !f.IsVirtual);
|
|
3063
|
+
if (hasField) {
|
|
3064
|
+
return parent;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
return null;
|
|
3068
|
+
}
|
|
2183
3069
|
/**
|
|
2184
3070
|
* Apply smart field identification results to entity fields
|
|
2185
3071
|
*/
|
|
@@ -2239,7 +3125,8 @@ NumberedRows AS (
|
|
|
2239
3125
|
}
|
|
2240
3126
|
}
|
|
2241
3127
|
/**
|
|
2242
|
-
* Apply form layout generation results to set category on entity fields
|
|
3128
|
+
* Apply form layout generation results to set category on entity fields.
|
|
3129
|
+
* Delegates to shared methods for category assignment, icon, and category info persistence.
|
|
2243
3130
|
* @param pool Database connection pool
|
|
2244
3131
|
* @param entityId Entity ID to update
|
|
2245
3132
|
* @param fields Entity fields
|
|
@@ -2247,17 +3134,47 @@ NumberedRows AS (
|
|
|
2247
3134
|
* @param isNewEntity If true, apply entityImportance; if false, skip it
|
|
2248
3135
|
*/
|
|
2249
3136
|
async applyFormLayout(pool, entityId, fields, result, isNewEntity = false) {
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
3137
|
+
const existingCategories = this.buildExistingCategorySet(fields);
|
|
3138
|
+
await this.applyFieldCategories(pool, entityId, fields, result.fieldCategories, existingCategories);
|
|
3139
|
+
if (result.entityIcon) {
|
|
3140
|
+
await this.applyEntityIcon(pool, entityId, result.entityIcon);
|
|
3141
|
+
}
|
|
3142
|
+
// Resolve categoryInfo from new or legacy format
|
|
3143
|
+
const categoryInfoToStore = result.categoryInfo ||
|
|
3144
|
+
(result.categoryIcons ?
|
|
3145
|
+
Object.fromEntries(Object.entries(result.categoryIcons).map(([cat, icon]) => [cat, { icon, description: '' }])) : null);
|
|
3146
|
+
if (categoryInfoToStore) {
|
|
3147
|
+
await this.applyCategoryInfoSettings(pool, entityId, categoryInfoToStore);
|
|
3148
|
+
}
|
|
3149
|
+
if (isNewEntity && result.entityImportance) {
|
|
3150
|
+
await this.applyEntityImportance(pool, entityId, result.entityImportance);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3154
|
+
// Shared category / icon / settings persistence methods
|
|
3155
|
+
// Used by both the regular entity pipeline and VE decoration pipeline
|
|
3156
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3157
|
+
/**
|
|
3158
|
+
* Builds a set of existing category names from entity fields.
|
|
3159
|
+
* Used to enforce category stability (prevent renaming).
|
|
3160
|
+
*/
|
|
3161
|
+
buildExistingCategorySet(fields) {
|
|
2253
3162
|
const existingCategories = new Set();
|
|
2254
3163
|
for (const field of fields) {
|
|
2255
3164
|
if (field.Category && field.Category.trim() !== '') {
|
|
2256
3165
|
existingCategories.add(field.Category);
|
|
2257
3166
|
}
|
|
2258
3167
|
}
|
|
2259
|
-
|
|
2260
|
-
|
|
3168
|
+
return existingCategories;
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Applies category, display name, extended type, and code type to entity fields.
|
|
3172
|
+
* Enforces stability rules: fields with existing categories cannot move to NEW categories.
|
|
3173
|
+
* All SQL updates are batched into a single execution for performance.
|
|
3174
|
+
*/
|
|
3175
|
+
async applyFieldCategories(pool, entityId, fields, fieldCategories, existingCategories) {
|
|
3176
|
+
const sqlStatements = [];
|
|
3177
|
+
for (const fieldCategory of fieldCategories) {
|
|
2261
3178
|
const field = fields.find(f => f.Name === fieldCategory.fieldName);
|
|
2262
3179
|
if (field && field.AutoUpdateCategory && field.ID) {
|
|
2263
3180
|
// Override category to "System Metadata" for __mj_ fields (system audit fields)
|
|
@@ -2266,158 +3183,126 @@ NumberedRows AS (
|
|
|
2266
3183
|
category = 'System Metadata';
|
|
2267
3184
|
}
|
|
2268
3185
|
// ENFORCEMENT: Prevent category renaming
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
// 2. Moving to another EXISTING category
|
|
2272
|
-
// New categories are only allowed for fields that don't already have a category
|
|
2273
|
-
const fieldHasExistingCategory = field.Category && field.Category.trim() !== '';
|
|
2274
|
-
const categoryIsExisting = existingCategories.has(category);
|
|
2275
|
-
const categoryIsNew = !categoryIsExisting;
|
|
3186
|
+
const fieldHasExistingCategory = field.Category != null && field.Category.trim() !== '';
|
|
3187
|
+
const categoryIsNew = !existingCategories.has(category);
|
|
2276
3188
|
if (fieldHasExistingCategory && categoryIsNew) {
|
|
2277
|
-
// LLM is trying to move an existing field to a brand new category
|
|
2278
|
-
// This could be an attempt to rename a category - reject it
|
|
2279
3189
|
logStatus(` Rejected category change for field '${field.Name}': cannot move from existing category '${field.Category}' to new category '${category}'. Keeping original category.`);
|
|
2280
|
-
category = field.Category;
|
|
3190
|
+
category = field.Category;
|
|
2281
3191
|
}
|
|
2282
|
-
// Build SET clause with all available metadata
|
|
2283
3192
|
const setClauses = [
|
|
2284
3193
|
`Category = '${category.replace(/'/g, "''")}'`,
|
|
2285
3194
|
`GeneratedFormSection = 'Category'`
|
|
2286
3195
|
];
|
|
2287
|
-
// Add DisplayName if provided and field allows auto-update
|
|
2288
3196
|
if (fieldCategory.displayName && field.AutoUpdateDisplayName) {
|
|
2289
3197
|
setClauses.push(`DisplayName = '${fieldCategory.displayName.replace(/'/g, "''")}'`);
|
|
2290
3198
|
}
|
|
2291
|
-
// Add ExtendedType if provided
|
|
2292
3199
|
if (fieldCategory.extendedType !== undefined) {
|
|
2293
|
-
const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${fieldCategory.extendedType.replace(/'/g, "''")}'`;
|
|
3200
|
+
const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${String(fieldCategory.extendedType).replace(/'/g, "''")}'`;
|
|
2294
3201
|
setClauses.push(`ExtendedType = ${extendedType}`);
|
|
2295
3202
|
}
|
|
2296
|
-
// Add CodeType if provided
|
|
2297
3203
|
if (fieldCategory.codeType !== undefined) {
|
|
2298
|
-
const codeType = fieldCategory.codeType === null ? 'NULL' : `'${fieldCategory.codeType.replace(/'/g, "''")}'`;
|
|
3204
|
+
const codeType = fieldCategory.codeType === null ? 'NULL' : `'${String(fieldCategory.codeType).replace(/'/g, "''")}'`;
|
|
2299
3205
|
setClauses.push(`CodeType = ${codeType}`);
|
|
2300
3206
|
}
|
|
2301
|
-
|
|
3207
|
+
sqlStatements.push(`UPDATE [${mj_core_schema()}].EntityField
|
|
2302
3208
|
SET ${setClauses.join(',\n ')}
|
|
2303
3209
|
WHERE ID = '${field.ID}'
|
|
2304
|
-
AND AutoUpdateCategory = 1
|
|
2305
|
-
sqlStatements.push(updateSQL);
|
|
3210
|
+
AND AutoUpdateCategory = 1`);
|
|
2306
3211
|
}
|
|
2307
3212
|
else if (!field) {
|
|
2308
|
-
logError(`Form layout
|
|
3213
|
+
logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
|
|
2309
3214
|
}
|
|
2310
3215
|
}
|
|
2311
|
-
// Execute all field updates in one batch
|
|
2312
3216
|
if (sqlStatements.length > 0) {
|
|
2313
|
-
|
|
2314
|
-
await this.LogSQLAndExecute(pool, combinedSQL, `Set categories for ${sqlStatements.length} fields`, false);
|
|
2315
|
-
}
|
|
2316
|
-
// Store entity icon if provided and entity doesn't already have one
|
|
2317
|
-
if (result.entityIcon && result.entityIcon.trim().length > 0) {
|
|
2318
|
-
// Check if entity already has an icon
|
|
2319
|
-
const checkEntitySQL = `
|
|
2320
|
-
SELECT Icon FROM [${mj_core_schema()}].Entity
|
|
2321
|
-
WHERE ID = '${entityId}'
|
|
2322
|
-
`;
|
|
2323
|
-
const entityCheck = await pool.request().query(checkEntitySQL);
|
|
2324
|
-
if (entityCheck.recordset.length > 0) {
|
|
2325
|
-
const currentIcon = entityCheck.recordset[0].Icon;
|
|
2326
|
-
// Only update if entity doesn't have an icon set
|
|
2327
|
-
if (!currentIcon || currentIcon.trim().length === 0) {
|
|
2328
|
-
const escapedIcon = result.entityIcon.replace(/'/g, "''");
|
|
2329
|
-
const updateEntitySQL = `
|
|
2330
|
-
UPDATE [${mj_core_schema()}].Entity
|
|
2331
|
-
SET Icon = '${escapedIcon}',
|
|
2332
|
-
__mj_UpdatedAt = GETUTCDATE()
|
|
2333
|
-
WHERE ID = '${entityId}'
|
|
2334
|
-
`;
|
|
2335
|
-
await this.LogSQLAndExecute(pool, updateEntitySQL, `Set entity icon to ${result.entityIcon}`, false);
|
|
2336
|
-
logStatus(` ✓ Set entity icon: ${result.entityIcon}`);
|
|
2337
|
-
}
|
|
2338
|
-
}
|
|
3217
|
+
await this.LogSQLAndExecute(pool, sqlStatements.join('\n'), `Set categories for ${sqlStatements.length} fields`, false);
|
|
2339
3218
|
}
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
const existingNew = await pool.request().query(checkNewSQL);
|
|
2354
|
-
if (existingNew.recordset.length > 0) {
|
|
2355
|
-
// Update existing setting
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Sets the entity icon if the entity doesn't already have one.
|
|
3222
|
+
*/
|
|
3223
|
+
async applyEntityIcon(pool, entityId, entityIcon) {
|
|
3224
|
+
if (!entityIcon || entityIcon.trim().length === 0)
|
|
3225
|
+
return;
|
|
3226
|
+
const checkSQL = `SELECT Icon FROM [${mj_core_schema()}].Entity WHERE ID = '${entityId}'`;
|
|
3227
|
+
const entityCheck = await pool.request().query(checkSQL);
|
|
3228
|
+
if (entityCheck.recordset.length > 0) {
|
|
3229
|
+
const currentIcon = entityCheck.recordset[0].Icon;
|
|
3230
|
+
if (!currentIcon || currentIcon.trim().length === 0) {
|
|
3231
|
+
const escapedIcon = entityIcon.replace(/'/g, "''");
|
|
2356
3232
|
const updateSQL = `
|
|
2357
|
-
UPDATE [${mj_core_schema()}].
|
|
2358
|
-
SET
|
|
2359
|
-
|
|
2360
|
-
WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'
|
|
3233
|
+
UPDATE [${mj_core_schema()}].Entity
|
|
3234
|
+
SET Icon = '${escapedIcon}', __mj_UpdatedAt = GETUTCDATE()
|
|
3235
|
+
WHERE ID = '${entityId}'
|
|
2361
3236
|
`;
|
|
2362
|
-
await this.LogSQLAndExecute(pool, updateSQL, `
|
|
3237
|
+
await this.LogSQLAndExecute(pool, updateSQL, `Set entity icon to ${entityIcon}`, false);
|
|
3238
|
+
logStatus(` Set entity icon: ${entityIcon}`);
|
|
2363
3239
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Upserts FieldCategoryInfo (new format) and FieldCategoryIcons (legacy format) in EntitySetting.
|
|
3244
|
+
*/
|
|
3245
|
+
async applyCategoryInfoSettings(pool, entityId, categoryInfo) {
|
|
3246
|
+
if (!categoryInfo || Object.keys(categoryInfo).length === 0)
|
|
3247
|
+
return;
|
|
3248
|
+
const infoJSON = JSON.stringify(categoryInfo).replace(/'/g, "''");
|
|
3249
|
+
// Upsert FieldCategoryInfo (new format)
|
|
3250
|
+
const checkNewSQL = `SELECT ID FROM [${mj_core_schema()}].EntitySetting WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'`;
|
|
3251
|
+
const existingNew = await pool.request().query(checkNewSQL);
|
|
3252
|
+
if (existingNew.recordset.length > 0) {
|
|
3253
|
+
await this.LogSQLAndExecute(pool, `
|
|
3254
|
+
UPDATE [${mj_core_schema()}].EntitySetting
|
|
3255
|
+
SET Value = '${infoJSON}', __mj_UpdatedAt = GETUTCDATE()
|
|
3256
|
+
WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'
|
|
3257
|
+
`, `Update FieldCategoryInfo setting for entity`, false);
|
|
3258
|
+
}
|
|
3259
|
+
else {
|
|
3260
|
+
const newId = uuidv4();
|
|
3261
|
+
await this.LogSQLAndExecute(pool, `
|
|
3262
|
+
INSERT INTO [${mj_core_schema()}].EntitySetting (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
|
|
3263
|
+
VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', GETUTCDATE(), GETUTCDATE())
|
|
3264
|
+
`, `Insert FieldCategoryInfo setting for entity`, false);
|
|
3265
|
+
}
|
|
3266
|
+
// Also upsert legacy FieldCategoryIcons for backwards compatibility
|
|
3267
|
+
const iconsOnly = {};
|
|
3268
|
+
for (const [category, info] of Object.entries(categoryInfo)) {
|
|
3269
|
+
if (info && typeof info === 'object' && 'icon' in info) {
|
|
3270
|
+
iconsOnly[category] = info.icon;
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
const iconsJSON = JSON.stringify(iconsOnly).replace(/'/g, "''");
|
|
3274
|
+
const checkLegacySQL = `SELECT ID FROM [${mj_core_schema()}].EntitySetting WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'`;
|
|
3275
|
+
const existingLegacy = await pool.request().query(checkLegacySQL);
|
|
3276
|
+
if (existingLegacy.recordset.length > 0) {
|
|
3277
|
+
await this.LogSQLAndExecute(pool, `
|
|
3278
|
+
UPDATE [${mj_core_schema()}].EntitySetting
|
|
3279
|
+
SET Value = '${iconsJSON}', __mj_UpdatedAt = GETUTCDATE()
|
|
2384
3280
|
WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'
|
|
2385
|
-
|
|
2386
|
-
const existingLegacy = await pool.request().query(checkLegacySQL);
|
|
2387
|
-
if (existingLegacy.recordset.length > 0) {
|
|
2388
|
-
const updateSQL = `
|
|
2389
|
-
UPDATE [${mj_core_schema()}].EntitySetting
|
|
2390
|
-
SET Value = '${iconsJSON}',
|
|
2391
|
-
__mj_UpdatedAt = GETUTCDATE()
|
|
2392
|
-
WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'
|
|
2393
|
-
`;
|
|
2394
|
-
await this.LogSQLAndExecute(pool, updateSQL, `Update FieldCategoryIcons setting for entity (legacy format)`, false);
|
|
2395
|
-
}
|
|
2396
|
-
else {
|
|
2397
|
-
const newId = uuidv4();
|
|
2398
|
-
const insertSQL = `
|
|
2399
|
-
INSERT INTO [${mj_core_schema()}].EntitySetting (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
|
|
2400
|
-
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', GETUTCDATE(), GETUTCDATE())
|
|
2401
|
-
`;
|
|
2402
|
-
await this.LogSQLAndExecute(pool, insertSQL, `Insert FieldCategoryIcons setting for entity (legacy format)`, false);
|
|
2403
|
-
}
|
|
3281
|
+
`, `Update FieldCategoryIcons setting (legacy)`, false);
|
|
2404
3282
|
}
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
UPDATE [${mj_core_schema()}].ApplicationEntity
|
|
2412
|
-
SET DefaultForNewUser = ${defaultForNewUser},
|
|
2413
|
-
__mj_UpdatedAt = GETUTCDATE()
|
|
2414
|
-
WHERE EntityID = '${entityId}'
|
|
2415
|
-
`;
|
|
2416
|
-
await this.LogSQLAndExecute(pool, updateAppEntitySQL, `Set DefaultForNewUser=${defaultForNewUser} for NEW entity based on AI analysis (category: ${result.entityImportance.entityCategory}, confidence: ${result.entityImportance.confidence})`, false);
|
|
2417
|
-
logStatus(` ✓ Entity importance (NEW Entity): ${result.entityImportance.entityCategory} (defaultForNewUser: ${result.entityImportance.defaultForNewUser}, confidence: ${result.entityImportance.confidence})`);
|
|
2418
|
-
logStatus(` Reasoning: ${result.entityImportance.reasoning}`);
|
|
3283
|
+
else {
|
|
3284
|
+
const newId = uuidv4();
|
|
3285
|
+
await this.LogSQLAndExecute(pool, `
|
|
3286
|
+
INSERT INTO [${mj_core_schema()}].EntitySetting (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
|
|
3287
|
+
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', GETUTCDATE(), GETUTCDATE())
|
|
3288
|
+
`, `Insert FieldCategoryIcons setting (legacy)`, false);
|
|
2419
3289
|
}
|
|
2420
3290
|
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Applies entity importance analysis to ApplicationEntity records.
|
|
3293
|
+
* Only called for NEW entities to set DefaultForNewUser.
|
|
3294
|
+
*/
|
|
3295
|
+
async applyEntityImportance(pool, entityId, importance) {
|
|
3296
|
+
const defaultForNewUser = importance.defaultForNewUser ? 1 : 0;
|
|
3297
|
+
const updateSQL = `
|
|
3298
|
+
UPDATE [${mj_core_schema()}].ApplicationEntity
|
|
3299
|
+
SET DefaultForNewUser = ${defaultForNewUser}, __mj_UpdatedAt = GETUTCDATE()
|
|
3300
|
+
WHERE EntityID = '${entityId}'
|
|
3301
|
+
`;
|
|
3302
|
+
await this.LogSQLAndExecute(pool, updateSQL, `Set DefaultForNewUser=${defaultForNewUser} for NEW entity (category: ${importance.entityCategory}, confidence: ${importance.confidence})`, false);
|
|
3303
|
+
logStatus(` Entity importance (NEW Entity): ${importance.entityCategory} (defaultForNewUser: ${importance.defaultForNewUser}, confidence: ${importance.confidence})`);
|
|
3304
|
+
logStatus(` Reasoning: ${importance.reasoning}`);
|
|
3305
|
+
}
|
|
2421
3306
|
/**
|
|
2422
3307
|
* Executes the given SQL query using the given ConnectionPool object.
|
|
2423
3308
|
* If the appendToLogFile parameter is true, the query will also be appended to the log file.
|