@memberjunction/codegen-lib 6.1.0-edge.4 → 6.1.0-edge.6
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 +20 -0
- package/dist/Angular/angular-codegen.d.ts +9 -6
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +80 -51
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Angular/join-grid-related-entity-component.d.ts.map +1 -1
- package/dist/Angular/join-grid-related-entity-component.js +5 -2
- package/dist/Angular/join-grid-related-entity-component.js.map +1 -1
- package/dist/Angular/related-entity-components.d.ts +11 -0
- package/dist/Angular/related-entity-components.d.ts.map +1 -1
- package/dist/Angular/related-entity-components.js +23 -0
- package/dist/Angular/related-entity-components.js.map +1 -1
- package/dist/Angular/timeline-related-entity-component.d.ts.map +1 -1
- package/dist/Angular/timeline-related-entity-component.js +3 -2
- package/dist/Angular/timeline-related-entity-component.js.map +1 -1
- package/dist/Config/config.d.ts +216 -12
- package/dist/Config/config.d.ts.map +1 -1
- package/dist/Config/config.js +88 -6
- package/dist/Config/config.js.map +1 -1
- package/dist/Config/schema-output.d.ts +33 -0
- package/dist/Config/schema-output.d.ts.map +1 -0
- package/dist/Config/schema-output.js +52 -0
- package/dist/Config/schema-output.js.map +1 -0
- package/dist/Database/codeGenDatabaseProvider.d.ts +68 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.js +142 -3
- package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
- package/dist/Database/dbSchema.js +2 -1
- package/dist/Database/dbSchema.js.map +1 -1
- package/dist/Database/decision-metadata-format.d.ts +124 -0
- package/dist/Database/decision-metadata-format.d.ts.map +1 -0
- package/dist/Database/decision-metadata-format.js +279 -0
- package/dist/Database/decision-metadata-format.js.map +1 -0
- package/dist/Database/decision-metadata-writer.d.ts +48 -0
- package/dist/Database/decision-metadata-writer.d.ts.map +1 -0
- package/dist/Database/decision-metadata-writer.js +482 -0
- package/dist/Database/decision-metadata-writer.js.map +1 -0
- package/dist/Database/entity-field-change-tracking.d.ts +53 -0
- package/dist/Database/entity-field-change-tracking.d.ts.map +1 -0
- package/dist/Database/entity-field-change-tracking.js +120 -0
- package/dist/Database/entity-field-change-tracking.js.map +1 -0
- package/dist/Database/entity-level-tree-cycles.d.ts.map +1 -1
- package/dist/Database/entity-level-tree-cycles.js +3 -2
- package/dist/Database/entity-level-tree-cycles.js.map +1 -1
- package/dist/Database/exclude-tables.d.ts +26 -0
- package/dist/Database/exclude-tables.d.ts.map +1 -0
- package/dist/Database/exclude-tables.js +38 -0
- package/dist/Database/exclude-tables.js.map +1 -0
- package/dist/Database/field-metadata-lock.d.ts +47 -0
- package/dist/Database/field-metadata-lock.d.ts.map +1 -0
- package/dist/Database/field-metadata-lock.js +159 -0
- package/dist/Database/field-metadata-lock.js.map +1 -0
- package/dist/Database/manage-metadata.d.ts +142 -42
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +625 -287
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +2 -2
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +90 -41
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/postgresql/metadataSupportObjects.js +31 -8
- package/dist/Database/providers/postgresql/metadataSupportObjects.js.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +3 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +58 -23
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +15 -2
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -1
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.test.js +17 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.test.js.map +1 -1
- package/dist/Database/sql_codegen.d.ts +24 -0
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +108 -29
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts +22 -0
- package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts.map +1 -1
- package/dist/Manifest/GenerateClassRegistrationsManifest.js +115 -7
- package/dist/Manifest/GenerateClassRegistrationsManifest.js.map +1 -1
- package/dist/Misc/action_subclasses_codegen.js +2 -2
- package/dist/Misc/action_subclasses_codegen.js.map +1 -1
- package/dist/Misc/advanced_generation.d.ts +7 -5
- package/dist/Misc/advanced_generation.d.ts.map +1 -1
- package/dist/Misc/advanced_generation.js +34 -23
- package/dist/Misc/advanced_generation.js.map +1 -1
- package/dist/Misc/codegen-reporter.d.ts +5 -0
- package/dist/Misc/codegen-reporter.d.ts.map +1 -1
- package/dist/Misc/codegen-reporter.js +9 -2
- package/dist/Misc/codegen-reporter.js.map +1 -1
- package/dist/Misc/emit-stats.d.ts +28 -0
- package/dist/Misc/emit-stats.d.ts.map +1 -0
- package/dist/Misc/emit-stats.js +43 -0
- package/dist/Misc/emit-stats.js.map +1 -0
- package/dist/Misc/entity_subclasses_codegen.d.ts +20 -2
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +120 -51
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/file-write.d.ts +15 -0
- package/dist/Misc/file-write.d.ts.map +1 -0
- package/dist/Misc/file-write.js +33 -0
- package/dist/Misc/file-write.js.map +1 -0
- package/dist/Misc/graphql_server_codegen.d.ts +12 -2
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +72 -16
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/remote_operations_codegen.d.ts.map +1 -1
- package/dist/Misc/remote_operations_codegen.js +4 -3
- package/dist/Misc/remote_operations_codegen.js.map +1 -1
- package/dist/Misc/runCommand.d.ts +5 -0
- package/dist/Misc/runCommand.d.ts.map +1 -1
- package/dist/Misc/runCommand.js +43 -17
- package/dist/Misc/runCommand.js.map +1 -1
- package/dist/Misc/schema-emit.d.ts +123 -0
- package/dist/Misc/schema-emit.d.ts.map +1 -0
- package/dist/Misc/schema-emit.js +221 -0
- package/dist/Misc/schema-emit.js.map +1 -0
- package/dist/Misc/sql_logging.d.ts +63 -8
- package/dist/Misc/sql_logging.d.ts.map +1 -1
- package/dist/Misc/sql_logging.js +169 -42
- package/dist/Misc/sql_logging.js.map +1 -1
- package/dist/Misc/util.d.ts +12 -0
- package/dist/Misc/util.d.ts.map +1 -1
- package/dist/Misc/util.js +71 -8
- package/dist/Misc/util.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +28 -1
- package/dist/plugins/index.js.map +1 -1
- package/dist/runCodeGen.d.ts +20 -2
- package/dist/runCodeGen.d.ts.map +1 -1
- package/dist/runCodeGen.js +186 -33
- package/dist/runCodeGen.js.map +1 -1
- package/package.json +29 -29
|
@@ -11,7 +11,7 @@ import { QueryParameterProcessor } from '@memberjunction/query-processor';
|
|
|
11
11
|
// and the SS code path silently fails.
|
|
12
12
|
import './providers/sqlserver/SQLServerCodeGenProvider.js';
|
|
13
13
|
import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
|
|
14
|
-
import { CodeNameFromString, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
|
|
14
|
+
import { CodeNameFromString, EntityFieldInfo, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
|
|
15
15
|
import { MJEntityFieldSchema } from "@memberjunction/core-entities";
|
|
16
16
|
import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
|
|
17
17
|
import { SQLUtilityBase } from "./sql.js";
|
|
@@ -23,10 +23,15 @@ import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFi
|
|
|
23
23
|
import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
|
|
24
24
|
import { SQLParser } from "@memberjunction/sql-parser";
|
|
25
25
|
import { createDisplayName, generatePluralName, MJGlobal, ResolveSingleEntityResourceTarget, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
|
|
26
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
26
|
+
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
|
|
27
27
|
import * as fs from 'fs';
|
|
28
28
|
import path from 'path';
|
|
29
|
+
import { canonicalJSONStringify, deepEqualJSON } from "../Misc/util.js";
|
|
29
30
|
import { SQLLogging } from "../Misc/sql_logging.js";
|
|
31
|
+
import { computeFieldMetadataUpdate } from "./field-metadata-lock.js";
|
|
32
|
+
import { DecisionMetadataWriter } from "./decision-metadata-writer.js";
|
|
33
|
+
import { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
|
|
34
|
+
export { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
|
|
30
35
|
export class ValidatorResult {
|
|
31
36
|
constructor() {
|
|
32
37
|
this.entityName = "";
|
|
@@ -161,47 +166,19 @@ export class ManageMetadataBase {
|
|
|
161
166
|
return pool.queryWithParams(this.qsql(sql), params);
|
|
162
167
|
}
|
|
163
168
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* `MAX(Sequence) + 1` for that entity.
|
|
169
|
+
* The `Sequence` expression for every EntityField INSERT CodeGen emits:
|
|
170
|
+
* `(SELECT COALESCE(MAX(Sequence), 0) + 1 FROM EntityField WHERE EntityID = '<id>')`.
|
|
167
171
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const tbl = this.qs(schema, 'EntityField');
|
|
179
|
-
const seqCol = this.qi('Sequence');
|
|
180
|
-
const entityIdCol = this.qi('EntityID');
|
|
181
|
-
const sql = `SELECT
|
|
182
|
-
${this.coalesce(`MAX(${seqCol})`, '0')} AS ${this.qi('MaxSeq')},
|
|
183
|
-
${this.coalesce(`MAX(CASE WHEN ${seqCol} = @Candidate THEN 1 ELSE 0 END)`, '0')} AS ${this.qi('Hit')}
|
|
184
|
-
FROM ${tbl}
|
|
185
|
-
WHERE ${entityIdCol} = @EntityID`;
|
|
186
|
-
try {
|
|
187
|
-
const result = await this.runQueryWithParams(pool, sql, {
|
|
188
|
-
EntityID: entityId,
|
|
189
|
-
Candidate: candidate,
|
|
190
|
-
});
|
|
191
|
-
const row = result.recordset?.[0];
|
|
192
|
-
if (!row)
|
|
193
|
-
return candidate;
|
|
194
|
-
const hit = Number(row.Hit ?? 0) > 0;
|
|
195
|
-
if (!hit)
|
|
196
|
-
return candidate;
|
|
197
|
-
const maxSeq = Number(row.MaxSeq ?? 0);
|
|
198
|
-
return maxSeq + 1;
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
// If the lookup itself fails, fall back to the candidate. The downstream
|
|
202
|
-
// INSERT will surface the real error if there's a true collision.
|
|
203
|
-
return candidate;
|
|
204
|
-
}
|
|
172
|
+
* Evaluated at APPLY time, never a literal. The value is disposable — the run's own
|
|
173
|
+
* spUpdateExistingEntityFieldsFromSchema pass and R__RefreshMetadata renumber every field from
|
|
174
|
+
* the schema — but it must be unique on ANY database in ANY order, because the INSERT is
|
|
175
|
+
* appended verbatim to a migration and Flyway runs every versioned migration before the
|
|
176
|
+
* repeatable renumber. Each INSERT re-evaluates MAX after the one before it, so a batch that
|
|
177
|
+
* executes sequentially in emission order rises in that order (#3670, #4202).
|
|
178
|
+
*/
|
|
179
|
+
applyTimeEntityFieldSequenceSQL(entityID) {
|
|
180
|
+
// COALESCE is valid on both platforms and is the form the docs and the gate describe.
|
|
181
|
+
return `(SELECT COALESCE(MAX(${this.qi('Sequence')}), 0) + 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ${this.qi('EntityID')} = '${entityID}')`;
|
|
205
182
|
}
|
|
206
183
|
/**
|
|
207
184
|
* Wraps a SELECT query with a row limit.
|
|
@@ -276,6 +253,9 @@ export class ManageMetadataBase {
|
|
|
276
253
|
static get newEntityList() {
|
|
277
254
|
return this._newEntityList;
|
|
278
255
|
}
|
|
256
|
+
static set newEntityList(value) {
|
|
257
|
+
this._newEntityList = value;
|
|
258
|
+
}
|
|
279
259
|
static { this._modifiedEntityList = []; }
|
|
280
260
|
/**
|
|
281
261
|
* Globally scoped list of entities that have been modified during the metadata management process.
|
|
@@ -283,6 +263,124 @@ export class ManageMetadataBase {
|
|
|
283
263
|
static get modifiedEntityList() {
|
|
284
264
|
return this._modifiedEntityList;
|
|
285
265
|
}
|
|
266
|
+
static set modifiedEntityList(value) {
|
|
267
|
+
this._modifiedEntityList = value;
|
|
268
|
+
}
|
|
269
|
+
static { this._newFieldSet = new Set(); }
|
|
270
|
+
static { this._changedFields = new Map(); }
|
|
271
|
+
static { this._changedFieldReport = []; }
|
|
272
|
+
static fieldKey(entityID, name) {
|
|
273
|
+
const eid = (entityID ?? '').trim().toLowerCase();
|
|
274
|
+
const fld = (name ?? '').trim().toLowerCase();
|
|
275
|
+
if (!eid || !fld) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
return `${eid}:${fld}`;
|
|
279
|
+
}
|
|
280
|
+
static registerNewField(entityID, name) {
|
|
281
|
+
const key = this.fieldKey(entityID, name);
|
|
282
|
+
if (key) {
|
|
283
|
+
this._newFieldSet.add(key);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
static registerFieldChange(entityID, name, reasons) {
|
|
287
|
+
const key = this.fieldKey(entityID, name);
|
|
288
|
+
if (key) {
|
|
289
|
+
let set = this._changedFields.get(key);
|
|
290
|
+
if (!set) {
|
|
291
|
+
set = new Set();
|
|
292
|
+
this._changedFields.set(key, set);
|
|
293
|
+
}
|
|
294
|
+
for (const r of reasons) {
|
|
295
|
+
set.add(r);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
static isFieldNew(entityID, name) {
|
|
300
|
+
const key = this.fieldKey(entityID, name);
|
|
301
|
+
return key ? this._newFieldSet.has(key) : false;
|
|
302
|
+
}
|
|
303
|
+
static fieldChangeReasons(entityID, name) {
|
|
304
|
+
const key = this.fieldKey(entityID, name);
|
|
305
|
+
const set = key ? this._changedFields.get(key) : undefined;
|
|
306
|
+
return set ?? new Set();
|
|
307
|
+
}
|
|
308
|
+
static isDisplayNameReopened(entityID, name) {
|
|
309
|
+
const reasons = this.fieldChangeReasons(entityID, name);
|
|
310
|
+
for (const r of reasons) {
|
|
311
|
+
if (DISPLAYNAME_REOPEN_REASONS.has(r)) {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
static isTypeReopened(entityID, name) {
|
|
318
|
+
const reasons = this.fieldChangeReasons(entityID, name);
|
|
319
|
+
for (const r of reasons) {
|
|
320
|
+
if (TYPE_REOPEN_REASONS.has(r)) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
static get newFieldCount() {
|
|
327
|
+
return this._newFieldSet.size;
|
|
328
|
+
}
|
|
329
|
+
static get changedFieldCount() {
|
|
330
|
+
return this._changedFields.size;
|
|
331
|
+
}
|
|
332
|
+
static get changedFieldReport() {
|
|
333
|
+
return this._changedFieldReport;
|
|
334
|
+
}
|
|
335
|
+
static clearFieldTracking() {
|
|
336
|
+
this._newFieldSet.clear();
|
|
337
|
+
this._changedFields.clear();
|
|
338
|
+
this._changedFieldReport = [];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Takes a snapshot of EntityField rows from vwEntityFields for diffing.
|
|
342
|
+
* Only queries columns in TRACKED_FIELD_COLUMNS (Sequence is excluded).
|
|
343
|
+
*/
|
|
344
|
+
async snapshotEntityFields(pool, entityIDs) {
|
|
345
|
+
const isScoped = entityIDs !== undefined && entityIDs.length > 0;
|
|
346
|
+
const filter = isScoped
|
|
347
|
+
? `WHERE ${this.qi('EntityID')} IN (${entityIDs.map(id => `'${id}'`).join(',')})`
|
|
348
|
+
: '';
|
|
349
|
+
const cols = TRACKED_FIELD_COLUMNS.map(c => this.qi(c)).join(', ');
|
|
350
|
+
const sSQL = `SELECT ${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Entity')} AS ${this.qi('EntityName')}, ${this.qi('Name')}, ${cols}
|
|
351
|
+
FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
|
|
352
|
+
${filter}`;
|
|
353
|
+
const result = await this.runQuery(pool, sSQL);
|
|
354
|
+
const map = new Map();
|
|
355
|
+
if (result && result.recordset) {
|
|
356
|
+
for (const r of result.recordset) {
|
|
357
|
+
map.set(String(r.ID).toLowerCase(), r);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return map;
|
|
361
|
+
}
|
|
362
|
+
findFieldByName(fields, name) {
|
|
363
|
+
if (!name)
|
|
364
|
+
return undefined;
|
|
365
|
+
const target = name.trim().toLowerCase();
|
|
366
|
+
return fields.find(f => (f.Name ?? '').trim().toLowerCase() === target);
|
|
367
|
+
}
|
|
368
|
+
static { this._deletedEntitySchemaList = []; }
|
|
369
|
+
/**
|
|
370
|
+
* Schemas that lost an entity during this run.
|
|
371
|
+
*
|
|
372
|
+
* Deletion cannot be reported the way new/modified entities are. Those lists carry entity
|
|
373
|
+
* NAMES, which downstream code resolves to a schema by looking the entity up in live
|
|
374
|
+
* metadata — and a deleted entity is, by then, no longer there to look up. So the schema is
|
|
375
|
+
* captured here at deletion time instead.
|
|
376
|
+
*
|
|
377
|
+
* Without this the schema is never marked dirty, its per-schema file is never rebuilt, and
|
|
378
|
+
* the dead class keeps a live `@RegisterClass` registration on disk. The old monolith could
|
|
379
|
+
* not drift this way: it was rewritten in full every run.
|
|
380
|
+
*/
|
|
381
|
+
static get deletedEntitySchemaList() {
|
|
382
|
+
return this._deletedEntitySchemaList;
|
|
383
|
+
}
|
|
286
384
|
static { this._entitiesRequiringViewRegen = []; }
|
|
287
385
|
/**
|
|
288
386
|
* Entities that had late-phase changes requiring their base views to be
|
|
@@ -923,29 +1021,63 @@ export class ManageMetadataBase {
|
|
|
923
1021
|
logStatus(` > Entities config: "${ec.SchemaName}.${ec.BaseTable}" has no attributes to set — skipping`);
|
|
924
1022
|
continue;
|
|
925
1023
|
}
|
|
926
|
-
// Look up the entity by BaseTable + SchemaName
|
|
1024
|
+
// Look up the entity by BaseTable + SchemaName, selecting current values for the named attributes
|
|
1025
|
+
const attrCols = attrs.map(([k]) => `${this.qi(k)}`).join(', ');
|
|
927
1026
|
const entityResult = await this.runQueryWithParams(pool, `
|
|
928
|
-
${this.selectTop(1,
|
|
1027
|
+
${this.selectTop(1, `ID, Name, ${attrCols}`, `FROM ${this.qs(schema, 'vwEntities')}
|
|
929
1028
|
WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName`)}
|
|
930
1029
|
`, { 'BaseTable': ec.BaseTable, 'SchemaName': ec.SchemaName });
|
|
931
1030
|
if (entityResult.recordset.length === 0) {
|
|
932
1031
|
logError(` > Entities config: entity for "${ec.SchemaName}.${ec.BaseTable}" not found — skipping`);
|
|
933
1032
|
continue;
|
|
934
1033
|
}
|
|
935
|
-
const
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const
|
|
1034
|
+
const currentRow = entityResult.recordset[0];
|
|
1035
|
+
const entityId = String(currentRow.ID);
|
|
1036
|
+
const entityName = String(currentRow.Name);
|
|
1037
|
+
// Compare each attribute against the current database value; only include differing attributes
|
|
1038
|
+
const differingAttrs = [];
|
|
940
1039
|
for (const [key, value] of attrs) {
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1040
|
+
const curVal = currentRow[key];
|
|
1041
|
+
let differs = false;
|
|
1042
|
+
let litVal = 'NULL';
|
|
1043
|
+
if (typeof value === 'boolean') {
|
|
1044
|
+
const curBool = curVal === true || curVal === 1 || curVal === '1';
|
|
1045
|
+
differs = curBool !== value;
|
|
1046
|
+
litVal = this.boolLit(value);
|
|
1047
|
+
}
|
|
1048
|
+
else if (typeof value === 'number') {
|
|
1049
|
+
const curNum = curVal === null || curVal === undefined ? null : Number(curVal);
|
|
1050
|
+
differs = curNum !== value;
|
|
1051
|
+
litVal = String(value);
|
|
1052
|
+
}
|
|
1053
|
+
else if (typeof value === 'string') {
|
|
1054
|
+
const curStr = curVal === null || curVal === undefined ? '' : String(curVal);
|
|
1055
|
+
differs = curStr !== value;
|
|
1056
|
+
litVal = `'${value.replace(/'/g, "''")}'`;
|
|
1057
|
+
}
|
|
1058
|
+
else if (value === null) {
|
|
1059
|
+
differs = curVal !== null && curVal !== undefined;
|
|
1060
|
+
litVal = 'NULL';
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
differs = JSON.stringify(curVal) !== JSON.stringify(value);
|
|
1064
|
+
litVal = `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
|
1065
|
+
}
|
|
1066
|
+
if (differs) {
|
|
1067
|
+
differingAttrs.push({ key, literalValue: litVal, summary: `${key}=${value}` });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (differingAttrs.length === 0) {
|
|
1071
|
+
continue;
|
|
946
1072
|
}
|
|
947
|
-
|
|
948
|
-
const
|
|
1073
|
+
// Build an UPDATE with literal values and execute through LogSQLAndExecute for migration capture
|
|
1074
|
+
const setClauses = differingAttrs.map(a => `${this.qi(a.key)} = ${a.literalValue}`);
|
|
1075
|
+
setClauses.unshift(`${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}`);
|
|
1076
|
+
const updateSQL = `UPDATE ${this.qs(schema, 'Entity')}
|
|
1077
|
+
SET ${setClauses.join(', ')}
|
|
1078
|
+
WHERE ${this.qi('ID')} = '${entityId}'`;
|
|
1079
|
+
await this.LogSQLAndExecute(pool, updateSQL, `Update attributes on "${entityName}" from additionalSchemaInfo`);
|
|
1080
|
+
const attrSummary = differingAttrs.map(a => a.summary).join(', ');
|
|
949
1081
|
logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
|
|
950
1082
|
updatedCount++;
|
|
951
1083
|
}
|
|
@@ -1107,9 +1239,10 @@ export class ManageMetadataBase {
|
|
|
1107
1239
|
const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
|
|
1108
1240
|
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
|
|
1109
1241
|
// includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
|
|
1110
|
-
// migration file needs a GO between statements for Flyway/sqlcmd.
|
|
1111
|
-
|
|
1112
|
-
await this.LogSQLAndExecute(pool,
|
|
1242
|
+
// migration file needs a GO between statements for Flyway/sqlcmd. Pass the provider's
|
|
1243
|
+
// separator, not the 'GO' default: on PostgreSQL it is '' and a literal GO breaks replay.
|
|
1244
|
+
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
|
|
1245
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
|
|
1113
1246
|
// 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
|
|
1114
1247
|
const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
|
|
1115
1248
|
if (existing.recordset.length > 0) {
|
|
@@ -1367,8 +1500,8 @@ export class ManageMetadataBase {
|
|
|
1367
1500
|
}
|
|
1368
1501
|
const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
|
|
1369
1502
|
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
|
|
1370
|
-
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
|
|
1371
|
-
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true);
|
|
1503
|
+
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
|
|
1504
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
|
|
1372
1505
|
// 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
|
|
1373
1506
|
const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
|
|
1374
1507
|
let generatedEntityId = existingVE.recordset[0]?.ID;
|
|
@@ -1948,8 +2081,8 @@ export class ManageMetadataBase {
|
|
|
1948
2081
|
* INTEGRITY CHECK — in a well-formed entity every base (non-virtual) field sequences BEFORE the
|
|
1949
2082
|
* virtual/related fields, so the EntityField order matches the base view's `SELECT [base].*, <joins>`
|
|
1950
2083
|
* column output. The positional save-capture in the data providers (e.g. SQLServerDataProvider's
|
|
1951
|
-
* @ResultTable) relies on that alignment. CodeGen
|
|
1952
|
-
* `
|
|
2084
|
+
* @ResultTable) relies on that alignment. CodeGen inserts newly-discovered columns at an apply-time
|
|
2085
|
+
* `MAX(Sequence) + ordinal` placeholder that updateExistingEntityFieldsFromSchema is supposed to renumber; if
|
|
1953
2086
|
* a base column is left sequenced AFTER a virtual field, the save-capture would mis-route values by
|
|
1954
2087
|
* position. The providers now compensate (saves stay correct), but the metadata is still wrong — so we
|
|
1955
2088
|
* scan for it after every metadata pass and log a prominent warning to drive the root cause out over time.
|
|
@@ -1962,7 +2095,7 @@ export class ManageMetadataBase {
|
|
|
1962
2095
|
const sSQL = `SELECT ${this.qi('EntityID')}, ${this.qi('Entity')}, ${this.qi('Name')}, ${this.qi('Sequence')}, ${this.qi('IsVirtual')}
|
|
1963
2096
|
FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
|
|
1964
2097
|
${excl}
|
|
1965
|
-
ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}`;
|
|
2098
|
+
ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('Name')}`;
|
|
1966
2099
|
const result = await this.runQuery(pool, sSQL);
|
|
1967
2100
|
const rows = result.recordset;
|
|
1968
2101
|
const isV = (v) => v === true || v === 1 || v === '1';
|
|
@@ -1995,7 +2128,7 @@ export class ManageMetadataBase {
|
|
|
1995
2128
|
` Healthy entities sequence ALL base (non-virtual) fields before virtual/related fields, so EntityField\n` +
|
|
1996
2129
|
` order matches the base view ([base].* then joins) that the data-provider save-capture relies on. When a\n` +
|
|
1997
2130
|
` base column sequences after a virtual field, the capture re-orders defensively (saves stay correct), but\n` +
|
|
1998
|
-
` the metadata is wrong. Usual cause: a newly-added column's
|
|
2131
|
+
` the metadata is wrong. Usual cause: a newly-added column's apply-time MAX(Sequence)+ordinal placeholder was not\n` +
|
|
1999
2132
|
` renumbered. Fix the field's Sequence at the source so it sorts with the base columns.\n` +
|
|
2000
2133
|
` Affected (entity: base fields out of order):\n`;
|
|
2001
2134
|
for (const o of offenders)
|
|
@@ -2448,11 +2581,8 @@ export class ManageMetadataBase {
|
|
|
2448
2581
|
else {
|
|
2449
2582
|
// this means that we do NOT have a match so the field does not exist in the entity definition, so we need to add it
|
|
2450
2583
|
newEntityFieldUUID = this.createNewUUID();
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
// when nothing exists yet, but a partial prior run could have left rows at the same
|
|
2454
|
-
// ordinal under this EntityID — UQ_EntityField_EntityID_Sequence then fires on PG.
|
|
2455
|
-
const safeSequence = await this.nextAvailableEntityFieldSequence(pool, entity.ID, fieldSequence);
|
|
2584
|
+
// Apply-time Sequence: see applyTimeEntityFieldSequenceSQL. A literal here would be
|
|
2585
|
+
// replayed verbatim from the CodeGen capture and collide on a fresh install.
|
|
2456
2586
|
const q = (n) => this.qi(n);
|
|
2457
2587
|
const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
|
|
2458
2588
|
${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
|
|
@@ -2461,10 +2591,11 @@ export class ManageMetadataBase {
|
|
|
2461
2591
|
${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
|
|
2462
2592
|
VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
|
|
2463
2593
|
${veField.Length}, ${veField.Precision}, ${veField.Scale},
|
|
2464
|
-
${
|
|
2594
|
+
${this.applyTimeEntityFieldSequenceSQL(entity.ID)}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
|
|
2465
2595
|
${this.utcNow()}, ${this.utcNow()}
|
|
2466
2596
|
)`;
|
|
2467
2597
|
await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
|
|
2598
|
+
ManageMetadataBase.registerNewField(entity.ID, veField.FieldName);
|
|
2468
2599
|
didUpdate = true;
|
|
2469
2600
|
}
|
|
2470
2601
|
}
|
|
@@ -2486,14 +2617,16 @@ export class ManageMetadataBase {
|
|
|
2486
2617
|
if (virtualEntities.length === 0) {
|
|
2487
2618
|
return;
|
|
2488
2619
|
}
|
|
2489
|
-
// Pre-build available entities list once (shared across all virtual entity decorations)
|
|
2620
|
+
// Pre-build available entities list once (shared across all virtual entity decorations).
|
|
2621
|
+
// Only single-column keys are offered as FK targets: an MJ foreign key references exactly one
|
|
2622
|
+
// column (RelatedEntityFieldName), so a composite-key entity cannot be the target of a soft FK.
|
|
2490
2623
|
const availableEntities = md.Entities
|
|
2491
|
-
.filter(e => !e.VirtualEntity && e.PrimaryKeys.length
|
|
2624
|
+
.filter(e => !e.VirtualEntity && e.PrimaryKeys.length === 1)
|
|
2492
2625
|
.map(e => ({
|
|
2493
2626
|
Name: e.Name,
|
|
2494
2627
|
SchemaName: e.SchemaName,
|
|
2495
2628
|
BaseTable: e.BaseTable,
|
|
2496
|
-
PrimaryKeyField: e.
|
|
2629
|
+
PrimaryKeyField: e.FirstPrimaryKey.Name // first-pk-ok: filtered to PrimaryKeys.length === 1 above; this is the FK target column offered to the LLM
|
|
2497
2630
|
}));
|
|
2498
2631
|
logStatus(` Decorating virtual entity fields with LLM (${virtualEntities.length} entities)...`);
|
|
2499
2632
|
let decoratedCount = 0;
|
|
@@ -2625,7 +2758,7 @@ export class ManageMetadataBase {
|
|
|
2625
2758
|
// Load VE EntityField rows from DB (we need the ID and auto-update flags)
|
|
2626
2759
|
const schema = mj_core_schema();
|
|
2627
2760
|
const fieldsSQL = `
|
|
2628
|
-
SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, GeneratedFormSection, DisplayName, ExtendedType, CodeType
|
|
2761
|
+
SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, AutoUpdateExtendedType, GeneratedFormSection, DisplayName, ExtendedType, CodeType, ValueListType
|
|
2629
2762
|
FROM ${this.qs(schema, 'EntityField')}
|
|
2630
2763
|
WHERE EntityID = '${entity.ID}'
|
|
2631
2764
|
`;
|
|
@@ -2646,16 +2779,18 @@ export class ManageMetadataBase {
|
|
|
2646
2779
|
if (fieldCategories.length === 0)
|
|
2647
2780
|
return false;
|
|
2648
2781
|
const existingCategories = this.buildExistingCategorySet(dbFields);
|
|
2649
|
-
|
|
2782
|
+
const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
|
|
2783
|
+
await this.applyFieldCategories(pool, entity, dbFields, fieldCategories, existingCategories, { isNewEntity });
|
|
2650
2784
|
// Apply entity icon if provided
|
|
2651
2785
|
if (result.entityIcon) {
|
|
2652
|
-
await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
|
|
2786
|
+
await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
|
|
2653
2787
|
}
|
|
2654
2788
|
// Apply category info settings if provided
|
|
2655
2789
|
if (result.categoryInfo && Object.keys(result.categoryInfo).length > 0) {
|
|
2656
|
-
await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo);
|
|
2790
|
+
await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo, entity.Name);
|
|
2657
2791
|
}
|
|
2658
2792
|
logStatus(` Applied categories for VE ${entity.Name} (${fieldCategories.length} fields)`);
|
|
2793
|
+
await DecisionMetadataWriter.Instance.flushEntity(entity);
|
|
2659
2794
|
return true;
|
|
2660
2795
|
}
|
|
2661
2796
|
/**
|
|
@@ -2753,9 +2888,12 @@ export class ManageMetadataBase {
|
|
|
2753
2888
|
continue;
|
|
2754
2889
|
}
|
|
2755
2890
|
const escapedDescription = fd.description.replace(/'/g, "''");
|
|
2756
|
-
let setClauses = `Description='${escapedDescription}'`;
|
|
2757
|
-
// Apply extended type if provided and
|
|
2758
|
-
|
|
2891
|
+
let setClauses = `Description='${escapedDescription}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)}`;
|
|
2892
|
+
// Apply extended type if provided, valid, and the field is not locked
|
|
2893
|
+
const isFieldNew = ManageMetadataBase.isFieldNew(entity.ID, field.Name);
|
|
2894
|
+
const isTypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, field.Name);
|
|
2895
|
+
const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
|
|
2896
|
+
if (fd.extendedType && field.AutoUpdateExtendedType && (isNewEntity || isFieldNew || isTypeReopened)) {
|
|
2759
2897
|
const validExtendedType = this.validateExtendedType(fd.extendedType);
|
|
2760
2898
|
if (validExtendedType) {
|
|
2761
2899
|
setClauses += `, ExtendedType='${validExtendedType}'`;
|
|
@@ -2772,12 +2910,9 @@ export class ManageMetadataBase {
|
|
|
2772
2910
|
return true;
|
|
2773
2911
|
}
|
|
2774
2912
|
/**
|
|
2775
|
-
* Valid values for EntityField.ExtendedType
|
|
2913
|
+
* Valid values for EntityField.ExtendedType. Domain lives on {@link EntityFieldInfo.ExtendedTypes}.
|
|
2776
2914
|
*/
|
|
2777
|
-
static { this.VALID_EXTENDED_TYPES = new Set(
|
|
2778
|
-
'Code', 'Email', 'FaceTime', 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
|
|
2779
|
-
'GeoCity', 'GeoPostalCode', 'GeoAddress', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
|
|
2780
|
-
]); }
|
|
2915
|
+
static { this.VALID_EXTENDED_TYPES = new Set(EntityFieldInfo.ExtendedTypes); }
|
|
2781
2916
|
static { this.EXTENDED_TYPE_ALIASES = {
|
|
2782
2917
|
'phone': 'Tel',
|
|
2783
2918
|
'telephone': 'Tel',
|
|
@@ -2807,6 +2942,17 @@ export class ManageMetadataBase {
|
|
|
2807
2942
|
'zoom': 'ZoomMtg',
|
|
2808
2943
|
'whatsapp': 'WhatsApp',
|
|
2809
2944
|
'skype': 'Skype',
|
|
2945
|
+
'image': 'Image',
|
|
2946
|
+
'photo': 'Image',
|
|
2947
|
+
'picture': 'Image',
|
|
2948
|
+
'logo': 'Image',
|
|
2949
|
+
'avatar': 'Image',
|
|
2950
|
+
'thumbnail': 'Image',
|
|
2951
|
+
'color': 'Color',
|
|
2952
|
+
'colour': 'Color',
|
|
2953
|
+
'hex': 'Color',
|
|
2954
|
+
'json': 'JSON',
|
|
2955
|
+
'jsonb': 'JSON',
|
|
2810
2956
|
}; }
|
|
2811
2957
|
/**
|
|
2812
2958
|
* Validates an LLM-suggested ExtendedType against the allowed values in EntityField.
|
|
@@ -2914,12 +3060,8 @@ export class ManageMetadataBase {
|
|
|
2914
3060
|
else {
|
|
2915
3061
|
// Create new virtual field record for this parent field
|
|
2916
3062
|
const newFieldID = this.createNewUUID();
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
2919
|
-
// (e.g. partial prior run left a record at the candidate ordinal under this
|
|
2920
|
-
// EntityID — UQ_EntityField_EntityID_Sequence then fires on PG).
|
|
2921
|
-
const candidate = 100000 + parentFields.indexOf(parentField);
|
|
2922
|
-
const sequence = await this.nextAvailableEntityFieldSequence(pool, childEntity.ID, candidate);
|
|
3063
|
+
// Apply-time Sequence (see applyTimeEntityFieldSequenceSQL); reordered by
|
|
3064
|
+
// updateExistingEntityFieldsFromSchema afterwards.
|
|
2923
3065
|
const q = (n) => this.qi(n);
|
|
2924
3066
|
const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
|
|
2925
3067
|
${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
|
|
@@ -2931,9 +3073,10 @@ export class ManageMetadataBase {
|
|
|
2931
3073
|
'${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
|
|
2932
3074
|
'${parentField.Type}', ${this.boolLit(parentField.AllowsNull)},
|
|
2933
3075
|
${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
|
|
2934
|
-
${
|
|
3076
|
+
${this.applyTimeEntityFieldSequenceSQL(childEntity.ID)}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
|
|
2935
3077
|
${this.utcNow()}, ${this.utcNow()})`;
|
|
2936
3078
|
await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
3079
|
+
ManageMetadataBase.registerNewField(childEntity.ID, parentField.Name);
|
|
2937
3080
|
bUpdated = true;
|
|
2938
3081
|
}
|
|
2939
3082
|
}
|
|
@@ -3006,7 +3149,7 @@ export class ManageMetadataBase {
|
|
|
3006
3149
|
RelatedEntityID IS NOT NULL AND
|
|
3007
3150
|
IsVirtual = ${this.boolLit(false)} AND
|
|
3008
3151
|
EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
|
|
3009
|
-
ORDER BY RelatedEntityID`;
|
|
3152
|
+
ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
|
|
3010
3153
|
const entityFieldsResult = await this.runQuery(pool, sSQL);
|
|
3011
3154
|
const entityFields = entityFieldsResult.recordset;
|
|
3012
3155
|
// Get the relationship counts for each entity
|
|
@@ -3184,7 +3327,7 @@ export class ManageMetadataBase {
|
|
|
3184
3327
|
RelatedEntityID IS NOT NULL AND
|
|
3185
3328
|
IsVirtual = ${this.boolLit(false)} AND
|
|
3186
3329
|
EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
|
|
3187
|
-
ORDER BY RelatedEntityID`;
|
|
3330
|
+
ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
|
|
3188
3331
|
const entityFieldsResult = await this.runQuery(pool, sSQL);
|
|
3189
3332
|
const entityFields = entityFieldsResult.recordset;
|
|
3190
3333
|
const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
|
|
@@ -3260,6 +3403,11 @@ export class ManageMetadataBase {
|
|
|
3260
3403
|
const sqlDelete = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spDeleteEntityWithCoreDependencies', [`'${e.ID}'`], ['EntityID'], true);
|
|
3261
3404
|
await this.LogSQLAndExecute(pool, sqlDelete, `SQL text to remove entity ${e.Name}`);
|
|
3262
3405
|
logStatus(` > Removed metadata for table ${e.SchemaName}.${e.BaseTable}`);
|
|
3406
|
+
// Recorded only after the delete succeeds, so a failed removal (logged below for
|
|
3407
|
+
// an admin to handle) does not mark a schema dirty for an entity still present.
|
|
3408
|
+
if (e.SchemaName && e.SchemaName.trim().length > 0) {
|
|
3409
|
+
ManageMetadataBase._deletedEntitySchemaList.push(e.SchemaName.trim());
|
|
3410
|
+
}
|
|
3263
3411
|
// next up we need to remove the spCreate, spDelete, spUpdate, BaseView, and FullTextSearchFunction, if provided.
|
|
3264
3412
|
// We only remoe these artifcacts when they are generated which is info we have in the BaseViewGenerated, spCreateGenerated, etc. fields
|
|
3265
3413
|
await this.checkDropSQLObject(pool, e.BaseViewGenerated, 'view', e.SchemaName, e.BaseView);
|
|
@@ -3409,7 +3557,7 @@ export class ManageMetadataBase {
|
|
|
3409
3557
|
// AN: 14-June-2025 - See note below about the new order of these steps, this must
|
|
3410
3558
|
// happen before we update existing entity fields from schema.
|
|
3411
3559
|
const step2StartTime = new Date();
|
|
3412
|
-
if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs)) {
|
|
3560
|
+
if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs, excludeSchemas)) {
|
|
3413
3561
|
logError('Error creating new entity fields from schema');
|
|
3414
3562
|
bSuccess = false;
|
|
3415
3563
|
}
|
|
@@ -3426,7 +3574,7 @@ export class ManageMetadataBase {
|
|
|
3426
3574
|
}
|
|
3427
3575
|
// AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
|
|
3428
3576
|
// which results in the same pattern of behavior as migrations where we first create new fields
|
|
3429
|
-
//
|
|
3577
|
+
// at an apply-time MAX(Sequence)+ordinal placeholder (above every existing row) and then
|
|
3430
3578
|
// we align them properly in sequential order from 1+ via this method below.
|
|
3431
3579
|
const step3StartTime = new Date();
|
|
3432
3580
|
if (!await this.updateExistingEntityFieldsFromSchema(pool, excludeSchemas, scopedEntityIDs)) {
|
|
@@ -3565,6 +3713,9 @@ export class ManageMetadataBase {
|
|
|
3565
3713
|
try {
|
|
3566
3714
|
logStatus(` Found ${configInfo.additionalSchemaInfo}, applying soft PK/FK configuration...`);
|
|
3567
3715
|
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
3716
|
+
if (!config) {
|
|
3717
|
+
return true;
|
|
3718
|
+
}
|
|
3568
3719
|
let totalPKs = 0;
|
|
3569
3720
|
let totalFKs = 0;
|
|
3570
3721
|
const schema = mj_core_schema();
|
|
@@ -3589,6 +3740,18 @@ export class ManageMetadataBase {
|
|
|
3589
3740
|
const primaryKeys = table.PrimaryKey || [];
|
|
3590
3741
|
if (primaryKeys.length > 0) {
|
|
3591
3742
|
for (const pk of primaryKeys) {
|
|
3743
|
+
const checkSQL = `SELECT ${this.qi('IsPrimaryKey')}, ${this.qi('IsSoftPrimaryKey')}
|
|
3744
|
+
FROM ${this.qs(schema, 'EntityField')}
|
|
3745
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${pk.FieldName}'`;
|
|
3746
|
+
const checkRes = await this.runQuery(pool, checkSQL);
|
|
3747
|
+
if (checkRes.recordset.length > 0) {
|
|
3748
|
+
const row = checkRes.recordset[0];
|
|
3749
|
+
const isPk = row.IsPrimaryKey === true || row.IsPrimaryKey === 1 || row.IsPrimaryKey === '1';
|
|
3750
|
+
const isSoftPk = row.IsSoftPrimaryKey === true || row.IsSoftPrimaryKey === 1 || row.IsSoftPrimaryKey === '1';
|
|
3751
|
+
if (isPk && isSoftPk) {
|
|
3752
|
+
continue; // Already correctly set, skip write
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3592
3755
|
const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
|
|
3593
3756
|
SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
|
|
3594
3757
|
${this.qi('IsPrimaryKey')} = ${this.boolLit(true)},
|
|
@@ -3614,11 +3777,29 @@ export class ManageMetadataBase {
|
|
|
3614
3777
|
continue;
|
|
3615
3778
|
}
|
|
3616
3779
|
const relatedEntityId = relatedEntityResult.recordset[0].ID;
|
|
3780
|
+
const checkSQL = `SELECT ${this.qi('RelatedEntityID')}, ${this.qi('RelatedEntityFieldName')}, ${this.qi('IsSoftForeignKey')}, ${this.qi('AutoUpdateRelatedEntityInfo')}
|
|
3781
|
+
FROM ${this.qs(schema, 'EntityField')}
|
|
3782
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
|
|
3783
|
+
const checkRes = await this.runQuery(pool, checkSQL);
|
|
3784
|
+
if (checkRes.recordset.length > 0) {
|
|
3785
|
+
const row = checkRes.recordset[0];
|
|
3786
|
+
const curRelId = String(row.RelatedEntityID ?? '').trim().toLowerCase();
|
|
3787
|
+
const curRelField = String(row.RelatedEntityFieldName ?? '').trim().toLowerCase();
|
|
3788
|
+
const curSoftFk = row.IsSoftForeignKey === true || row.IsSoftForeignKey === 1 || row.IsSoftForeignKey === '1';
|
|
3789
|
+
const curAutoUpdate = row.AutoUpdateRelatedEntityInfo === true || row.AutoUpdateRelatedEntityInfo === 1 || row.AutoUpdateRelatedEntityInfo === '1';
|
|
3790
|
+
if (curRelId === String(relatedEntityId).trim().toLowerCase() &&
|
|
3791
|
+
curRelField === String(fk.RelatedField).trim().toLowerCase() &&
|
|
3792
|
+
curSoftFk &&
|
|
3793
|
+
!curAutoUpdate) {
|
|
3794
|
+
continue; // Already correctly set, skip write
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3617
3797
|
const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
|
|
3618
3798
|
SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
|
|
3619
3799
|
${this.qi('RelatedEntityID')} = '${relatedEntityId}',
|
|
3620
3800
|
${this.qi('RelatedEntityFieldName')} = '${fk.RelatedField}',
|
|
3621
|
-
${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)}
|
|
3801
|
+
${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)},
|
|
3802
|
+
${this.qi('AutoUpdateRelatedEntityInfo')} = ${this.boolLit(false)}
|
|
3622
3803
|
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
|
|
3623
3804
|
const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
|
|
3624
3805
|
if (result !== null) {
|
|
@@ -3651,7 +3832,7 @@ export class ManageMetadataBase {
|
|
|
3651
3832
|
return true;
|
|
3652
3833
|
try {
|
|
3653
3834
|
// Load all existing EntityFieldValue rows for dedup checking
|
|
3654
|
-
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
|
|
3835
|
+
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
|
|
3655
3836
|
const allEFVResult = await this.runQuery(pool, efvSQL);
|
|
3656
3837
|
const allEntityFieldValues = allEFVResult.recordset;
|
|
3657
3838
|
let totalApplied = 0;
|
|
@@ -3878,7 +4059,8 @@ export class ManageMetadataBase {
|
|
|
3878
4059
|
async dropExistingDefaultConstraint(pool, entity, fieldName) {
|
|
3879
4060
|
try {
|
|
3880
4061
|
const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
|
|
3881
|
-
|
|
4062
|
+
// DECLARE-bearing block: close its batch in the replayable log (see SQLLogging.appendToSQLLogFile).
|
|
4063
|
+
await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`, false, true, this.dbProvider.BatchSeparator);
|
|
3882
4064
|
}
|
|
3883
4065
|
catch (e) {
|
|
3884
4066
|
logError(e);
|
|
@@ -3897,14 +4079,20 @@ export class ManageMetadataBase {
|
|
|
3897
4079
|
if (ag.featureEnabled('EntityDescriptions')) {
|
|
3898
4080
|
// we have the feature enabled, so let's loop through the new entities and generate descriptions for them
|
|
3899
4081
|
for (let e of ManageMetadataBase.newEntityList) {
|
|
3900
|
-
|
|
4082
|
+
// Every literal in this function is doubled-quote escaped. The description below is
|
|
4083
|
+
// FREE TEXT FROM AN LLM — prose about business entities contains apostrophes as a
|
|
4084
|
+
// matter of course, and one unescaped quote aborts the whole CodeGen run with
|
|
4085
|
+
// "Unclosed quotation mark" (SQL Server) at the very end of an otherwise-complete
|
|
4086
|
+
// pass. '' doubling is correct on both supported dialects.
|
|
4087
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
4088
|
+
const dataResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE Name = '${esc(e)}'`);
|
|
3901
4089
|
const data = dataResult.recordset;
|
|
3902
4090
|
const fieldsResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} WHERE EntityID='${data[0].ID}'`);
|
|
3903
4091
|
const fields = fieldsResult.recordset;
|
|
3904
4092
|
// Use new API to generate entity description
|
|
3905
4093
|
const result = await ag.generateEntityDescription(e, data[0].BaseTable, fields.map((f) => ({ Name: f.Name, Type: f.Type, IsNullable: f.AllowsNull, Description: f.Description })), currentUser);
|
|
3906
4094
|
if (result?.entityDescription && result.entityDescription.length > 0) {
|
|
3907
|
-
const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET Description = '${result.entityDescription}' WHERE Name = '${e}'`;
|
|
4095
|
+
const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi('Description')} = '${esc(result.entityDescription)}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)} WHERE ${this.qi('Name')} = '${esc(e)}'`;
|
|
3908
4096
|
await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
|
|
3909
4097
|
}
|
|
3910
4098
|
else {
|
|
@@ -3988,9 +4176,9 @@ export class ManageMetadataBase {
|
|
|
3988
4176
|
*
|
|
3989
4177
|
* @returns {string} - The SQL statement to retrieve pending entity fields.
|
|
3990
4178
|
*/
|
|
3991
|
-
getPendingEntityFieldsSELECTSQL(entityIDs) {
|
|
4179
|
+
getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas) {
|
|
3992
4180
|
const schema = mj_core_schema();
|
|
3993
|
-
return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs);
|
|
4181
|
+
return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs, excludeSchemas);
|
|
3994
4182
|
}
|
|
3995
4183
|
/**
|
|
3996
4184
|
* This method builds a SQL Statement that will insert a row into the EntityField table with information about a new field.
|
|
@@ -4003,7 +4191,11 @@ export class ManageMetadataBase {
|
|
|
4003
4191
|
const isPrimaryKey = n.FieldName?.trim().toLowerCase() === 'id';
|
|
4004
4192
|
const isForeignKey = n.RelatedEntityID && n.RelatedEntityID.length > 0; // Foreign keys have RelatedEntityID set
|
|
4005
4193
|
const isNameField = n.FieldName?.trim().toLowerCase() === 'name' || n.IsNameField;
|
|
4006
|
-
|
|
4194
|
+
// The field's position in the schema (SourceOrdinal / column_id). `n.Sequence` is NOT that:
|
|
4195
|
+
// the pending-fields SELECT computes it as MAX+100000+ordinal, so comparing it against
|
|
4196
|
+
// IncludeFirstNFieldsAsDefaultInView could never be true and the setting was dead.
|
|
4197
|
+
const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
|
|
4198
|
+
const isEarlySequence = sourceOrdinal <= configInfo.newEntityDefaults?.IncludeFirstNFieldsAsDefaultInView;
|
|
4007
4199
|
const bDefaultInView = (isNameField || isEarlySequence) && !isPrimaryKey && !isForeignKey;
|
|
4008
4200
|
const escapedDescription = n.Description ? `'${n.Description.replace(/'/g, "''")}'` : 'NULL';
|
|
4009
4201
|
let fieldDisplayName = '';
|
|
@@ -4030,17 +4222,10 @@ export class ManageMetadataBase {
|
|
|
4030
4222
|
: (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
|
|
4031
4223
|
const conflictCheck = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
|
|
4032
4224
|
const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
|
|
4033
|
-
// Sequence is
|
|
4034
|
-
//
|
|
4035
|
-
//
|
|
4036
|
-
|
|
4037
|
-
// batch, manageEntityFields calls spUpdateExistingEntityFieldsFromSchema
|
|
4038
|
-
// which rewrites EVERY field on the entity from the live view — including
|
|
4039
|
-
// parked rows. That proc must run AFTER views are current (CodeGen Pass 2,
|
|
4040
|
-
// after SQL generation). Pass 1 still emits this SQL against whatever the
|
|
4041
|
-
// view is at that moment; Pass 2 is the one that matches the finished BaseView.
|
|
4042
|
-
const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
|
|
4043
|
-
const sequenceExpr = String(sourceOrdinal);
|
|
4225
|
+
// Sequence is an apply-time expression, never a literal: see applyTimeEntityFieldSequenceSQL.
|
|
4226
|
+
// `n.Sequence` (MAX+100000+ordinal from the pending SELECT) is only that query's ORDER BY key
|
|
4227
|
+
// and must never be written; `sourceOrdinal` above feeds DefaultInView only.
|
|
4228
|
+
const sequenceExpr = this.applyTimeEntityFieldSequenceSQL(n.EntityID);
|
|
4044
4229
|
return `
|
|
4045
4230
|
${guard.prefix}
|
|
4046
4231
|
INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')}
|
|
@@ -4094,7 +4279,7 @@ export class ManageMetadataBase {
|
|
|
4094
4279
|
${n.RelatedEntityID && n.RelatedEntityID.length > 0 ? `'${n.RelatedEntityID}'` : 'NULL'},
|
|
4095
4280
|
${n.RelatedEntityFieldName && n.RelatedEntityFieldName.length > 0 ? `'${n.RelatedEntityFieldName}'` : 'NULL'},
|
|
4096
4281
|
${this.boolLit(n.IsNameField !== null ? n.IsNameField : false)},
|
|
4097
|
-
${this.boolLit(n.
|
|
4282
|
+
${this.boolLit(Boolean(n.IsNameField))},
|
|
4098
4283
|
${this.boolLit(n.RelatedEntityID && n.RelatedEntityID.length > 0)},
|
|
4099
4284
|
${this.boolLit(bDefaultInView)},
|
|
4100
4285
|
${this.boolLit(n.IsPrimaryKey)},
|
|
@@ -4114,31 +4299,20 @@ export class ManageMetadataBase {
|
|
|
4114
4299
|
* @param sqlDefaultValue
|
|
4115
4300
|
* @returns
|
|
4116
4301
|
*/
|
|
4117
|
-
/**
|
|
4118
|
-
* Park existing EntityField.Sequence values out of the 1..N catalog range so a
|
|
4119
|
-
* following INSERT can use the real BaseView column_id without colliding on
|
|
4120
|
-
* UQ_EntityField_EntityID_Sequence. The +100000 band is unique-safe; the
|
|
4121
|
-
* subsequent spUpdateExistingEntityFieldsFromSchema rewrite brings every row
|
|
4122
|
-
* (parked and new) back to live catalog order. Skip rows already parked so a
|
|
4123
|
-
* second pass in the same run does not add 100000 twice.
|
|
4124
|
-
*/
|
|
4125
|
-
parkEntityFieldSequencesSQL(entityID) {
|
|
4126
|
-
return `UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
|
|
4127
|
-
SET ${this.qi('Sequence')} = ${this.qi('Sequence')} + 100000
|
|
4128
|
-
WHERE ${this.qi('EntityID')} = '${entityID}'
|
|
4129
|
-
AND ${this.qi('Sequence')} < 100000;`;
|
|
4130
|
-
}
|
|
4131
4302
|
parseDefaultValue(sqlDefaultValue) {
|
|
4132
4303
|
if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
|
|
4133
4304
|
return null;
|
|
4134
4305
|
}
|
|
4135
4306
|
return this.dbProvider.parseColumnDefaultValue(sqlDefaultValue) ?? null;
|
|
4136
4307
|
}
|
|
4137
|
-
async createNewEntityFieldsFromSchema(pool, entityIDs) {
|
|
4308
|
+
async createNewEntityFieldsFromSchema(pool, entityIDs, excludeSchemas) {
|
|
4138
4309
|
try {
|
|
4139
4310
|
// entityIDs flows down to the provider's WHERE clause so the inline SELECT
|
|
4140
|
-
// narrows to changed entities only when scoped.
|
|
4141
|
-
|
|
4311
|
+
// narrows to changed entities only when scoped. excludeSchemas is the compiled
|
|
4312
|
+
// includeSchemas scope (out-of-allow-list schemas). Without it, Pass 1 inserts
|
|
4313
|
+
// pending fields for every schema in the database (Forms CodeGen emitted Common
|
|
4314
|
+
// Activity Files EntityField rows).
|
|
4315
|
+
const sSQL = this.getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas);
|
|
4142
4316
|
const newEntityFieldsResult = await this.runQuery(pool, sSQL);
|
|
4143
4317
|
const newEntityFields = newEntityFieldsResult.recordset;
|
|
4144
4318
|
if (newEntityFields.length > 0) {
|
|
@@ -4156,18 +4330,14 @@ export class ManageMetadataBase {
|
|
|
4156
4330
|
// Batch size is configurable via `metadataInsertBatchSize` (default 250).
|
|
4157
4331
|
const CHUNK_SIZE = configInfo.metadataInsertBatchSize ?? 250;
|
|
4158
4332
|
const inserts = [];
|
|
4159
|
-
const parkedEntityIDs = new Set();
|
|
4160
4333
|
for (let i = 0; i < newEntityFields.length; ++i) {
|
|
4161
4334
|
const n = newEntityFields[i];
|
|
4162
4335
|
if (n.EntityID !== null && n.EntityID !== undefined && n.EntityID.length > 0) {
|
|
4163
4336
|
// need to check for null entity id = that is because the above query can return candidate Entity Fields but the entities may not have been created if the entities
|
|
4164
4337
|
// that would have been created violate rules - such as not having an ID column, etc.
|
|
4165
|
-
if (!parkedEntityIDs.has(n.EntityID)) {
|
|
4166
|
-
inserts.push(this.parkEntityFieldSequencesSQL(n.EntityID));
|
|
4167
|
-
parkedEntityIDs.add(n.EntityID);
|
|
4168
|
-
}
|
|
4169
4338
|
const newEntityFieldUUID = this.createNewUUID();
|
|
4170
4339
|
inserts.push(this.getPendingEntityFieldINSERTSQL(newEntityFieldUUID, n));
|
|
4340
|
+
ManageMetadataBase.registerNewField(n.EntityID, n.FieldName);
|
|
4171
4341
|
}
|
|
4172
4342
|
}
|
|
4173
4343
|
for (let i = 0; i < inserts.length; i += CHUNK_SIZE) {
|
|
@@ -4263,11 +4433,20 @@ export class ManageMetadataBase {
|
|
|
4263
4433
|
const label = isScoped
|
|
4264
4434
|
? `SQL text to update existing entity fields from schema (${entityIDs.length} scoped entities)`
|
|
4265
4435
|
: `SQL text to update existing entity fields from schema`;
|
|
4266
|
-
const
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4436
|
+
const before = await this.snapshotEntityFields(pool, entityIDs);
|
|
4437
|
+
await this.LogSQLAndExecute(pool, sSQL, label, true);
|
|
4438
|
+
const after = await this.snapshotEntityFields(pool, entityIDs);
|
|
4439
|
+
const changes = diffEntityFieldSnapshots(before, after, (e, n) => ManageMetadataBase.isFieldNew(e, n));
|
|
4440
|
+
if (changes.length > 0) {
|
|
4441
|
+
ManageMetadataBase.addNewEntitiesToModifiedList(changes.map(c => c.entityName));
|
|
4442
|
+
for (const c of changes) {
|
|
4443
|
+
ManageMetadataBase.registerFieldChange(c.entityID, c.fieldName, c.reasons);
|
|
4444
|
+
ManageMetadataBase._changedFieldReport.push({
|
|
4445
|
+
entityName: c.entityName,
|
|
4446
|
+
fieldName: c.fieldName,
|
|
4447
|
+
reasons: [...c.reasons],
|
|
4448
|
+
});
|
|
4449
|
+
}
|
|
4271
4450
|
}
|
|
4272
4451
|
return true;
|
|
4273
4452
|
}
|
|
@@ -4420,13 +4599,22 @@ export class ManageMetadataBase {
|
|
|
4420
4599
|
const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
|
|
4421
4600
|
const resultResult = await this.runQuery(pool, sSQL);
|
|
4422
4601
|
const result = resultResult.recordset;
|
|
4423
|
-
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
|
|
4602
|
+
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
|
|
4424
4603
|
const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
|
|
4425
4604
|
const allEntityFieldValues = allEntityFieldValuesResult.recordset;
|
|
4426
|
-
const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence`;
|
|
4605
|
+
const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence, Name, ID`;
|
|
4427
4606
|
const allEntityFieldsResult = await this.runQuery(pool, efSQL);
|
|
4428
4607
|
const allEntityFields = allEntityFieldsResult.recordset;
|
|
4429
4608
|
const generationPromises = [];
|
|
4609
|
+
const ag = new AdvancedGeneration();
|
|
4610
|
+
// `skipDBUpdate` means load-only: `runValidationGeneration` is called below with
|
|
4611
|
+
// `generateNewCode = false`, and `generateValidatorFunctionFromCheckConstraint` only reaches an
|
|
4612
|
+
// LLM when that flag is true. Reading a validator back out of an Approved `GeneratedCode` record
|
|
4613
|
+
// is therefore a plain database read — gating it on the AI feature flag is what made
|
|
4614
|
+
// `mj codegen --no-ai` DELETE every committed `Validate()` override rather than preserve it, and
|
|
4615
|
+
// the `codegen-drift` gate (which runs `--no-ai`) then demanded that lossy output. Generation
|
|
4616
|
+
// stays gated; only the read is unconditional.
|
|
4617
|
+
const emitValidators = skipDBUpdate || ag.featureEnabled('ParseCheckConstraints');
|
|
4430
4618
|
const columnLevelResults = result.filter((r) => r.EntityFieldID); // get the column level constraints
|
|
4431
4619
|
const tableLevelResults = result.filter((r) => !r.EntityFieldID); // get the table level constraints
|
|
4432
4620
|
for (const r of columnLevelResults) {
|
|
@@ -4460,9 +4648,8 @@ export class ManageMetadataBase {
|
|
|
4460
4648
|
else {
|
|
4461
4649
|
// if we get here that means we don't have a simple condition in the check constraint that the RegEx could parse. If Advanced Generation is enabled, we will
|
|
4462
4650
|
// attempt to use an LLM to do things fancier now
|
|
4463
|
-
if (
|
|
4464
|
-
|
|
4465
|
-
// the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
|
|
4651
|
+
if (emitValidators) {
|
|
4652
|
+
// either we are loading persisted validators, or the feature is on and we may generate new ones
|
|
4466
4653
|
// run this in parallel
|
|
4467
4654
|
generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
|
|
4468
4655
|
}
|
|
@@ -4471,9 +4658,8 @@ export class ManageMetadataBase {
|
|
|
4471
4658
|
}
|
|
4472
4659
|
// now for the table level constraints run the process for advanced generation
|
|
4473
4660
|
for (const r of tableLevelResults) {
|
|
4474
|
-
if (
|
|
4475
|
-
|
|
4476
|
-
// the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
|
|
4661
|
+
if (emitValidators) {
|
|
4662
|
+
// either we are loading persisted validators, or the feature is on and we may generate new ones
|
|
4477
4663
|
// run this in parallel
|
|
4478
4664
|
generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
|
|
4479
4665
|
}
|
|
@@ -4544,9 +4730,9 @@ export class ManageMetadataBase {
|
|
|
4544
4730
|
}
|
|
4545
4731
|
}
|
|
4546
4732
|
try {
|
|
4547
|
-
|
|
4733
|
+
const ag = new AdvancedGeneration();
|
|
4734
|
+
if (generateNewCode && ag.featureEnabled('ParseCheckConstraints')) {
|
|
4548
4735
|
// feature is enabled, so let's call the AI to generate a function for us
|
|
4549
|
-
const ag = new AdvancedGeneration();
|
|
4550
4736
|
const entityFieldListInfo = allEntityFields.filter(item => item.Entity.trim().toLowerCase() === data.EntityName.trim().toLowerCase()).map(item => ` * ${item.Name} - ${item.Type}${item.AllowsNull ? ' (nullable)' : ' (not null)'}`).join('\n');
|
|
4551
4737
|
// Use new API to parse check constraint
|
|
4552
4738
|
const result = await ag.parseCheckConstraint(constraintDefinition, entityFieldListInfo, generatedValidationFunctionName, currentUser);
|
|
@@ -4998,15 +5184,12 @@ export class ManageMetadataBase {
|
|
|
4998
5184
|
// process a single new entity
|
|
4999
5185
|
let newEntityName = await this.createNewEntityName(newEntity, currentUser);
|
|
5000
5186
|
const newEntityDisplayName = this.createNewEntityDisplayName(newEntity, newEntityName);
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
if (existingEntity || existingEntityInNewEntityList) {
|
|
5005
|
-
// the generated name is already in place, so we need another name
|
|
5006
|
-
suffix = '__' + newEntity.SchemaName;
|
|
5007
|
-
newEntityName = newEntityName + suffix;
|
|
5008
|
-
LogError(` >>>> WARNING: Entity name already exists, so using ${newEntityName} instead. If you did not intend for this, please rename the ${newEntity.SchemaName}.${newEntity.TableName} table in the database.`);
|
|
5187
|
+
const { name: uniqueName, suffix: uniqueSuffix } = this.resolveUniqueEntityName(newEntityName, newEntity.SchemaName, md.Entities.map(e => e.Name));
|
|
5188
|
+
if (uniqueName !== newEntityName) {
|
|
5189
|
+
LogError(` >>>> WARNING: Entity name already exists, so using ${uniqueName} instead. If you did not intend for this, please rename the ${newEntity.SchemaName}.${newEntity.TableName} table in the database.`);
|
|
5009
5190
|
}
|
|
5191
|
+
newEntityName = uniqueName;
|
|
5192
|
+
const suffix = uniqueSuffix;
|
|
5010
5193
|
const isNewSchema = await this.isSchemaNew(pool, newEntity.SchemaName);
|
|
5011
5194
|
const newEntityID = this.createNewUUID();
|
|
5012
5195
|
const sSQLInsert = this.createNewEntityInsertSQL(newEntityID, newEntityName, newEntity, suffix, newEntityDisplayName);
|
|
@@ -5562,6 +5745,49 @@ export class ManageMetadataBase {
|
|
|
5562
5745
|
}
|
|
5563
5746
|
return revoked;
|
|
5564
5747
|
}
|
|
5748
|
+
/**
|
|
5749
|
+
* Pick an entity name that is actually free, disambiguating with the schema name and, if
|
|
5750
|
+
* that is still taken, a counter.
|
|
5751
|
+
*
|
|
5752
|
+
* Entity names are generated from the table name with trailing discriminators stripped, so
|
|
5753
|
+
* distinct tables routinely generate the SAME name — NetSuite's customlist72, customlist74,
|
|
5754
|
+
* customlist160, customlist436, customlist534 and customlist873 all generate "Custom Lists".
|
|
5755
|
+
*
|
|
5756
|
+
* The previous logic appended the schema suffix ONCE and assumed the result was unique. It
|
|
5757
|
+
* is not: the first table took "Custom Lists", and every table after it appended the same
|
|
5758
|
+
* "__netsuite" and produced the identical "Custom Lists__netsuite". The second was a
|
|
5759
|
+
* duplicate-key failure on UQ_Entity_Name and the rest were never created — a silent loss of
|
|
5760
|
+
* 8 entities on one NetSuite tenant, reported only as repeated identical INSERT errors.
|
|
5761
|
+
*
|
|
5762
|
+
* Comparison is case-INSENSITIVE on both sides, matching UQ_Entity_Name's collation. The old
|
|
5763
|
+
* in-run check used an exact `===` while the metadata check beside it lowercased, so two
|
|
5764
|
+
* names differing only in case read as free and then collided on INSERT.
|
|
5765
|
+
*
|
|
5766
|
+
* @param desiredName the generated name to place
|
|
5767
|
+
* @param schemaName schema, used for the first disambiguation step
|
|
5768
|
+
* @param takenNames every name already spoken for — existing metadata AND names claimed
|
|
5769
|
+
* earlier in this run (which are not in metadata yet)
|
|
5770
|
+
* @returns the free name and the suffix used to reach it ('' when the name was already free)
|
|
5771
|
+
*/
|
|
5772
|
+
resolveUniqueEntityName(desiredName, schemaName, takenNames) {
|
|
5773
|
+
const taken = new Set([...takenNames, ...ManageMetadataBase.newEntityList].map(n => n.toLowerCase()));
|
|
5774
|
+
const isTaken = (candidate) => taken.has(candidate.toLowerCase());
|
|
5775
|
+
if (!isTaken(desiredName)) {
|
|
5776
|
+
return { name: desiredName, suffix: '' };
|
|
5777
|
+
}
|
|
5778
|
+
let suffix = '__' + schemaName;
|
|
5779
|
+
let name = desiredName + suffix;
|
|
5780
|
+
// Bounded: thousands of tables collapsing to one name is a naming problem no suffix can
|
|
5781
|
+
// fix, and looping forever would hide it. Let the INSERT fail loudly instead.
|
|
5782
|
+
const maxAttempts = 1000;
|
|
5783
|
+
let attempt = 2;
|
|
5784
|
+
while (isTaken(name) && attempt <= maxAttempts) {
|
|
5785
|
+
suffix = `__${schemaName}_${attempt}`;
|
|
5786
|
+
name = desiredName + suffix;
|
|
5787
|
+
attempt++;
|
|
5788
|
+
}
|
|
5789
|
+
return { name, suffix };
|
|
5790
|
+
}
|
|
5565
5791
|
createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
|
|
5566
5792
|
const newEntityDefaults = configInfo.newEntityDefaults;
|
|
5567
5793
|
const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;
|
|
@@ -5694,7 +5920,8 @@ export class ManageMetadataBase {
|
|
|
5694
5920
|
WHERE
|
|
5695
5921
|
${whereClause}
|
|
5696
5922
|
ORDER BY
|
|
5697
|
-
e.Name
|
|
5923
|
+
e.Name,
|
|
5924
|
+
e.ID
|
|
5698
5925
|
`;
|
|
5699
5926
|
const entitiesResult = await this.runQuery(pool, entitiesSQL);
|
|
5700
5927
|
const entities = entitiesResult.recordset;
|
|
@@ -5720,11 +5947,16 @@ export class ManageMetadataBase {
|
|
|
5720
5947
|
ef.AutoUpdateIncludeInUserSearchAPI,
|
|
5721
5948
|
ef.AutoUpdateCategory,
|
|
5722
5949
|
ef.AutoUpdateDisplayName,
|
|
5950
|
+
ef.AutoUpdateExtendedType,
|
|
5951
|
+
ef.ExtendedType,
|
|
5952
|
+
ef.CodeType,
|
|
5953
|
+
ef.GeneratedFormSection,
|
|
5723
5954
|
ef.EntityIDFieldName,
|
|
5724
5955
|
ef.RelatedEntity,
|
|
5725
5956
|
ef.IsVirtual,
|
|
5726
5957
|
ef.AllowUpdateAPI,
|
|
5727
5958
|
ef.IsNameField,
|
|
5959
|
+
ef.ValueListType,
|
|
5728
5960
|
ef.DefaultInView,
|
|
5729
5961
|
ef.IncludeInUserSearchAPI,
|
|
5730
5962
|
ef.UserSearchPredicateAPI,
|
|
@@ -5738,7 +5970,9 @@ export class ManageMetadataBase {
|
|
|
5738
5970
|
ef.EntityID IN (${entityIds})
|
|
5739
5971
|
ORDER BY
|
|
5740
5972
|
ef.EntityID,
|
|
5741
|
-
ef.Sequence
|
|
5973
|
+
ef.Sequence,
|
|
5974
|
+
ef.Name,
|
|
5975
|
+
ef.ID
|
|
5742
5976
|
`;
|
|
5743
5977
|
const fieldsResult = await this.runQuery(pool, fieldsSQL);
|
|
5744
5978
|
const allFields = fieldsResult.recordset;
|
|
@@ -5753,6 +5987,9 @@ export class ManageMetadataBase {
|
|
|
5753
5987
|
WHERE
|
|
5754
5988
|
es.EntityID IN (${entityIds})
|
|
5755
5989
|
AND es.Name = 'FieldCategoryInfo'
|
|
5990
|
+
ORDER BY
|
|
5991
|
+
es.EntityID,
|
|
5992
|
+
es.Name
|
|
5756
5993
|
`;
|
|
5757
5994
|
const settingsResult = await this.runQuery(pool, settingsSQL);
|
|
5758
5995
|
const allSettings = settingsResult.recordset;
|
|
@@ -5850,16 +6087,26 @@ export class ManageMetadataBase {
|
|
|
5850
6087
|
// Fields for this entity — O(1) lookup into the pre-grouped map (keyed on the normalized
|
|
5851
6088
|
// EntityID) instead of a linear scan of the full pooled array.
|
|
5852
6089
|
const fields = fieldsByEntity.get(String(entity.ID ?? '').trim().toLowerCase()) ?? [];
|
|
6090
|
+
// Decorate fields with change state from field tracking (C1/C3)
|
|
6091
|
+
for (const f of fields) {
|
|
6092
|
+
f.IsNew = ManageMetadataBase.isFieldNew(entity.ID, f.Name);
|
|
6093
|
+
f.DescriptionReopened = ManageMetadataBase.isDisplayNameReopened(entity.ID, f.Name);
|
|
6094
|
+
f.TypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, f.Name);
|
|
6095
|
+
}
|
|
6096
|
+
const newFieldNames = new Set(fields.filter((f) => f.IsNew).map((f) => String(f.Name)));
|
|
6097
|
+
const typeReopenedNames = new Set(fields.filter((f) => f.TypeReopened).map((f) => String(f.Name)));
|
|
6098
|
+
const hasNewFields = newFieldNames.size > 0;
|
|
6099
|
+
const hasTypeReopened = typeReopenedNames.size > 0;
|
|
6100
|
+
const hasDescriptionReopened = fields.some((f) => f.DescriptionReopened);
|
|
5853
6101
|
// Determine if this is a new entity (for DefaultForNewUser decision)
|
|
5854
6102
|
const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
|
|
5855
|
-
// Smart Field Identification
|
|
5856
|
-
|
|
5857
|
-
// or if entity-level search/FTS auto-update flags are set
|
|
5858
|
-
const needsFieldAnalysis = fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
|
|
6103
|
+
// Smart Field Identification — FM4. Runs only when there is something new to decide about.
|
|
6104
|
+
const needsFieldAnalysis = (isNewEntity || hasNewFields || hasTypeReopened) && fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
|
|
5859
6105
|
// entity-level auto-update flags come from the raw SQL query, not EntityInfo
|
|
5860
6106
|
const entityRecord = entity;
|
|
5861
|
-
const needsEntitySearchConfig = entityRecord.AutoUpdateAllowUserSearchAPI || entityRecord.AutoUpdateFullTextSearch;
|
|
6107
|
+
const needsEntitySearchConfig = isNewEntity && (Boolean(entityRecord.AutoUpdateAllowUserSearchAPI) || Boolean(entityRecord.AutoUpdateFullTextSearch));
|
|
5862
6108
|
if (needsFieldAnalysis || needsEntitySearchConfig) {
|
|
6109
|
+
CodeGenReporter.Instance.counter('ai.smartFieldCalls');
|
|
5863
6110
|
const fieldAnalysis = await ag.identifyFields({
|
|
5864
6111
|
Name: entity.Name,
|
|
5865
6112
|
Description: entity.Description,
|
|
@@ -5874,13 +6121,17 @@ export class ManageMetadataBase {
|
|
|
5874
6121
|
AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
|
|
5875
6122
|
FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
|
|
5876
6123
|
AutoUpdateFullTextSearch: entityRecord.AutoUpdateFullTextSearch,
|
|
5877
|
-
}, fields, fieldAnalysis);
|
|
6124
|
+
}, fields, fieldAnalysis, { isNewEntity, newFieldNames, typeReopenedNames });
|
|
5878
6125
|
}
|
|
5879
6126
|
}
|
|
5880
6127
|
// Form Layout Generation
|
|
5881
|
-
//
|
|
5882
|
-
|
|
6128
|
+
// Fires only on change events (new entity, new fields, reopened description/type) AND when there is work to do
|
|
6129
|
+
// (blank categories with AutoUpdateCategory=1, or reopened fields to review). Gated on events so omitted blank fields don't loop (§3.4).
|
|
6130
|
+
const hasCategoryNeeded = fields.some((f) => Boolean(f.AutoUpdateCategory) && (!f.Category || String(f.Category).trim() === ''));
|
|
6131
|
+
const needsCategoryGeneration = (isNewEntity || hasNewFields || hasDescriptionReopened || hasTypeReopened)
|
|
6132
|
+
&& (hasCategoryNeeded || hasDescriptionReopened || hasTypeReopened);
|
|
5883
6133
|
if (needsCategoryGeneration) {
|
|
6134
|
+
CodeGenReporter.Instance.counter('ai.formLayoutCalls');
|
|
5884
6135
|
// Build IS-A parent chain context if this entity has a parent
|
|
5885
6136
|
const parentChainContext = this.buildParentChainContext(entity, fields);
|
|
5886
6137
|
const layoutAnalysis = await ag.generateFormLayout({
|
|
@@ -5896,6 +6147,8 @@ export class ManageMetadataBase {
|
|
|
5896
6147
|
logStatus(` Applied form layout for ${entity.Name}`);
|
|
5897
6148
|
}
|
|
5898
6149
|
}
|
|
6150
|
+
// Flush decision metadata for this entity
|
|
6151
|
+
await DecisionMetadataWriter.Instance.flushEntity(entity);
|
|
5899
6152
|
}
|
|
5900
6153
|
catch (ex) {
|
|
5901
6154
|
logError('Error Processing Entity Advanced Generation', ex);
|
|
@@ -5965,19 +6218,21 @@ export class ManageMetadataBase {
|
|
|
5965
6218
|
/**
|
|
5966
6219
|
* Apply smart field identification results to entity fields and entity-level search configuration
|
|
5967
6220
|
*/
|
|
5968
|
-
async applySmartFieldIdentification(pool, entity, fields, result) {
|
|
6221
|
+
async applySmartFieldIdentification(pool, entity, fields, result, ctx) {
|
|
5969
6222
|
// Run the guardrail pipeline once up front so every downstream applier
|
|
5970
6223
|
// sees the same already-normalized result. See search-guardrails.ts for
|
|
5971
6224
|
// the rules. Mutates result in place — see normalizeSearchFlagsInPlace.
|
|
5972
6225
|
this.normalizeSearchFlagsInPlace(entity, fields, result);
|
|
5973
6226
|
const sqlStatements = [];
|
|
5974
|
-
this.applyNameFieldUpdates(sqlStatements, fields, result);
|
|
5975
|
-
this.applyDefaultInViewUpdates(sqlStatements, fields, result);
|
|
5976
|
-
this.applySearchableFieldUpdates(sqlStatements, fields, result, entity);
|
|
5977
|
-
this.applySearchPredicateUpdates(sqlStatements, fields, result);
|
|
5978
|
-
|
|
6227
|
+
this.applyNameFieldUpdates(sqlStatements, fields, result, entity);
|
|
6228
|
+
this.applyDefaultInViewUpdates(sqlStatements, fields, result, entity, ctx);
|
|
6229
|
+
this.applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx);
|
|
6230
|
+
this.applySearchPredicateUpdates(sqlStatements, fields, result, entity, ctx);
|
|
6231
|
+
if (!ctx || ctx.isNewEntity) {
|
|
6232
|
+
this.applyEntitySearchConfig(sqlStatements, entity, result);
|
|
6233
|
+
}
|
|
5979
6234
|
if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
|
|
5980
|
-
this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result);
|
|
6235
|
+
this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx);
|
|
5981
6236
|
}
|
|
5982
6237
|
// Execute all updates in one batch
|
|
5983
6238
|
if (sqlStatements.length > 0) {
|
|
@@ -6116,11 +6371,11 @@ export class ManageMetadataBase {
|
|
|
6116
6371
|
* `AutoUpdateIsNameField=0` are never touched in either direction — the user owns
|
|
6117
6372
|
* them, and `EntityInfo.NameField`'s literal-`Name` preference arbitrates at runtime.
|
|
6118
6373
|
*/
|
|
6119
|
-
applyNameFieldUpdates(sqlStatements, fields, result) {
|
|
6374
|
+
applyNameFieldUpdates(sqlStatements, fields, result, entity) {
|
|
6120
6375
|
const nameFieldNames = result.nameFields ?? [];
|
|
6121
6376
|
// Surface LLM candidates that don't exist on the entity (same diagnostics as before).
|
|
6122
6377
|
for (const nfName of nameFieldNames) {
|
|
6123
|
-
if (!
|
|
6378
|
+
if (!this.findFieldByName(fields, nfName)) {
|
|
6124
6379
|
logError(`Smart field identification returned invalid nameField: '${nfName}' not found in entity fields`);
|
|
6125
6380
|
}
|
|
6126
6381
|
}
|
|
@@ -6133,6 +6388,9 @@ export class ManageMetadataBase {
|
|
|
6133
6388
|
WHERE ID = '${winner.ID}'
|
|
6134
6389
|
AND AutoUpdateIsNameField = ${this.boolLit(true)}
|
|
6135
6390
|
`);
|
|
6391
|
+
if (entity?.Name) {
|
|
6392
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(winner.Name), 'IsNameField', true);
|
|
6393
|
+
}
|
|
6136
6394
|
}
|
|
6137
6395
|
// Clear every other flagged field that allows auto-update — single winner, always.
|
|
6138
6396
|
for (const f of fields) {
|
|
@@ -6145,6 +6403,9 @@ export class ManageMetadataBase {
|
|
|
6145
6403
|
WHERE ID = '${f.ID}'
|
|
6146
6404
|
AND AutoUpdateIsNameField = ${this.boolLit(true)}
|
|
6147
6405
|
`);
|
|
6406
|
+
if (entity?.Name) {
|
|
6407
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(f.Name), 'IsNameField', false);
|
|
6408
|
+
}
|
|
6148
6409
|
}
|
|
6149
6410
|
}
|
|
6150
6411
|
/**
|
|
@@ -6166,7 +6427,7 @@ export class ManageMetadataBase {
|
|
|
6166
6427
|
return this.preferLiteralNameField(eligibleFlagged);
|
|
6167
6428
|
}
|
|
6168
6429
|
for (const candidateName of rankedCandidates) {
|
|
6169
|
-
const candidate =
|
|
6430
|
+
const candidate = this.findFieldByName(fields, candidateName);
|
|
6170
6431
|
if (candidate && this.isFieldEligibleForNameField(candidate)) {
|
|
6171
6432
|
return candidate;
|
|
6172
6433
|
}
|
|
@@ -6274,12 +6535,15 @@ export class ManageMetadataBase {
|
|
|
6274
6535
|
/**
|
|
6275
6536
|
* Generate SQL UPDATEs for DefaultInView on the identified default view fields
|
|
6276
6537
|
*/
|
|
6277
|
-
applyDefaultInViewUpdates(sqlStatements, fields, result) {
|
|
6538
|
+
applyDefaultInViewUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
|
|
6539
|
+
const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
|
|
6540
|
+
const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
|
|
6541
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6278
6542
|
// Guard against a malformed LLM result that omits the array entirely.
|
|
6279
6543
|
// Mirrors the defensive handling in the sibling apply* methods.
|
|
6280
6544
|
const defaultInView = result.defaultInView ?? [];
|
|
6281
|
-
const defaultInViewFields = fields.filter(f => defaultInView.
|
|
6282
|
-
const missingFields = defaultInView.filter(name => !
|
|
6545
|
+
const defaultInViewFields = fields.filter(f => defaultInView.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateDefaultInView && f.ID && inScope(f));
|
|
6546
|
+
const missingFields = defaultInView.filter(name => !this.findFieldByName(fields, name));
|
|
6283
6547
|
if (missingFields.length > 0) {
|
|
6284
6548
|
logError(`Smart field identification returned invalid defaultInView fields: ${missingFields.join(', ')} not found in entity`);
|
|
6285
6549
|
}
|
|
@@ -6291,6 +6555,9 @@ export class ManageMetadataBase {
|
|
|
6291
6555
|
WHERE ID = '${field.ID}'
|
|
6292
6556
|
AND AutoUpdateDefaultInView = ${this.boolLit(true)}
|
|
6293
6557
|
`);
|
|
6558
|
+
if (entity?.Name) {
|
|
6559
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'DefaultInView', true);
|
|
6560
|
+
}
|
|
6294
6561
|
}
|
|
6295
6562
|
}
|
|
6296
6563
|
}
|
|
@@ -6305,12 +6572,13 @@ export class ManageMetadataBase {
|
|
|
6305
6572
|
* always-wrong cases — they cause the data provider to emit unindexed scans on
|
|
6306
6573
|
* every search.
|
|
6307
6574
|
*/
|
|
6308
|
-
applySearchableFieldUpdates(sqlStatements, fields, result, entity) {
|
|
6575
|
+
applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx) {
|
|
6576
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6309
6577
|
if (!result.searchableFields || result.searchableFields.length === 0) {
|
|
6310
6578
|
return;
|
|
6311
6579
|
}
|
|
6312
|
-
const searchableFields = fields.filter(f => result.searchableFields.
|
|
6313
|
-
const missingSearchableFields = result.searchableFields.filter(name => !
|
|
6580
|
+
const searchableFields = fields.filter(f => result.searchableFields.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateIncludeInUserSearchAPI && f.ID && inScope(f));
|
|
6581
|
+
const missingSearchableFields = result.searchableFields.filter(name => !this.findFieldByName(fields, name));
|
|
6314
6582
|
if (missingSearchableFields.length > 0) {
|
|
6315
6583
|
logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
|
|
6316
6584
|
}
|
|
@@ -6328,6 +6596,9 @@ export class ManageMetadataBase {
|
|
|
6328
6596
|
WHERE ID = '${field.ID}'
|
|
6329
6597
|
AND AutoUpdateIncludeInUserSearchAPI = ${this.boolLit(true)}
|
|
6330
6598
|
`);
|
|
6599
|
+
if (entity?.Name) {
|
|
6600
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'IncludeInUserSearchAPI', true);
|
|
6601
|
+
}
|
|
6331
6602
|
}
|
|
6332
6603
|
}
|
|
6333
6604
|
}
|
|
@@ -6345,7 +6616,7 @@ export class ManageMetadataBase {
|
|
|
6345
6616
|
if (!textTypes.has(type))
|
|
6346
6617
|
return false;
|
|
6347
6618
|
// Unbounded text on a non-FTX entity cannot be index-seeked.
|
|
6348
|
-
const length = field.Length;
|
|
6619
|
+
const length = (field.Length ?? field.MaxLength);
|
|
6349
6620
|
if (length === -1 && !ftxEnabled)
|
|
6350
6621
|
return false;
|
|
6351
6622
|
return true;
|
|
@@ -6353,17 +6624,20 @@ export class ManageMetadataBase {
|
|
|
6353
6624
|
/**
|
|
6354
6625
|
* Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
|
|
6355
6626
|
*/
|
|
6356
|
-
applySearchPredicateUpdates(sqlStatements, fields, result) {
|
|
6627
|
+
applySearchPredicateUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
|
|
6628
|
+
const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
|
|
6629
|
+
const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
|
|
6630
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6357
6631
|
if (!result.searchPredicates || result.searchPredicates.length === 0) {
|
|
6358
6632
|
return;
|
|
6359
6633
|
}
|
|
6360
6634
|
for (const sp of result.searchPredicates) {
|
|
6361
|
-
const field =
|
|
6635
|
+
const field = this.findFieldByName(fields, sp.field);
|
|
6362
6636
|
if (!field) {
|
|
6363
6637
|
logError(`Smart field identification returned invalid searchPredicate field: '${sp.field}' not found in entity fields`);
|
|
6364
6638
|
continue;
|
|
6365
6639
|
}
|
|
6366
|
-
if (!field.AutoUpdateUserSearchPredicate || !field.ID) {
|
|
6640
|
+
if (!field.AutoUpdateUserSearchPredicate || !field.ID || !inScope(field)) {
|
|
6367
6641
|
continue;
|
|
6368
6642
|
}
|
|
6369
6643
|
// Only update if the current value differs from the recommended predicate
|
|
@@ -6374,6 +6648,9 @@ export class ManageMetadataBase {
|
|
|
6374
6648
|
WHERE ID = '${field.ID}'
|
|
6375
6649
|
AND AutoUpdateUserSearchPredicate = ${this.boolLit(true)}
|
|
6376
6650
|
`);
|
|
6651
|
+
if (entity?.Name) {
|
|
6652
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'UserSearchPredicateAPI', sp.predicate);
|
|
6653
|
+
}
|
|
6377
6654
|
}
|
|
6378
6655
|
}
|
|
6379
6656
|
}
|
|
@@ -6421,6 +6698,9 @@ export class ManageMetadataBase {
|
|
|
6421
6698
|
WHERE ID = '${entity.ID}'
|
|
6422
6699
|
AND AutoUpdateAllowUserSearchAPI = ${this.boolLit(true)}
|
|
6423
6700
|
`);
|
|
6701
|
+
if (entity.Name) {
|
|
6702
|
+
DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'AllowUserSearchAPI', newValue);
|
|
6703
|
+
}
|
|
6424
6704
|
}
|
|
6425
6705
|
}
|
|
6426
6706
|
/**
|
|
@@ -6451,9 +6731,10 @@ export class ManageMetadataBase {
|
|
|
6451
6731
|
/**
|
|
6452
6732
|
* Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
|
|
6453
6733
|
*/
|
|
6454
|
-
applyFullTextSearchUpdates(sqlStatements, entity, fields, result) {
|
|
6455
|
-
|
|
6456
|
-
|
|
6734
|
+
applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx) {
|
|
6735
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6736
|
+
// Entity-level FullTextSearchEnabled (only for new entities)
|
|
6737
|
+
if ((!ctx || ctx.isNewEntity) && result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
|
|
6457
6738
|
const newValue = result.enableFullTextSearch;
|
|
6458
6739
|
const currentValue = !!entity.FullTextSearchEnabled;
|
|
6459
6740
|
if (newValue !== currentValue) {
|
|
@@ -6463,6 +6744,9 @@ export class ManageMetadataBase {
|
|
|
6463
6744
|
WHERE ID = '${entity.ID}'
|
|
6464
6745
|
AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
|
|
6465
6746
|
`);
|
|
6747
|
+
if (entity.Name) {
|
|
6748
|
+
DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'FullTextSearchEnabled', newValue);
|
|
6749
|
+
}
|
|
6466
6750
|
}
|
|
6467
6751
|
}
|
|
6468
6752
|
// Field-level FullTextSearchEnabled
|
|
@@ -6470,12 +6754,12 @@ export class ManageMetadataBase {
|
|
|
6470
6754
|
return;
|
|
6471
6755
|
}
|
|
6472
6756
|
for (const ftsFieldName of result.fullTextSearchFields) {
|
|
6473
|
-
const field =
|
|
6757
|
+
const field = this.findFieldByName(fields, ftsFieldName);
|
|
6474
6758
|
if (!field) {
|
|
6475
6759
|
logError(`Smart field identification returned invalid fullTextSearchField: '${ftsFieldName}' not found in entity fields`);
|
|
6476
6760
|
continue;
|
|
6477
6761
|
}
|
|
6478
|
-
if (!field.AutoUpdateFullTextSearch || !field.ID) {
|
|
6762
|
+
if (!field.AutoUpdateFullTextSearch || !field.ID || !inScope(field)) {
|
|
6479
6763
|
continue;
|
|
6480
6764
|
}
|
|
6481
6765
|
if (!field.FullTextSearchEnabled) {
|
|
@@ -6485,6 +6769,9 @@ export class ManageMetadataBase {
|
|
|
6485
6769
|
WHERE ID = '${field.ID}'
|
|
6486
6770
|
AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
|
|
6487
6771
|
`);
|
|
6772
|
+
if (entity.Name) {
|
|
6773
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, String(field.Name), 'FullTextSearchEnabled', true);
|
|
6774
|
+
}
|
|
6488
6775
|
}
|
|
6489
6776
|
}
|
|
6490
6777
|
}
|
|
@@ -6502,29 +6789,29 @@ export class ManageMetadataBase {
|
|
|
6502
6789
|
// Defensive: a malformed LLM result can leave fieldCategories undefined or
|
|
6503
6790
|
// non-array; both consumers below iterate it, so coerce to a safe array.
|
|
6504
6791
|
const fieldCategories = Array.isArray(result.fieldCategories) ? result.fieldCategories : [];
|
|
6505
|
-
await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories);
|
|
6792
|
+
await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, { isNewEntity });
|
|
6506
6793
|
// Auto-detect geo-capable entities and set SupportsGeoCoding
|
|
6507
|
-
await this.detectAndSetGeoCodingSupport(pool, entity
|
|
6794
|
+
await this.detectAndSetGeoCodingSupport(pool, entity);
|
|
6508
6795
|
if (result.entityIcon) {
|
|
6509
|
-
await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
|
|
6796
|
+
await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
|
|
6510
6797
|
}
|
|
6511
6798
|
// Resolve categoryInfo from new or legacy format
|
|
6512
6799
|
const categoryInfoToStore = result.categoryInfo ||
|
|
6513
6800
|
(result.categoryIcons ?
|
|
6514
6801
|
Object.fromEntries(Object.entries(result.categoryIcons).map(([cat, icon]) => [cat, { icon, description: '' }])) : null);
|
|
6515
6802
|
if (categoryInfoToStore) {
|
|
6516
|
-
await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore);
|
|
6803
|
+
await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore, entity.Name);
|
|
6517
6804
|
}
|
|
6518
6805
|
if (isNewEntity && result.entityImportance) {
|
|
6519
|
-
await this.applyEntityImportance(pool, entity.ID, result.entityImportance);
|
|
6806
|
+
await this.applyEntityImportance(pool, entity.ID, result.entityImportance, entity.Name);
|
|
6520
6807
|
}
|
|
6521
6808
|
}
|
|
6522
6809
|
/**
|
|
6523
|
-
* Detect if an entity has geo-capable fields based on
|
|
6810
|
+
* Detect if an entity has geo-capable fields based on persisted ExtendedType values in the database.
|
|
6524
6811
|
* If any field has a Geo* ExtendedType, set Entity.SupportsGeoCoding = 1.
|
|
6525
6812
|
* Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
|
|
6526
6813
|
*/
|
|
6527
|
-
async detectAndSetGeoCodingSupport(pool, entity
|
|
6814
|
+
async detectAndSetGeoCodingSupport(pool, entity) {
|
|
6528
6815
|
const schema = mj_core_schema();
|
|
6529
6816
|
// Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
|
|
6530
6817
|
const autoUpdateResult = await pool.query(`
|
|
@@ -6536,14 +6823,8 @@ export class ManageMetadataBase {
|
|
|
6536
6823
|
if (!row || !row.AutoUpdateSupportsGeoCoding) {
|
|
6537
6824
|
return; // Flag is locked, don't modify
|
|
6538
6825
|
}
|
|
6539
|
-
//
|
|
6540
|
-
|
|
6541
|
-
'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
|
|
6542
|
-
'GeoCity', 'GeoPostalCode', 'GeoAddress'
|
|
6543
|
-
]);
|
|
6544
|
-
const hasGeoFields = fieldCategories.some(fc => fc.extendedType != null && geoExtendedTypes.has(fc.extendedType));
|
|
6545
|
-
// Also check existing fields in the database for Geo* ExtendedTypes
|
|
6546
|
-
// (in case the LLM didn't re-assign them this run but they were previously set)
|
|
6826
|
+
// Check existing fields in the database for Geo* ExtendedTypes
|
|
6827
|
+
// (relies on persisted state after applyFieldCategories has run)
|
|
6547
6828
|
const existingGeoResult = await pool.query(`
|
|
6548
6829
|
SELECT COUNT(*) AS ${this.qi('GeoFieldCount')}
|
|
6549
6830
|
FROM ${this.qs(schema, 'EntityField')}
|
|
@@ -6551,7 +6832,7 @@ export class ManageMetadataBase {
|
|
|
6551
6832
|
AND ${this.qi('ExtendedType')} IN ('Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince', 'GeoCity', 'GeoPostalCode', 'GeoAddress')
|
|
6552
6833
|
`);
|
|
6553
6834
|
const existingGeoCount = existingGeoResult?.recordset?.[0]?.GeoFieldCount ?? 0;
|
|
6554
|
-
const shouldSupportGeo =
|
|
6835
|
+
const shouldSupportGeo = existingGeoCount > 0;
|
|
6555
6836
|
const currentValue = row.SupportsGeoCoding ? true : false;
|
|
6556
6837
|
if (shouldSupportGeo !== currentValue) {
|
|
6557
6838
|
await this.LogSQLAndExecute(pool, `
|
|
@@ -6559,7 +6840,8 @@ export class ManageMetadataBase {
|
|
|
6559
6840
|
SET ${this.qi('SupportsGeoCoding')} = ${this.boolLit(shouldSupportGeo)}
|
|
6560
6841
|
WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = ${this.boolLit(true)}
|
|
6561
6842
|
`, `Set SupportsGeoCoding = ${shouldSupportGeo} for ${entity.Name}`);
|
|
6562
|
-
logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from
|
|
6843
|
+
logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from persisted geo fields)`);
|
|
6844
|
+
DecisionMetadataWriter.Instance.recordEntityDecision(entity.Name, 'SupportsGeoCoding', shouldSupportGeo);
|
|
6563
6845
|
// Queue for late-phase view regeneration — the view was already generated
|
|
6564
6846
|
// before this flag was set, so it needs to be regenerated with the geo JOIN
|
|
6565
6847
|
ManageMetadataBase.AddEntityRequiringViewRegen(entity.Name, 'Geocoding');
|
|
@@ -6606,55 +6888,72 @@ export class ManageMetadataBase {
|
|
|
6606
6888
|
* Enforces stability rules: fields with existing categories cannot move to NEW categories.
|
|
6607
6889
|
* All SQL updates are batched into a single execution for performance.
|
|
6608
6890
|
*/
|
|
6609
|
-
async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories) {
|
|
6891
|
+
async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, ctx) {
|
|
6610
6892
|
const sqlStatements = [];
|
|
6893
|
+
const reporter = CodeGenReporter.Instance;
|
|
6894
|
+
// Count fields with blank Category that the LLM omitted (FM2 re-trigger loop tracking)
|
|
6895
|
+
const proposedNames = new Set(fieldCategories.map(fc => (fc.fieldName ?? '').trim().toLowerCase()));
|
|
6896
|
+
let omittedBlankCount = 0;
|
|
6897
|
+
for (const field of fields) {
|
|
6898
|
+
const isBlankCategory = !field.Category || field.Category.trim() === '';
|
|
6899
|
+
if (isBlankCategory && field.AutoUpdateCategory && !proposedNames.has(field.Name.trim().toLowerCase())) {
|
|
6900
|
+
omittedBlankCount++;
|
|
6901
|
+
}
|
|
6902
|
+
}
|
|
6903
|
+
if (omittedBlankCount > 0) {
|
|
6904
|
+
reporter.counter('ai.fieldsOmittedByLLM', omittedBlankCount);
|
|
6905
|
+
}
|
|
6611
6906
|
for (const fieldCategory of fieldCategories) {
|
|
6612
|
-
const field =
|
|
6613
|
-
if (field
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
setClauses.push(`Category = '${category.replace(/'/g, "''")}'`);
|
|
6629
|
-
}
|
|
6630
|
-
if (field.GeneratedFormSection !== 'Category') {
|
|
6631
|
-
setClauses.push(`GeneratedFormSection = 'Category'`);
|
|
6632
|
-
}
|
|
6633
|
-
if (fieldCategory.displayName && field.AutoUpdateDisplayName && field.DisplayName !== fieldCategory.displayName) {
|
|
6634
|
-
setClauses.push(`DisplayName = '${fieldCategory.displayName.replace(/'/g, "''")}'`);
|
|
6635
|
-
}
|
|
6636
|
-
if (fieldCategory.extendedType !== undefined && field.ExtendedType !== fieldCategory.extendedType) {
|
|
6637
|
-
const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${String(fieldCategory.extendedType).replace(/'/g, "''")}'`;
|
|
6638
|
-
setClauses.push(`ExtendedType = ${extendedType}`);
|
|
6907
|
+
const field = this.findFieldByName(fields, fieldCategory.fieldName);
|
|
6908
|
+
if (!field) {
|
|
6909
|
+
logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
|
|
6910
|
+
continue;
|
|
6911
|
+
}
|
|
6912
|
+
const lockCtx = {
|
|
6913
|
+
isNewEntity: ctx.isNewEntity,
|
|
6914
|
+
isNewField: ManageMetadataBase.isFieldNew(entity.ID, field.Name),
|
|
6915
|
+
descriptionReopened: ManageMetadataBase.isDisplayNameReopened(entity.ID, field.Name),
|
|
6916
|
+
typeReopened: ManageMetadataBase.isTypeReopened(entity.ID, field.Name),
|
|
6917
|
+
existingCategories,
|
|
6918
|
+
};
|
|
6919
|
+
const update = computeFieldMetadataUpdate(field, fieldCategory, lockCtx, this.validateExtendedType.bind(this), (val, fn) => this.sanitizeCodeType(val, fn ?? field.Name, entity.Name));
|
|
6920
|
+
for (const skip of update.skipped) {
|
|
6921
|
+
if (skip.reason === 'locked') {
|
|
6922
|
+
reporter.counter('ai.fieldsLocked');
|
|
6639
6923
|
}
|
|
6640
|
-
if (
|
|
6641
|
-
|
|
6642
|
-
if (field.CodeType !== sanitized) {
|
|
6643
|
-
const codeType = sanitized == null ? 'NULL' : `'${sanitized.replace(/'/g, "''")}'`;
|
|
6644
|
-
setClauses.push(`CodeType = ${codeType}`);
|
|
6645
|
-
}
|
|
6924
|
+
else if (skip.reason === 'invalid') {
|
|
6925
|
+
reporter.counter('ai.fieldsInvalidProposal');
|
|
6646
6926
|
}
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6927
|
+
}
|
|
6928
|
+
const setClauses = [];
|
|
6929
|
+
if (update.Category !== undefined) {
|
|
6930
|
+
setClauses.push(`Category = '${update.Category.replace(/'/g, "''")}'`);
|
|
6931
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'Category', update.Category);
|
|
6932
|
+
}
|
|
6933
|
+
if (update.GeneratedFormSection !== undefined) {
|
|
6934
|
+
setClauses.push(`GeneratedFormSection = 'Category'`);
|
|
6935
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'GeneratedFormSection', 'Category');
|
|
6936
|
+
}
|
|
6937
|
+
if (update.DisplayName !== undefined) {
|
|
6938
|
+
setClauses.push(`DisplayName = '${update.DisplayName.replace(/'/g, "''")}'`);
|
|
6939
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'DisplayName', update.DisplayName);
|
|
6940
|
+
}
|
|
6941
|
+
if (update.ExtendedType !== undefined) {
|
|
6942
|
+
const extVal = update.ExtendedType === null ? 'NULL' : `'${update.ExtendedType.replace(/'/g, "''")}'`;
|
|
6943
|
+
setClauses.push(`ExtendedType = ${extVal}`);
|
|
6944
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'ExtendedType', update.ExtendedType);
|
|
6945
|
+
}
|
|
6946
|
+
if (update.CodeType !== undefined) {
|
|
6947
|
+
const codeVal = update.CodeType === null ? 'NULL' : `'${update.CodeType.replace(/'/g, "''")}'`;
|
|
6948
|
+
setClauses.push(`CodeType = ${codeVal}`);
|
|
6949
|
+
DecisionMetadataWriter.Instance.recordFieldDecision(entity.Name, field.Name, 'CodeType', update.CodeType);
|
|
6950
|
+
}
|
|
6951
|
+
if (setClauses.length > 0) {
|
|
6952
|
+
sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
|
|
6650
6953
|
SET
|
|
6651
6954
|
${setClauses.join(',\n ')}
|
|
6652
6955
|
WHERE
|
|
6653
|
-
ID = '${field.ID}'
|
|
6654
|
-
}
|
|
6655
|
-
}
|
|
6656
|
-
else if (!field) {
|
|
6657
|
-
logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
|
|
6956
|
+
ID = '${field.ID}'`);
|
|
6658
6957
|
}
|
|
6659
6958
|
}
|
|
6660
6959
|
if (sqlStatements.length > 0) {
|
|
@@ -6669,7 +6968,7 @@ WHERE
|
|
|
6669
6968
|
/**
|
|
6670
6969
|
* Sets the entity icon if the entity doesn't already have one.
|
|
6671
6970
|
*/
|
|
6672
|
-
async applyEntityIcon(pool, entityId, entityIcon) {
|
|
6971
|
+
async applyEntityIcon(pool, entityId, entityIcon, entityName) {
|
|
6673
6972
|
if (!entityIcon || entityIcon.trim().length === 0)
|
|
6674
6973
|
return;
|
|
6675
6974
|
const checkSQL = `SELECT Icon FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ID = '${entityId}'`;
|
|
@@ -6686,6 +6985,9 @@ WHERE
|
|
|
6686
6985
|
try {
|
|
6687
6986
|
await this.LogSQLAndExecute(pool, updateSQL, `Set entity icon to ${entityIcon}`, false);
|
|
6688
6987
|
logStatus(` Set entity icon: ${entityIcon}`);
|
|
6988
|
+
if (entityName) {
|
|
6989
|
+
DecisionMetadataWriter.Instance.recordEntityDecision(entityName, 'Icon', entityIcon);
|
|
6990
|
+
}
|
|
6689
6991
|
}
|
|
6690
6992
|
catch (ex) {
|
|
6691
6993
|
logError('Error Applying Entity Icon', ex);
|
|
@@ -6696,32 +6998,49 @@ WHERE
|
|
|
6696
6998
|
/**
|
|
6697
6999
|
* Upserts FieldCategoryInfo (new format) and FieldCategoryIcons (legacy format) in EntitySetting.
|
|
6698
7000
|
*/
|
|
6699
|
-
async applyCategoryInfoSettings(pool, entityId, categoryInfo) {
|
|
7001
|
+
async applyCategoryInfoSettings(pool, entityId, categoryInfo, entityName) {
|
|
6700
7002
|
if (!categoryInfo || Object.keys(categoryInfo).length === 0)
|
|
6701
7003
|
return;
|
|
6702
|
-
|
|
7004
|
+
if (entityName) {
|
|
7005
|
+
DecisionMetadataWriter.Instance.recordEntitySetting(entityName, 'FieldCategoryInfo', categoryInfo);
|
|
7006
|
+
}
|
|
7007
|
+
const canonicalInfo = canonicalJSONStringify(categoryInfo, 2);
|
|
7008
|
+
const infoJSON = canonicalInfo.replace(/'/g, "''");
|
|
7009
|
+
const ENTITY_SETTING_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
6703
7010
|
// Upsert FieldCategoryInfo (new format)
|
|
6704
|
-
const checkNewSQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
7011
|
+
const checkNewSQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
6705
7012
|
const existingNew = await this.runQuery(pool, checkNewSQL);
|
|
6706
7013
|
if (existingNew.recordset.length > 0) {
|
|
7014
|
+
let isMatch = false;
|
|
6707
7015
|
try {
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
|
|
6712
|
-
`, `Update FieldCategoryInfo setting for entity`, false);
|
|
7016
|
+
const rawVal = existingNew.recordset[0].Value;
|
|
7017
|
+
const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
|
|
7018
|
+
isMatch = deepEqualJSON(parsed, categoryInfo);
|
|
6713
7019
|
}
|
|
6714
|
-
catch
|
|
6715
|
-
|
|
7020
|
+
catch {
|
|
7021
|
+
isMatch = false;
|
|
7022
|
+
}
|
|
7023
|
+
if (!isMatch) {
|
|
7024
|
+
try {
|
|
7025
|
+
await this.LogSQLAndExecute(pool, `
|
|
7026
|
+
UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
|
|
7027
|
+
SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7028
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
|
|
7029
|
+
`, `Update FieldCategoryInfo setting for entity`, false);
|
|
7030
|
+
}
|
|
7031
|
+
catch (ex) {
|
|
7032
|
+
logError('Error Applying Category Info Settings: Part 1', ex);
|
|
7033
|
+
}
|
|
6716
7034
|
}
|
|
6717
7035
|
}
|
|
6718
7036
|
else {
|
|
6719
|
-
const newId =
|
|
7037
|
+
const newId = uuidv5(`${entityId}|FieldCategoryInfo`, ENTITY_SETTING_NAMESPACE);
|
|
6720
7038
|
try {
|
|
6721
|
-
|
|
6722
|
-
|
|
6723
|
-
VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
|
|
6724
|
-
|
|
7039
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
7040
|
+
const insertSQL = `INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
|
|
7041
|
+
VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
7042
|
+
const sSQL = this.conditionalInsert(checkQuery, insertSQL);
|
|
7043
|
+
await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryInfo setting for entity`, false);
|
|
6725
7044
|
}
|
|
6726
7045
|
catch (ex) {
|
|
6727
7046
|
logError('Error Applying Category Info Settings: Part 2', ex);
|
|
@@ -6734,28 +7053,44 @@ WHERE
|
|
|
6734
7053
|
iconsOnly[category] = info.icon;
|
|
6735
7054
|
}
|
|
6736
7055
|
}
|
|
6737
|
-
|
|
6738
|
-
|
|
7056
|
+
if (entityName) {
|
|
7057
|
+
DecisionMetadataWriter.Instance.recordEntitySetting(entityName, 'FieldCategoryIcons', iconsOnly);
|
|
7058
|
+
}
|
|
7059
|
+
const canonicalIcons = canonicalJSONStringify(iconsOnly, 2);
|
|
7060
|
+
const iconsJSON = canonicalIcons.replace(/'/g, "''");
|
|
7061
|
+
const checkLegacySQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
|
|
6739
7062
|
const existingLegacy = await this.runQuery(pool, checkLegacySQL);
|
|
6740
7063
|
if (existingLegacy.recordset.length > 0) {
|
|
7064
|
+
let isMatch = false;
|
|
6741
7065
|
try {
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
|
|
6746
|
-
`, `Update FieldCategoryIcons setting (legacy)`, false);
|
|
7066
|
+
const rawVal = existingLegacy.recordset[0].Value;
|
|
7067
|
+
const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
|
|
7068
|
+
isMatch = deepEqualJSON(parsed, iconsOnly);
|
|
6747
7069
|
}
|
|
6748
|
-
catch
|
|
6749
|
-
|
|
7070
|
+
catch {
|
|
7071
|
+
isMatch = false;
|
|
7072
|
+
}
|
|
7073
|
+
if (!isMatch) {
|
|
7074
|
+
try {
|
|
7075
|
+
await this.LogSQLAndExecute(pool, `
|
|
7076
|
+
UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
|
|
7077
|
+
SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7078
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
|
|
7079
|
+
`, `Update FieldCategoryIcons setting (legacy)`, false);
|
|
7080
|
+
}
|
|
7081
|
+
catch (ex) {
|
|
7082
|
+
logError('Error Applying Category Info Settings: Part 3', ex);
|
|
7083
|
+
}
|
|
6750
7084
|
}
|
|
6751
7085
|
}
|
|
6752
7086
|
else {
|
|
6753
|
-
const newId =
|
|
7087
|
+
const newId = uuidv5(`${entityId}|FieldCategoryIcons`, ENTITY_SETTING_NAMESPACE);
|
|
6754
7088
|
try {
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
|
|
6758
|
-
|
|
7089
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
|
|
7090
|
+
const insertSQL = `INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
|
|
7091
|
+
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
7092
|
+
const sSQL = this.conditionalInsert(checkQuery, insertSQL);
|
|
7093
|
+
await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryIcons setting (legacy)`, false);
|
|
6759
7094
|
}
|
|
6760
7095
|
catch (ex) {
|
|
6761
7096
|
logError('Error Applying Category Info Settings: Part 4', ex);
|
|
@@ -6766,7 +7101,7 @@ WHERE
|
|
|
6766
7101
|
* Applies entity importance analysis to MJApplicationEntity records.
|
|
6767
7102
|
* Only called for NEW entities to set DefaultForNewUser.
|
|
6768
7103
|
*/
|
|
6769
|
-
async applyEntityImportance(pool, entityId, importance) {
|
|
7104
|
+
async applyEntityImportance(pool, entityId, importance, entityName) {
|
|
6770
7105
|
const updateSQL = `
|
|
6771
7106
|
UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
|
|
6772
7107
|
SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
@@ -6776,6 +7111,9 @@ WHERE
|
|
|
6776
7111
|
await this.LogSQLAndExecute(pool, updateSQL, `Set DefaultForNewUser=${importance.defaultForNewUser} for NEW entity (category: ${importance.entityCategory}, confidence: ${importance.confidence})`, false);
|
|
6777
7112
|
logStatus(` Entity importance (NEW Entity): ${importance.entityCategory} (defaultForNewUser: ${importance.defaultForNewUser}, confidence: ${importance.confidence})`);
|
|
6778
7113
|
logStatus(` Reasoning: ${importance.reasoning}`);
|
|
7114
|
+
if (entityName) {
|
|
7115
|
+
DecisionMetadataWriter.Instance.recordApplicationEntityDecision(entityName, '', 'DefaultForNewUser', importance.defaultForNewUser);
|
|
7116
|
+
}
|
|
6779
7117
|
}
|
|
6780
7118
|
catch (ex) {
|
|
6781
7119
|
logError('Error Applying Entity Importance', ex);
|