@memberjunction/codegen-lib 6.1.0-edge.5 → 6.1.0-edge.7
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 +29 -2
- package/dist/Config/config.d.ts.map +1 -1
- package/dist/Config/config.js +54 -5
- package/dist/Config/config.js.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts +64 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.js +49 -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/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/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 +133 -51
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +574 -306
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +10 -2
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +113 -42
- 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 +47 -2
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +176 -30
- 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/reconcileFieldLevelSecurity.d.ts +22 -0
- package/dist/Database/reconcileFieldLevelSecurity.d.ts.map +1 -0
- package/dist/Database/reconcileFieldLevelSecurity.js +47 -0
- package/dist/Database/reconcileFieldLevelSecurity.js.map +1 -0
- package/dist/Database/sql_codegen.d.ts +34 -0
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +152 -14
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts.map +1 -1
- package/dist/Manifest/GenerateClassRegistrationsManifest.js +3 -2
- 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 +17 -5
- package/dist/Misc/advanced_generation.d.ts.map +1 -1
- package/dist/Misc/advanced_generation.js +69 -23
- package/dist/Misc/advanced_generation.js.map +1 -1
- package/dist/Misc/codegen-reporter.d.ts +2 -0
- package/dist/Misc/codegen-reporter.d.ts.map +1 -1
- package/dist/Misc/codegen-reporter.js +6 -2
- package/dist/Misc/codegen-reporter.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +30 -20
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts +39 -0
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +63 -9
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/remote_operations_codegen.d.ts +36 -1
- package/dist/Misc/remote_operations_codegen.d.ts.map +1 -1
- package/dist/Misc/remote_operations_codegen.js +178 -8
- package/dist/Misc/remote_operations_codegen.js.map +1 -1
- package/dist/Misc/schema-emit.d.ts.map +1 -1
- package/dist/Misc/schema-emit.js +2 -1
- package/dist/Misc/schema-emit.js.map +1 -1
- 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/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.map +1 -1
- package/dist/runCodeGen.js +134 -26
- package/dist/runCodeGen.js.map +1 -1
- package/package.json +29 -29
|
@@ -11,22 +11,26 @@ 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";
|
|
18
18
|
import { applyIncludeSchemaScope } from "./schema-scope.js";
|
|
19
19
|
import { buildHealSchemaRoutineParams, getAuthoredExcludeSchemas, snapshotAuthoredExcludeSchemas } from "./heal-schema-params.js";
|
|
20
|
-
import { AdvancedGeneration } from "../Misc/advanced_generation.js";
|
|
20
|
+
import { AdvancedGeneration, isPlausibleEntityName } from "../Misc/advanced_generation.js";
|
|
21
21
|
import { CodeGenReporter } from "../Misc/codegen-reporter.js";
|
|
22
22
|
import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFieldName, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
|
|
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 { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
|
|
33
|
+
export { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
|
|
30
34
|
export class ValidatorResult {
|
|
31
35
|
constructor() {
|
|
32
36
|
this.entityName = "";
|
|
@@ -141,6 +145,19 @@ export class ManageMetadataBase {
|
|
|
141
145
|
boolLit(value) {
|
|
142
146
|
return this.dialect.BooleanLiteral(value);
|
|
143
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns an explicitly-typed UUID literal for the platform.
|
|
150
|
+
* SQL Server: CAST('...' AS uniqueidentifier), PostgreSQL: CAST('...' AS uuid)
|
|
151
|
+
*
|
|
152
|
+
* Needed wherever a UUID literal appears in the SELECT list of an `INSERT ... SELECT`. A bare
|
|
153
|
+
* quoted literal is fine in `INSERT ... VALUES`, where both platforms coerce it to the target
|
|
154
|
+
* column's type — but in the projection of a SELECT, PostgreSQL can resolve an untyped literal
|
|
155
|
+
* to `text` and then refuse to assign it to a `uuid` column. `CAST(x AS y)` is ANSI and settles
|
|
156
|
+
* it on both, with the type name coming from the dialect rather than a platform branch here.
|
|
157
|
+
*/
|
|
158
|
+
uuidLit(value) {
|
|
159
|
+
return `CAST('${value}' AS ${this.dialect.UuidTypeNames[0]})`;
|
|
160
|
+
}
|
|
144
161
|
/**
|
|
145
162
|
* Quotes mixed-case identifiers in a SQL string for the current platform.
|
|
146
163
|
* Delegates to the database provider's quoteSQLForExecution method.
|
|
@@ -161,47 +178,19 @@ export class ManageMetadataBase {
|
|
|
161
178
|
return pool.queryWithParams(this.qsql(sql), params);
|
|
162
179
|
}
|
|
163
180
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* `MAX(Sequence) + 1` for that entity.
|
|
181
|
+
* The `Sequence` expression for every EntityField INSERT CodeGen emits:
|
|
182
|
+
* `(SELECT COALESCE(MAX(Sequence), 0) + 1 FROM EntityField WHERE EntityID = '<id>')`.
|
|
167
183
|
*
|
|
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
|
-
}
|
|
184
|
+
* Evaluated at APPLY time, never a literal. The value is disposable — the run's own
|
|
185
|
+
* spUpdateExistingEntityFieldsFromSchema pass and R__RefreshMetadata renumber every field from
|
|
186
|
+
* the schema — but it must be unique on ANY database in ANY order, because the INSERT is
|
|
187
|
+
* appended verbatim to a migration and Flyway runs every versioned migration before the
|
|
188
|
+
* repeatable renumber. Each INSERT re-evaluates MAX after the one before it, so a batch that
|
|
189
|
+
* executes sequentially in emission order rises in that order (#3670, #4202).
|
|
190
|
+
*/
|
|
191
|
+
applyTimeEntityFieldSequenceSQL(entityID) {
|
|
192
|
+
// COALESCE is valid on both platforms and is the form the docs and the gate describe.
|
|
193
|
+
return `(SELECT COALESCE(MAX(${this.qi('Sequence')}), 0) + 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ${this.qi('EntityID')} = '${entityID}')`;
|
|
205
194
|
}
|
|
206
195
|
/**
|
|
207
196
|
* Wraps a SELECT query with a row limit.
|
|
@@ -276,6 +265,9 @@ export class ManageMetadataBase {
|
|
|
276
265
|
static get newEntityList() {
|
|
277
266
|
return this._newEntityList;
|
|
278
267
|
}
|
|
268
|
+
static set newEntityList(value) {
|
|
269
|
+
this._newEntityList = value;
|
|
270
|
+
}
|
|
279
271
|
static { this._modifiedEntityList = []; }
|
|
280
272
|
/**
|
|
281
273
|
* Globally scoped list of entities that have been modified during the metadata management process.
|
|
@@ -283,6 +275,108 @@ export class ManageMetadataBase {
|
|
|
283
275
|
static get modifiedEntityList() {
|
|
284
276
|
return this._modifiedEntityList;
|
|
285
277
|
}
|
|
278
|
+
static set modifiedEntityList(value) {
|
|
279
|
+
this._modifiedEntityList = value;
|
|
280
|
+
}
|
|
281
|
+
static { this._newFieldSet = new Set(); }
|
|
282
|
+
static { this._changedFields = new Map(); }
|
|
283
|
+
static { this._changedFieldReport = []; }
|
|
284
|
+
static fieldKey(entityID, name) {
|
|
285
|
+
const eid = (entityID ?? '').trim().toLowerCase();
|
|
286
|
+
const fld = (name ?? '').trim().toLowerCase();
|
|
287
|
+
if (!eid || !fld) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return `${eid}:${fld}`;
|
|
291
|
+
}
|
|
292
|
+
static registerNewField(entityID, name) {
|
|
293
|
+
const key = this.fieldKey(entityID, name);
|
|
294
|
+
if (key) {
|
|
295
|
+
this._newFieldSet.add(key);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
static registerFieldChange(entityID, name, reasons) {
|
|
299
|
+
const key = this.fieldKey(entityID, name);
|
|
300
|
+
if (key) {
|
|
301
|
+
let set = this._changedFields.get(key);
|
|
302
|
+
if (!set) {
|
|
303
|
+
set = new Set();
|
|
304
|
+
this._changedFields.set(key, set);
|
|
305
|
+
}
|
|
306
|
+
for (const r of reasons) {
|
|
307
|
+
set.add(r);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
static isFieldNew(entityID, name) {
|
|
312
|
+
const key = this.fieldKey(entityID, name);
|
|
313
|
+
return key ? this._newFieldSet.has(key) : false;
|
|
314
|
+
}
|
|
315
|
+
static fieldChangeReasons(entityID, name) {
|
|
316
|
+
const key = this.fieldKey(entityID, name);
|
|
317
|
+
const set = key ? this._changedFields.get(key) : undefined;
|
|
318
|
+
return set ?? new Set();
|
|
319
|
+
}
|
|
320
|
+
static isDisplayNameReopened(entityID, name) {
|
|
321
|
+
const reasons = this.fieldChangeReasons(entityID, name);
|
|
322
|
+
for (const r of reasons) {
|
|
323
|
+
if (DISPLAYNAME_REOPEN_REASONS.has(r)) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
static isTypeReopened(entityID, name) {
|
|
330
|
+
const reasons = this.fieldChangeReasons(entityID, name);
|
|
331
|
+
for (const r of reasons) {
|
|
332
|
+
if (TYPE_REOPEN_REASONS.has(r)) {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
static get newFieldCount() {
|
|
339
|
+
return this._newFieldSet.size;
|
|
340
|
+
}
|
|
341
|
+
static get changedFieldCount() {
|
|
342
|
+
return this._changedFields.size;
|
|
343
|
+
}
|
|
344
|
+
static get changedFieldReport() {
|
|
345
|
+
return this._changedFieldReport;
|
|
346
|
+
}
|
|
347
|
+
static clearFieldTracking() {
|
|
348
|
+
this._newFieldSet.clear();
|
|
349
|
+
this._changedFields.clear();
|
|
350
|
+
this._changedFieldReport = [];
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Takes a snapshot of EntityField rows from vwEntityFields for diffing.
|
|
354
|
+
* Only queries columns in TRACKED_FIELD_COLUMNS (Sequence is excluded).
|
|
355
|
+
*/
|
|
356
|
+
async snapshotEntityFields(pool, entityIDs) {
|
|
357
|
+
const isScoped = entityIDs !== undefined && entityIDs.length > 0;
|
|
358
|
+
const filter = isScoped
|
|
359
|
+
? `WHERE ${this.qi('EntityID')} IN (${entityIDs.map(id => `'${id}'`).join(',')})`
|
|
360
|
+
: '';
|
|
361
|
+
const cols = TRACKED_FIELD_COLUMNS.map(c => this.qi(c)).join(', ');
|
|
362
|
+
const sSQL = `SELECT ${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Entity')} AS ${this.qi('EntityName')}, ${this.qi('Name')}, ${cols}
|
|
363
|
+
FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
|
|
364
|
+
${filter}`;
|
|
365
|
+
const result = await this.runQuery(pool, sSQL);
|
|
366
|
+
const map = new Map();
|
|
367
|
+
if (result && result.recordset) {
|
|
368
|
+
for (const r of result.recordset) {
|
|
369
|
+
map.set(String(r.ID).toLowerCase(), r);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return map;
|
|
373
|
+
}
|
|
374
|
+
findFieldByName(fields, name) {
|
|
375
|
+
if (!name)
|
|
376
|
+
return undefined;
|
|
377
|
+
const target = name.trim().toLowerCase();
|
|
378
|
+
return fields.find(f => (f.Name ?? '').trim().toLowerCase() === target);
|
|
379
|
+
}
|
|
286
380
|
static { this._deletedEntitySchemaList = []; }
|
|
287
381
|
/**
|
|
288
382
|
* Schemas that lost an entity during this run.
|
|
@@ -939,29 +1033,63 @@ export class ManageMetadataBase {
|
|
|
939
1033
|
logStatus(` > Entities config: "${ec.SchemaName}.${ec.BaseTable}" has no attributes to set — skipping`);
|
|
940
1034
|
continue;
|
|
941
1035
|
}
|
|
942
|
-
// Look up the entity by BaseTable + SchemaName
|
|
1036
|
+
// Look up the entity by BaseTable + SchemaName, selecting current values for the named attributes
|
|
1037
|
+
const attrCols = attrs.map(([k]) => `${this.qi(k)}`).join(', ');
|
|
943
1038
|
const entityResult = await this.runQueryWithParams(pool, `
|
|
944
|
-
${this.selectTop(1,
|
|
1039
|
+
${this.selectTop(1, `ID, Name, ${attrCols}`, `FROM ${this.qs(schema, 'vwEntities')}
|
|
945
1040
|
WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName`)}
|
|
946
1041
|
`, { 'BaseTable': ec.BaseTable, 'SchemaName': ec.SchemaName });
|
|
947
1042
|
if (entityResult.recordset.length === 0) {
|
|
948
1043
|
logError(` > Entities config: entity for "${ec.SchemaName}.${ec.BaseTable}" not found — skipping`);
|
|
949
1044
|
continue;
|
|
950
1045
|
}
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const
|
|
1046
|
+
const currentRow = entityResult.recordset[0];
|
|
1047
|
+
const entityId = String(currentRow.ID);
|
|
1048
|
+
const entityName = String(currentRow.Name);
|
|
1049
|
+
// Compare each attribute against the current database value; only include differing attributes
|
|
1050
|
+
const differingAttrs = [];
|
|
956
1051
|
for (const [key, value] of attrs) {
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1052
|
+
const curVal = currentRow[key];
|
|
1053
|
+
let differs = false;
|
|
1054
|
+
let litVal = 'NULL';
|
|
1055
|
+
if (typeof value === 'boolean') {
|
|
1056
|
+
const curBool = curVal === true || curVal === 1 || curVal === '1';
|
|
1057
|
+
differs = curBool !== value;
|
|
1058
|
+
litVal = this.boolLit(value);
|
|
1059
|
+
}
|
|
1060
|
+
else if (typeof value === 'number') {
|
|
1061
|
+
const curNum = curVal === null || curVal === undefined ? null : Number(curVal);
|
|
1062
|
+
differs = curNum !== value;
|
|
1063
|
+
litVal = String(value);
|
|
1064
|
+
}
|
|
1065
|
+
else if (typeof value === 'string') {
|
|
1066
|
+
const curStr = curVal === null || curVal === undefined ? '' : String(curVal);
|
|
1067
|
+
differs = curStr !== value;
|
|
1068
|
+
litVal = `'${value.replace(/'/g, "''")}'`;
|
|
1069
|
+
}
|
|
1070
|
+
else if (value === null) {
|
|
1071
|
+
differs = curVal !== null && curVal !== undefined;
|
|
1072
|
+
litVal = 'NULL';
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
differs = JSON.stringify(curVal) !== JSON.stringify(value);
|
|
1076
|
+
litVal = `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
|
1077
|
+
}
|
|
1078
|
+
if (differs) {
|
|
1079
|
+
differingAttrs.push({ key, literalValue: litVal, summary: `${key}=${value}` });
|
|
1080
|
+
}
|
|
962
1081
|
}
|
|
963
|
-
|
|
964
|
-
|
|
1082
|
+
if (differingAttrs.length === 0) {
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
// Build an UPDATE with literal values and execute through LogSQLAndExecute for migration capture
|
|
1086
|
+
const setClauses = differingAttrs.map(a => `${this.qi(a.key)} = ${a.literalValue}`);
|
|
1087
|
+
setClauses.unshift(`${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}`);
|
|
1088
|
+
const updateSQL = `UPDATE ${this.qs(schema, 'Entity')}
|
|
1089
|
+
SET ${setClauses.join(', ')}
|
|
1090
|
+
WHERE ${this.qi('ID')} = '${entityId}'`;
|
|
1091
|
+
await this.LogSQLAndExecute(pool, updateSQL, `Update attributes on "${entityName}" from additionalSchemaInfo`);
|
|
1092
|
+
const attrSummary = differingAttrs.map(a => a.summary).join(', ');
|
|
965
1093
|
logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
|
|
966
1094
|
updatedCount++;
|
|
967
1095
|
}
|
|
@@ -1123,9 +1251,10 @@ export class ManageMetadataBase {
|
|
|
1123
1251
|
const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
|
|
1124
1252
|
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
|
|
1125
1253
|
// includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
|
|
1126
|
-
// migration file needs a GO between statements for Flyway/sqlcmd.
|
|
1127
|
-
|
|
1128
|
-
await this.LogSQLAndExecute(pool,
|
|
1254
|
+
// migration file needs a GO between statements for Flyway/sqlcmd. Pass the provider's
|
|
1255
|
+
// separator, not the 'GO' default: on PostgreSQL it is '' and a literal GO breaks replay.
|
|
1256
|
+
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
|
|
1257
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
|
|
1129
1258
|
// 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
|
|
1130
1259
|
const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
|
|
1131
1260
|
if (existing.recordset.length > 0) {
|
|
@@ -1383,8 +1512,8 @@ export class ManageMetadataBase {
|
|
|
1383
1512
|
}
|
|
1384
1513
|
const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
|
|
1385
1514
|
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
|
|
1386
|
-
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
|
|
1387
|
-
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true);
|
|
1515
|
+
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
|
|
1516
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
|
|
1388
1517
|
// 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
|
|
1389
1518
|
const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
|
|
1390
1519
|
let generatedEntityId = existingVE.recordset[0]?.ID;
|
|
@@ -1964,8 +2093,8 @@ export class ManageMetadataBase {
|
|
|
1964
2093
|
* INTEGRITY CHECK — in a well-formed entity every base (non-virtual) field sequences BEFORE the
|
|
1965
2094
|
* virtual/related fields, so the EntityField order matches the base view's `SELECT [base].*, <joins>`
|
|
1966
2095
|
* column output. The positional save-capture in the data providers (e.g. SQLServerDataProvider's
|
|
1967
|
-
* @ResultTable) relies on that alignment. CodeGen
|
|
1968
|
-
* `
|
|
2096
|
+
* @ResultTable) relies on that alignment. CodeGen inserts newly-discovered columns at an apply-time
|
|
2097
|
+
* `MAX(Sequence) + ordinal` placeholder that updateExistingEntityFieldsFromSchema is supposed to renumber; if
|
|
1969
2098
|
* a base column is left sequenced AFTER a virtual field, the save-capture would mis-route values by
|
|
1970
2099
|
* position. The providers now compensate (saves stay correct), but the metadata is still wrong — so we
|
|
1971
2100
|
* scan for it after every metadata pass and log a prominent warning to drive the root cause out over time.
|
|
@@ -1978,7 +2107,7 @@ export class ManageMetadataBase {
|
|
|
1978
2107
|
const sSQL = `SELECT ${this.qi('EntityID')}, ${this.qi('Entity')}, ${this.qi('Name')}, ${this.qi('Sequence')}, ${this.qi('IsVirtual')}
|
|
1979
2108
|
FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
|
|
1980
2109
|
${excl}
|
|
1981
|
-
ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}`;
|
|
2110
|
+
ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('Name')}`;
|
|
1982
2111
|
const result = await this.runQuery(pool, sSQL);
|
|
1983
2112
|
const rows = result.recordset;
|
|
1984
2113
|
const isV = (v) => v === true || v === 1 || v === '1';
|
|
@@ -2011,7 +2140,7 @@ export class ManageMetadataBase {
|
|
|
2011
2140
|
` Healthy entities sequence ALL base (non-virtual) fields before virtual/related fields, so EntityField\n` +
|
|
2012
2141
|
` order matches the base view ([base].* then joins) that the data-provider save-capture relies on. When a\n` +
|
|
2013
2142
|
` base column sequences after a virtual field, the capture re-orders defensively (saves stay correct), but\n` +
|
|
2014
|
-
` the metadata is wrong. Usual cause: a newly-added column's
|
|
2143
|
+
` the metadata is wrong. Usual cause: a newly-added column's apply-time MAX(Sequence)+ordinal placeholder was not\n` +
|
|
2015
2144
|
` renumbered. Fix the field's Sequence at the source so it sorts with the base columns.\n` +
|
|
2016
2145
|
` Affected (entity: base fields out of order):\n`;
|
|
2017
2146
|
for (const o of offenders)
|
|
@@ -2464,11 +2593,8 @@ export class ManageMetadataBase {
|
|
|
2464
2593
|
else {
|
|
2465
2594
|
// 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
|
|
2466
2595
|
newEntityFieldUUID = this.createNewUUID();
|
|
2467
|
-
//
|
|
2468
|
-
//
|
|
2469
|
-
// when nothing exists yet, but a partial prior run could have left rows at the same
|
|
2470
|
-
// ordinal under this EntityID — UQ_EntityField_EntityID_Sequence then fires on PG.
|
|
2471
|
-
const safeSequence = await this.nextAvailableEntityFieldSequence(pool, entity.ID, fieldSequence);
|
|
2596
|
+
// Apply-time Sequence: see applyTimeEntityFieldSequenceSQL. A literal here would be
|
|
2597
|
+
// replayed verbatim from the CodeGen capture and collide on a fresh install.
|
|
2472
2598
|
const q = (n) => this.qi(n);
|
|
2473
2599
|
const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
|
|
2474
2600
|
${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
|
|
@@ -2477,10 +2603,11 @@ export class ManageMetadataBase {
|
|
|
2477
2603
|
${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
|
|
2478
2604
|
VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
|
|
2479
2605
|
${veField.Length}, ${veField.Precision}, ${veField.Scale},
|
|
2480
|
-
${
|
|
2606
|
+
${this.applyTimeEntityFieldSequenceSQL(entity.ID)}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
|
|
2481
2607
|
${this.utcNow()}, ${this.utcNow()}
|
|
2482
2608
|
)`;
|
|
2483
2609
|
await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
|
|
2610
|
+
ManageMetadataBase.registerNewField(entity.ID, veField.FieldName);
|
|
2484
2611
|
didUpdate = true;
|
|
2485
2612
|
}
|
|
2486
2613
|
}
|
|
@@ -2502,14 +2629,16 @@ export class ManageMetadataBase {
|
|
|
2502
2629
|
if (virtualEntities.length === 0) {
|
|
2503
2630
|
return;
|
|
2504
2631
|
}
|
|
2505
|
-
// Pre-build available entities list once (shared across all virtual entity decorations)
|
|
2632
|
+
// Pre-build available entities list once (shared across all virtual entity decorations).
|
|
2633
|
+
// Only single-column keys are offered as FK targets: an MJ foreign key references exactly one
|
|
2634
|
+
// column (RelatedEntityFieldName), so a composite-key entity cannot be the target of a soft FK.
|
|
2506
2635
|
const availableEntities = md.Entities
|
|
2507
|
-
.filter(e => !e.VirtualEntity && e.PrimaryKeys.length
|
|
2636
|
+
.filter(e => !e.VirtualEntity && e.PrimaryKeys.length === 1)
|
|
2508
2637
|
.map(e => ({
|
|
2509
2638
|
Name: e.Name,
|
|
2510
2639
|
SchemaName: e.SchemaName,
|
|
2511
2640
|
BaseTable: e.BaseTable,
|
|
2512
|
-
PrimaryKeyField: e.
|
|
2641
|
+
PrimaryKeyField: e.FirstPrimaryKey.Name // first-pk-ok: filtered to PrimaryKeys.length === 1 above; this is the FK target column offered to the LLM
|
|
2513
2642
|
}));
|
|
2514
2643
|
logStatus(` Decorating virtual entity fields with LLM (${virtualEntities.length} entities)...`);
|
|
2515
2644
|
let decoratedCount = 0;
|
|
@@ -2641,7 +2770,7 @@ export class ManageMetadataBase {
|
|
|
2641
2770
|
// Load VE EntityField rows from DB (we need the ID and auto-update flags)
|
|
2642
2771
|
const schema = mj_core_schema();
|
|
2643
2772
|
const fieldsSQL = `
|
|
2644
|
-
SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, GeneratedFormSection, DisplayName, ExtendedType, CodeType
|
|
2773
|
+
SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, AutoUpdateExtendedType, GeneratedFormSection, DisplayName, ExtendedType, CodeType, ValueListType
|
|
2645
2774
|
FROM ${this.qs(schema, 'EntityField')}
|
|
2646
2775
|
WHERE EntityID = '${entity.ID}'
|
|
2647
2776
|
`;
|
|
@@ -2662,14 +2791,15 @@ export class ManageMetadataBase {
|
|
|
2662
2791
|
if (fieldCategories.length === 0)
|
|
2663
2792
|
return false;
|
|
2664
2793
|
const existingCategories = this.buildExistingCategorySet(dbFields);
|
|
2665
|
-
|
|
2794
|
+
const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
|
|
2795
|
+
await this.applyFieldCategories(pool, entity, dbFields, fieldCategories, existingCategories, { isNewEntity });
|
|
2666
2796
|
// Apply entity icon if provided
|
|
2667
2797
|
if (result.entityIcon) {
|
|
2668
|
-
await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
|
|
2798
|
+
await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
|
|
2669
2799
|
}
|
|
2670
2800
|
// Apply category info settings if provided
|
|
2671
2801
|
if (result.categoryInfo && Object.keys(result.categoryInfo).length > 0) {
|
|
2672
|
-
await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo);
|
|
2802
|
+
await this.applyCategoryInfoSettings(pool, entity.ID, result.categoryInfo, entity.Name);
|
|
2673
2803
|
}
|
|
2674
2804
|
logStatus(` Applied categories for VE ${entity.Name} (${fieldCategories.length} fields)`);
|
|
2675
2805
|
return true;
|
|
@@ -2769,9 +2899,12 @@ export class ManageMetadataBase {
|
|
|
2769
2899
|
continue;
|
|
2770
2900
|
}
|
|
2771
2901
|
const escapedDescription = fd.description.replace(/'/g, "''");
|
|
2772
|
-
let setClauses = `Description='${escapedDescription}'`;
|
|
2773
|
-
// Apply extended type if provided and
|
|
2774
|
-
|
|
2902
|
+
let setClauses = `Description='${escapedDescription}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)}`;
|
|
2903
|
+
// Apply extended type if provided, valid, and the field is not locked
|
|
2904
|
+
const isFieldNew = ManageMetadataBase.isFieldNew(entity.ID, field.Name);
|
|
2905
|
+
const isTypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, field.Name);
|
|
2906
|
+
const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
|
|
2907
|
+
if (fd.extendedType && field.AutoUpdateExtendedType && (isNewEntity || isFieldNew || isTypeReopened)) {
|
|
2775
2908
|
const validExtendedType = this.validateExtendedType(fd.extendedType);
|
|
2776
2909
|
if (validExtendedType) {
|
|
2777
2910
|
setClauses += `, ExtendedType='${validExtendedType}'`;
|
|
@@ -2788,12 +2921,9 @@ export class ManageMetadataBase {
|
|
|
2788
2921
|
return true;
|
|
2789
2922
|
}
|
|
2790
2923
|
/**
|
|
2791
|
-
* Valid values for EntityField.ExtendedType
|
|
2924
|
+
* Valid values for EntityField.ExtendedType. Domain lives on {@link EntityFieldInfo.ExtendedTypes}.
|
|
2792
2925
|
*/
|
|
2793
|
-
static { this.VALID_EXTENDED_TYPES = new Set(
|
|
2794
|
-
'Code', 'Email', 'FaceTime', 'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
|
|
2795
|
-
'GeoCity', 'GeoPostalCode', 'GeoAddress', 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg'
|
|
2796
|
-
]); }
|
|
2926
|
+
static { this.VALID_EXTENDED_TYPES = new Set(EntityFieldInfo.ExtendedTypes); }
|
|
2797
2927
|
static { this.EXTENDED_TYPE_ALIASES = {
|
|
2798
2928
|
'phone': 'Tel',
|
|
2799
2929
|
'telephone': 'Tel',
|
|
@@ -2823,6 +2953,17 @@ export class ManageMetadataBase {
|
|
|
2823
2953
|
'zoom': 'ZoomMtg',
|
|
2824
2954
|
'whatsapp': 'WhatsApp',
|
|
2825
2955
|
'skype': 'Skype',
|
|
2956
|
+
'image': 'Image',
|
|
2957
|
+
'photo': 'Image',
|
|
2958
|
+
'picture': 'Image',
|
|
2959
|
+
'logo': 'Image',
|
|
2960
|
+
'avatar': 'Image',
|
|
2961
|
+
'thumbnail': 'Image',
|
|
2962
|
+
'color': 'Color',
|
|
2963
|
+
'colour': 'Color',
|
|
2964
|
+
'hex': 'Color',
|
|
2965
|
+
'json': 'JSON',
|
|
2966
|
+
'jsonb': 'JSON',
|
|
2826
2967
|
}; }
|
|
2827
2968
|
/**
|
|
2828
2969
|
* Validates an LLM-suggested ExtendedType against the allowed values in EntityField.
|
|
@@ -2930,12 +3071,8 @@ export class ManageMetadataBase {
|
|
|
2930
3071
|
else {
|
|
2931
3072
|
// Create new virtual field record for this parent field
|
|
2932
3073
|
const newFieldID = this.createNewUUID();
|
|
2933
|
-
//
|
|
2934
|
-
//
|
|
2935
|
-
// (e.g. partial prior run left a record at the candidate ordinal under this
|
|
2936
|
-
// EntityID — UQ_EntityField_EntityID_Sequence then fires on PG).
|
|
2937
|
-
const candidate = 100000 + parentFields.indexOf(parentField);
|
|
2938
|
-
const sequence = await this.nextAvailableEntityFieldSequence(pool, childEntity.ID, candidate);
|
|
3074
|
+
// Apply-time Sequence (see applyTimeEntityFieldSequenceSQL); reordered by
|
|
3075
|
+
// updateExistingEntityFieldsFromSchema afterwards.
|
|
2939
3076
|
const q = (n) => this.qi(n);
|
|
2940
3077
|
const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
|
|
2941
3078
|
${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
|
|
@@ -2947,9 +3084,10 @@ export class ManageMetadataBase {
|
|
|
2947
3084
|
'${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
|
|
2948
3085
|
'${parentField.Type}', ${this.boolLit(parentField.AllowsNull)},
|
|
2949
3086
|
${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
|
|
2950
|
-
${
|
|
3087
|
+
${this.applyTimeEntityFieldSequenceSQL(childEntity.ID)}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
|
|
2951
3088
|
${this.utcNow()}, ${this.utcNow()})`;
|
|
2952
3089
|
await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
3090
|
+
ManageMetadataBase.registerNewField(childEntity.ID, parentField.Name);
|
|
2953
3091
|
bUpdated = true;
|
|
2954
3092
|
}
|
|
2955
3093
|
}
|
|
@@ -3022,7 +3160,7 @@ export class ManageMetadataBase {
|
|
|
3022
3160
|
RelatedEntityID IS NOT NULL AND
|
|
3023
3161
|
IsVirtual = ${this.boolLit(false)} AND
|
|
3024
3162
|
EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
|
|
3025
|
-
ORDER BY RelatedEntityID`;
|
|
3163
|
+
ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
|
|
3026
3164
|
const entityFieldsResult = await this.runQuery(pool, sSQL);
|
|
3027
3165
|
const entityFields = entityFieldsResult.recordset;
|
|
3028
3166
|
// Get the relationship counts for each entity
|
|
@@ -3200,7 +3338,7 @@ export class ManageMetadataBase {
|
|
|
3200
3338
|
RelatedEntityID IS NOT NULL AND
|
|
3201
3339
|
IsVirtual = ${this.boolLit(false)} AND
|
|
3202
3340
|
EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
|
|
3203
|
-
ORDER BY RelatedEntityID`;
|
|
3341
|
+
ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
|
|
3204
3342
|
const entityFieldsResult = await this.runQuery(pool, sSQL);
|
|
3205
3343
|
const entityFields = entityFieldsResult.recordset;
|
|
3206
3344
|
const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
|
|
@@ -3430,7 +3568,7 @@ export class ManageMetadataBase {
|
|
|
3430
3568
|
// AN: 14-June-2025 - See note below about the new order of these steps, this must
|
|
3431
3569
|
// happen before we update existing entity fields from schema.
|
|
3432
3570
|
const step2StartTime = new Date();
|
|
3433
|
-
if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs)) {
|
|
3571
|
+
if (!await this.createNewEntityFieldsFromSchema(pool, scopedEntityIDs, excludeSchemas)) {
|
|
3434
3572
|
logError('Error creating new entity fields from schema');
|
|
3435
3573
|
bSuccess = false;
|
|
3436
3574
|
}
|
|
@@ -3447,7 +3585,7 @@ export class ManageMetadataBase {
|
|
|
3447
3585
|
}
|
|
3448
3586
|
// AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
|
|
3449
3587
|
// which results in the same pattern of behavior as migrations where we first create new fields
|
|
3450
|
-
//
|
|
3588
|
+
// at an apply-time MAX(Sequence)+ordinal placeholder (above every existing row) and then
|
|
3451
3589
|
// we align them properly in sequential order from 1+ via this method below.
|
|
3452
3590
|
const step3StartTime = new Date();
|
|
3453
3591
|
if (!await this.updateExistingEntityFieldsFromSchema(pool, excludeSchemas, scopedEntityIDs)) {
|
|
@@ -3586,6 +3724,9 @@ export class ManageMetadataBase {
|
|
|
3586
3724
|
try {
|
|
3587
3725
|
logStatus(` Found ${configInfo.additionalSchemaInfo}, applying soft PK/FK configuration...`);
|
|
3588
3726
|
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
3727
|
+
if (!config) {
|
|
3728
|
+
return true;
|
|
3729
|
+
}
|
|
3589
3730
|
let totalPKs = 0;
|
|
3590
3731
|
let totalFKs = 0;
|
|
3591
3732
|
const schema = mj_core_schema();
|
|
@@ -3610,6 +3751,18 @@ export class ManageMetadataBase {
|
|
|
3610
3751
|
const primaryKeys = table.PrimaryKey || [];
|
|
3611
3752
|
if (primaryKeys.length > 0) {
|
|
3612
3753
|
for (const pk of primaryKeys) {
|
|
3754
|
+
const checkSQL = `SELECT ${this.qi('IsPrimaryKey')}, ${this.qi('IsSoftPrimaryKey')}
|
|
3755
|
+
FROM ${this.qs(schema, 'EntityField')}
|
|
3756
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${pk.FieldName}'`;
|
|
3757
|
+
const checkRes = await this.runQuery(pool, checkSQL);
|
|
3758
|
+
if (checkRes.recordset.length > 0) {
|
|
3759
|
+
const row = checkRes.recordset[0];
|
|
3760
|
+
const isPk = row.IsPrimaryKey === true || row.IsPrimaryKey === 1 || row.IsPrimaryKey === '1';
|
|
3761
|
+
const isSoftPk = row.IsSoftPrimaryKey === true || row.IsSoftPrimaryKey === 1 || row.IsSoftPrimaryKey === '1';
|
|
3762
|
+
if (isPk && isSoftPk) {
|
|
3763
|
+
continue; // Already correctly set, skip write
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3613
3766
|
const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
|
|
3614
3767
|
SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
|
|
3615
3768
|
${this.qi('IsPrimaryKey')} = ${this.boolLit(true)},
|
|
@@ -3635,11 +3788,29 @@ export class ManageMetadataBase {
|
|
|
3635
3788
|
continue;
|
|
3636
3789
|
}
|
|
3637
3790
|
const relatedEntityId = relatedEntityResult.recordset[0].ID;
|
|
3791
|
+
const checkSQL = `SELECT ${this.qi('RelatedEntityID')}, ${this.qi('RelatedEntityFieldName')}, ${this.qi('IsSoftForeignKey')}, ${this.qi('AutoUpdateRelatedEntityInfo')}
|
|
3792
|
+
FROM ${this.qs(schema, 'EntityField')}
|
|
3793
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
|
|
3794
|
+
const checkRes = await this.runQuery(pool, checkSQL);
|
|
3795
|
+
if (checkRes.recordset.length > 0) {
|
|
3796
|
+
const row = checkRes.recordset[0];
|
|
3797
|
+
const curRelId = String(row.RelatedEntityID ?? '').trim().toLowerCase();
|
|
3798
|
+
const curRelField = String(row.RelatedEntityFieldName ?? '').trim().toLowerCase();
|
|
3799
|
+
const curSoftFk = row.IsSoftForeignKey === true || row.IsSoftForeignKey === 1 || row.IsSoftForeignKey === '1';
|
|
3800
|
+
const curAutoUpdate = row.AutoUpdateRelatedEntityInfo === true || row.AutoUpdateRelatedEntityInfo === 1 || row.AutoUpdateRelatedEntityInfo === '1';
|
|
3801
|
+
if (curRelId === String(relatedEntityId).trim().toLowerCase() &&
|
|
3802
|
+
curRelField === String(fk.RelatedField).trim().toLowerCase() &&
|
|
3803
|
+
curSoftFk &&
|
|
3804
|
+
!curAutoUpdate) {
|
|
3805
|
+
continue; // Already correctly set, skip write
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3638
3808
|
const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
|
|
3639
3809
|
SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
|
|
3640
3810
|
${this.qi('RelatedEntityID')} = '${relatedEntityId}',
|
|
3641
3811
|
${this.qi('RelatedEntityFieldName')} = '${fk.RelatedField}',
|
|
3642
|
-
${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)}
|
|
3812
|
+
${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)},
|
|
3813
|
+
${this.qi('AutoUpdateRelatedEntityInfo')} = ${this.boolLit(false)}
|
|
3643
3814
|
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
|
|
3644
3815
|
const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
|
|
3645
3816
|
if (result !== null) {
|
|
@@ -3672,7 +3843,7 @@ export class ManageMetadataBase {
|
|
|
3672
3843
|
return true;
|
|
3673
3844
|
try {
|
|
3674
3845
|
// Load all existing EntityFieldValue rows for dedup checking
|
|
3675
|
-
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
|
|
3846
|
+
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
|
|
3676
3847
|
const allEFVResult = await this.runQuery(pool, efvSQL);
|
|
3677
3848
|
const allEntityFieldValues = allEFVResult.recordset;
|
|
3678
3849
|
let totalApplied = 0;
|
|
@@ -3899,7 +4070,8 @@ export class ManageMetadataBase {
|
|
|
3899
4070
|
async dropExistingDefaultConstraint(pool, entity, fieldName) {
|
|
3900
4071
|
try {
|
|
3901
4072
|
const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
|
|
3902
|
-
|
|
4073
|
+
// DECLARE-bearing block: close its batch in the replayable log (see SQLLogging.appendToSQLLogFile).
|
|
4074
|
+
await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`, false, true, this.dbProvider.BatchSeparator);
|
|
3903
4075
|
}
|
|
3904
4076
|
catch (e) {
|
|
3905
4077
|
logError(e);
|
|
@@ -3931,7 +4103,7 @@ export class ManageMetadataBase {
|
|
|
3931
4103
|
// Use new API to generate entity description
|
|
3932
4104
|
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);
|
|
3933
4105
|
if (result?.entityDescription && result.entityDescription.length > 0) {
|
|
3934
|
-
const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET Description = '${esc(result.entityDescription)}' WHERE Name = '${esc(e)}'`;
|
|
4106
|
+
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)}'`;
|
|
3935
4107
|
await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
|
|
3936
4108
|
}
|
|
3937
4109
|
else {
|
|
@@ -4015,9 +4187,9 @@ export class ManageMetadataBase {
|
|
|
4015
4187
|
*
|
|
4016
4188
|
* @returns {string} - The SQL statement to retrieve pending entity fields.
|
|
4017
4189
|
*/
|
|
4018
|
-
getPendingEntityFieldsSELECTSQL(entityIDs) {
|
|
4190
|
+
getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas) {
|
|
4019
4191
|
const schema = mj_core_schema();
|
|
4020
|
-
return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs);
|
|
4192
|
+
return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs, excludeSchemas);
|
|
4021
4193
|
}
|
|
4022
4194
|
/**
|
|
4023
4195
|
* This method builds a SQL Statement that will insert a row into the EntityField table with information about a new field.
|
|
@@ -4030,7 +4202,11 @@ export class ManageMetadataBase {
|
|
|
4030
4202
|
const isPrimaryKey = n.FieldName?.trim().toLowerCase() === 'id';
|
|
4031
4203
|
const isForeignKey = n.RelatedEntityID && n.RelatedEntityID.length > 0; // Foreign keys have RelatedEntityID set
|
|
4032
4204
|
const isNameField = n.FieldName?.trim().toLowerCase() === 'name' || n.IsNameField;
|
|
4033
|
-
|
|
4205
|
+
// The field's position in the schema (SourceOrdinal / column_id). `n.Sequence` is NOT that:
|
|
4206
|
+
// the pending-fields SELECT computes it as MAX+100000+ordinal, so comparing it against
|
|
4207
|
+
// IncludeFirstNFieldsAsDefaultInView could never be true and the setting was dead.
|
|
4208
|
+
const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
|
|
4209
|
+
const isEarlySequence = sourceOrdinal <= configInfo.newEntityDefaults?.IncludeFirstNFieldsAsDefaultInView;
|
|
4034
4210
|
const bDefaultInView = (isNameField || isEarlySequence) && !isPrimaryKey && !isForeignKey;
|
|
4035
4211
|
const escapedDescription = n.Description ? `'${n.Description.replace(/'/g, "''")}'` : 'NULL';
|
|
4036
4212
|
let fieldDisplayName = '';
|
|
@@ -4057,17 +4233,10 @@ export class ManageMetadataBase {
|
|
|
4057
4233
|
: (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
|
|
4058
4234
|
const conflictCheck = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
|
|
4059
4235
|
const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
|
|
4060
|
-
// Sequence is
|
|
4061
|
-
//
|
|
4062
|
-
//
|
|
4063
|
-
|
|
4064
|
-
// batch, manageEntityFields calls spUpdateExistingEntityFieldsFromSchema
|
|
4065
|
-
// which rewrites EVERY field on the entity from the live view — including
|
|
4066
|
-
// parked rows. That proc must run AFTER views are current (CodeGen Pass 2,
|
|
4067
|
-
// after SQL generation). Pass 1 still emits this SQL against whatever the
|
|
4068
|
-
// view is at that moment; Pass 2 is the one that matches the finished BaseView.
|
|
4069
|
-
const sourceOrdinal = typeof n.SourceOrdinal === 'number' && n.SourceOrdinal > 0 ? n.SourceOrdinal : 1;
|
|
4070
|
-
const sequenceExpr = String(sourceOrdinal);
|
|
4236
|
+
// Sequence is an apply-time expression, never a literal: see applyTimeEntityFieldSequenceSQL.
|
|
4237
|
+
// `n.Sequence` (MAX+100000+ordinal from the pending SELECT) is only that query's ORDER BY key
|
|
4238
|
+
// and must never be written; `sourceOrdinal` above feeds DefaultInView only.
|
|
4239
|
+
const sequenceExpr = this.applyTimeEntityFieldSequenceSQL(n.EntityID);
|
|
4071
4240
|
return `
|
|
4072
4241
|
${guard.prefix}
|
|
4073
4242
|
INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')}
|
|
@@ -4121,7 +4290,7 @@ export class ManageMetadataBase {
|
|
|
4121
4290
|
${n.RelatedEntityID && n.RelatedEntityID.length > 0 ? `'${n.RelatedEntityID}'` : 'NULL'},
|
|
4122
4291
|
${n.RelatedEntityFieldName && n.RelatedEntityFieldName.length > 0 ? `'${n.RelatedEntityFieldName}'` : 'NULL'},
|
|
4123
4292
|
${this.boolLit(n.IsNameField !== null ? n.IsNameField : false)},
|
|
4124
|
-
${this.boolLit(n.
|
|
4293
|
+
${this.boolLit(Boolean(n.IsNameField))},
|
|
4125
4294
|
${this.boolLit(n.RelatedEntityID && n.RelatedEntityID.length > 0)},
|
|
4126
4295
|
${this.boolLit(bDefaultInView)},
|
|
4127
4296
|
${this.boolLit(n.IsPrimaryKey)},
|
|
@@ -4141,45 +4310,20 @@ export class ManageMetadataBase {
|
|
|
4141
4310
|
* @param sqlDefaultValue
|
|
4142
4311
|
* @returns
|
|
4143
4312
|
*/
|
|
4144
|
-
/**
|
|
4145
|
-
* Park existing EntityField.Sequence values out of the 1..N catalog range so a
|
|
4146
|
-
* following INSERT can use the real BaseView column_id without colliding on
|
|
4147
|
-
* UQ_EntityField_EntityID_Sequence. The +100000 band is unique-safe; the
|
|
4148
|
-
* subsequent spUpdateExistingEntityFieldsFromSchema rewrite brings every row
|
|
4149
|
-
* (parked and new) back to live catalog order.
|
|
4150
|
-
*
|
|
4151
|
-
* Parks ONLY when nothing on the entity is parked yet, which is what makes a second
|
|
4152
|
-
* emission for the same entity in the same run a no-op. `Sequence < 100000` alone does
|
|
4153
|
-
* not achieve that: after the first park the rows below the band are precisely the ones
|
|
4154
|
-
* the first pass just INSERTED at their catalog ordinals, so a second park lifts THOSE
|
|
4155
|
-
* into the band — onto the row the first park moved from the same ordinal, and the
|
|
4156
|
-
* migration dies on UQ_EntityField_EntityID_Sequence with a duplicate at 100000+ordinal.
|
|
4157
|
-
* Reachable whenever an entity gains fields in both CodeGen passes: pass 1 for the real
|
|
4158
|
-
* columns, pass 2 for the denormalized name column a new foreign key introduces.
|
|
4159
|
-
*/
|
|
4160
|
-
parkEntityFieldSequencesSQL(entityID) {
|
|
4161
|
-
const table = this.qs(mj_core_schema(), 'EntityField');
|
|
4162
|
-
return `UPDATE ${table}
|
|
4163
|
-
SET ${this.qi('Sequence')} = ${this.qi('Sequence')} + 100000
|
|
4164
|
-
WHERE ${this.qi('EntityID')} = '${entityID}'
|
|
4165
|
-
AND ${this.qi('Sequence')} < 100000
|
|
4166
|
-
AND NOT EXISTS (
|
|
4167
|
-
SELECT 1 FROM ${table}
|
|
4168
|
-
WHERE ${this.qi('EntityID')} = '${entityID}'
|
|
4169
|
-
AND ${this.qi('Sequence')} >= 100000
|
|
4170
|
-
);`;
|
|
4171
|
-
}
|
|
4172
4313
|
parseDefaultValue(sqlDefaultValue) {
|
|
4173
4314
|
if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
|
|
4174
4315
|
return null;
|
|
4175
4316
|
}
|
|
4176
4317
|
return this.dbProvider.parseColumnDefaultValue(sqlDefaultValue) ?? null;
|
|
4177
4318
|
}
|
|
4178
|
-
async createNewEntityFieldsFromSchema(pool, entityIDs) {
|
|
4319
|
+
async createNewEntityFieldsFromSchema(pool, entityIDs, excludeSchemas) {
|
|
4179
4320
|
try {
|
|
4180
4321
|
// entityIDs flows down to the provider's WHERE clause so the inline SELECT
|
|
4181
|
-
// narrows to changed entities only when scoped.
|
|
4182
|
-
|
|
4322
|
+
// narrows to changed entities only when scoped. excludeSchemas is the compiled
|
|
4323
|
+
// includeSchemas scope (out-of-allow-list schemas). Without it, Pass 1 inserts
|
|
4324
|
+
// pending fields for every schema in the database (Forms CodeGen emitted Common
|
|
4325
|
+
// Activity Files EntityField rows).
|
|
4326
|
+
const sSQL = this.getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas);
|
|
4183
4327
|
const newEntityFieldsResult = await this.runQuery(pool, sSQL);
|
|
4184
4328
|
const newEntityFields = newEntityFieldsResult.recordset;
|
|
4185
4329
|
if (newEntityFields.length > 0) {
|
|
@@ -4197,18 +4341,14 @@ export class ManageMetadataBase {
|
|
|
4197
4341
|
// Batch size is configurable via `metadataInsertBatchSize` (default 250).
|
|
4198
4342
|
const CHUNK_SIZE = configInfo.metadataInsertBatchSize ?? 250;
|
|
4199
4343
|
const inserts = [];
|
|
4200
|
-
const parkedEntityIDs = new Set();
|
|
4201
4344
|
for (let i = 0; i < newEntityFields.length; ++i) {
|
|
4202
4345
|
const n = newEntityFields[i];
|
|
4203
4346
|
if (n.EntityID !== null && n.EntityID !== undefined && n.EntityID.length > 0) {
|
|
4204
4347
|
// 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
|
|
4205
4348
|
// that would have been created violate rules - such as not having an ID column, etc.
|
|
4206
|
-
if (!parkedEntityIDs.has(n.EntityID)) {
|
|
4207
|
-
inserts.push(this.parkEntityFieldSequencesSQL(n.EntityID));
|
|
4208
|
-
parkedEntityIDs.add(n.EntityID);
|
|
4209
|
-
}
|
|
4210
4349
|
const newEntityFieldUUID = this.createNewUUID();
|
|
4211
4350
|
inserts.push(this.getPendingEntityFieldINSERTSQL(newEntityFieldUUID, n));
|
|
4351
|
+
ManageMetadataBase.registerNewField(n.EntityID, n.FieldName);
|
|
4212
4352
|
}
|
|
4213
4353
|
}
|
|
4214
4354
|
for (let i = 0; i < inserts.length; i += CHUNK_SIZE) {
|
|
@@ -4304,11 +4444,20 @@ export class ManageMetadataBase {
|
|
|
4304
4444
|
const label = isScoped
|
|
4305
4445
|
? `SQL text to update existing entity fields from schema (${entityIDs.length} scoped entities)`
|
|
4306
4446
|
: `SQL text to update existing entity fields from schema`;
|
|
4307
|
-
const
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4447
|
+
const before = await this.snapshotEntityFields(pool, entityIDs);
|
|
4448
|
+
await this.LogSQLAndExecute(pool, sSQL, label, true);
|
|
4449
|
+
const after = await this.snapshotEntityFields(pool, entityIDs);
|
|
4450
|
+
const changes = diffEntityFieldSnapshots(before, after, (e, n) => ManageMetadataBase.isFieldNew(e, n));
|
|
4451
|
+
if (changes.length > 0) {
|
|
4452
|
+
ManageMetadataBase.addNewEntitiesToModifiedList(changes.map(c => c.entityName));
|
|
4453
|
+
for (const c of changes) {
|
|
4454
|
+
ManageMetadataBase.registerFieldChange(c.entityID, c.fieldName, c.reasons);
|
|
4455
|
+
ManageMetadataBase._changedFieldReport.push({
|
|
4456
|
+
entityName: c.entityName,
|
|
4457
|
+
fieldName: c.fieldName,
|
|
4458
|
+
reasons: [...c.reasons],
|
|
4459
|
+
});
|
|
4460
|
+
}
|
|
4312
4461
|
}
|
|
4313
4462
|
return true;
|
|
4314
4463
|
}
|
|
@@ -4461,13 +4610,22 @@ export class ManageMetadataBase {
|
|
|
4461
4610
|
const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
|
|
4462
4611
|
const resultResult = await this.runQuery(pool, sSQL);
|
|
4463
4612
|
const result = resultResult.recordset;
|
|
4464
|
-
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence`;
|
|
4613
|
+
const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
|
|
4465
4614
|
const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
|
|
4466
4615
|
const allEntityFieldValues = allEntityFieldValuesResult.recordset;
|
|
4467
|
-
const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence`;
|
|
4616
|
+
const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence, Name, ID`;
|
|
4468
4617
|
const allEntityFieldsResult = await this.runQuery(pool, efSQL);
|
|
4469
4618
|
const allEntityFields = allEntityFieldsResult.recordset;
|
|
4470
4619
|
const generationPromises = [];
|
|
4620
|
+
const ag = new AdvancedGeneration();
|
|
4621
|
+
// `skipDBUpdate` means load-only: `runValidationGeneration` is called below with
|
|
4622
|
+
// `generateNewCode = false`, and `generateValidatorFunctionFromCheckConstraint` only reaches an
|
|
4623
|
+
// LLM when that flag is true. Reading a validator back out of an Approved `GeneratedCode` record
|
|
4624
|
+
// is therefore a plain database read — gating it on the AI feature flag is what made
|
|
4625
|
+
// `mj codegen --no-ai` DELETE every committed `Validate()` override rather than preserve it, and
|
|
4626
|
+
// the `codegen-drift` gate (which runs `--no-ai`) then demanded that lossy output. Generation
|
|
4627
|
+
// stays gated; only the read is unconditional.
|
|
4628
|
+
const emitValidators = skipDBUpdate || ag.featureEnabled('ParseCheckConstraints');
|
|
4471
4629
|
const columnLevelResults = result.filter((r) => r.EntityFieldID); // get the column level constraints
|
|
4472
4630
|
const tableLevelResults = result.filter((r) => !r.EntityFieldID); // get the table level constraints
|
|
4473
4631
|
for (const r of columnLevelResults) {
|
|
@@ -4501,20 +4659,20 @@ export class ManageMetadataBase {
|
|
|
4501
4659
|
else {
|
|
4502
4660
|
// 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
|
|
4503
4661
|
// attempt to use an LLM to do things fancier now
|
|
4504
|
-
if (
|
|
4505
|
-
|
|
4506
|
-
// the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
|
|
4662
|
+
if (emitValidators) {
|
|
4663
|
+
// either we are loading persisted validators, or the feature is on and we may generate new ones
|
|
4507
4664
|
// run this in parallel
|
|
4508
4665
|
generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
|
|
4509
4666
|
}
|
|
4510
4667
|
}
|
|
4511
4668
|
}
|
|
4512
4669
|
}
|
|
4513
|
-
// now for the table level constraints
|
|
4670
|
+
// now for the table level constraints, build a Validate function for each constraint.
|
|
4671
|
+
// As above: no featureEnabled() gate here. Loading previously-generated code is not an AI operation, and the
|
|
4672
|
+
// ParseCheckConstraints gate that does guard the LLM call lives inside generateValidatorFunctionFromCheckConstraint.
|
|
4514
4673
|
for (const r of tableLevelResults) {
|
|
4515
|
-
if (
|
|
4516
|
-
|
|
4517
|
-
// the user has the feature turned on, let's generate a description of the constraint and then build a Validate function for the constraint
|
|
4674
|
+
if (emitValidators) {
|
|
4675
|
+
// either we are loading persisted validators, or the feature is on and we may generate new ones
|
|
4518
4676
|
// run this in parallel
|
|
4519
4677
|
generationPromises.push(this.runValidationGeneration(r, allEntityFields, !skipDBUpdate, currentUser));
|
|
4520
4678
|
}
|
|
@@ -4585,9 +4743,9 @@ export class ManageMetadataBase {
|
|
|
4585
4743
|
}
|
|
4586
4744
|
}
|
|
4587
4745
|
try {
|
|
4588
|
-
|
|
4746
|
+
const ag = new AdvancedGeneration();
|
|
4747
|
+
if (generateNewCode && ag.featureEnabled('ParseCheckConstraints')) {
|
|
4589
4748
|
// feature is enabled, so let's call the AI to generate a function for us
|
|
4590
|
-
const ag = new AdvancedGeneration();
|
|
4591
4749
|
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');
|
|
4592
4750
|
// Use new API to parse check constraint
|
|
4593
4751
|
const result = await ag.parseCheckConstraint(constraintDefinition, entityFieldListInfo, generatedValidationFunctionName, currentUser);
|
|
@@ -4806,6 +4964,10 @@ export class ManageMetadataBase {
|
|
|
4806
4964
|
if (newEntities && newEntities.length > 0) {
|
|
4807
4965
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
4808
4966
|
const transaction = await pool.beginTransaction();
|
|
4967
|
+
// The in-run list of new entity names is process-static. Names pushed by a batch that is then
|
|
4968
|
+
// rolled back must not survive into the next run of the same process (in-process CodeGen runs
|
|
4969
|
+
// many times per process), or every one of them reads as "taken" and gets a schema suffix.
|
|
4970
|
+
const newEntityNamesBefore = ManageMetadataBase.newEntityList.length;
|
|
4809
4971
|
try {
|
|
4810
4972
|
// wrap in a transaction so we get all of it or none of it
|
|
4811
4973
|
for (let i = 0; i < newEntities.length; ++i) {
|
|
@@ -4816,6 +4978,7 @@ export class ManageMetadataBase {
|
|
|
4816
4978
|
}
|
|
4817
4979
|
catch (e) {
|
|
4818
4980
|
await transaction.rollback();
|
|
4981
|
+
ManageMetadataBase.newEntityList.length = newEntityNamesBefore;
|
|
4819
4982
|
throw e;
|
|
4820
4983
|
}
|
|
4821
4984
|
if (ManageMetadataBase.newEntityList.length > 0) {
|
|
@@ -4924,11 +5087,13 @@ export class ManageMetadataBase {
|
|
|
4924
5087
|
}
|
|
4925
5088
|
async newEntityNameWithAdvancedGeneration(ag, newEntity, currentUser) {
|
|
4926
5089
|
const result = await ag.generateEntityName(newEntity.TableName, currentUser);
|
|
4927
|
-
|
|
5090
|
+
// Checked here as well as inside generateEntityName: a subclass or a stub can return anything, and
|
|
5091
|
+
// a non-name that reaches the INSERT fails it (see createNewEntity's catch for why that used to be silent).
|
|
5092
|
+
if (result && isPlausibleEntityName(result.entityName)) {
|
|
4928
5093
|
return this.markupEntityName(newEntity.SchemaName, result.entityName);
|
|
4929
5094
|
}
|
|
4930
5095
|
else {
|
|
4931
|
-
console.warn(
|
|
5096
|
+
console.warn(` >>> Advanced Generation Error: LLM returned an unusable entity name for ${newEntity.SchemaName}.${newEntity.TableName}, falling back to simple generated entity name`);
|
|
4932
5097
|
return this.simpleNewEntityName(newEntity.SchemaName, newEntity.TableName);
|
|
4933
5098
|
}
|
|
4934
5099
|
}
|
|
@@ -5102,9 +5267,7 @@ export class ManageMetadataBase {
|
|
|
5102
5267
|
for (const p of permissions) {
|
|
5103
5268
|
const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
|
|
5104
5269
|
if (RoleID) {
|
|
5105
|
-
const sSQLInsertPermission =
|
|
5106
|
-
(${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
|
|
5107
|
-
('${newEntityID}', '${RoleID}', ${this.boolLit(p.CanRead)}, ${this.boolLit(p.CanCreate)}, ${this.boolLit(p.CanUpdate)}, ${this.boolLit(p.CanDelete)}, ${this.utcNow()}, ${this.utcNow()})`;
|
|
5270
|
+
const sSQLInsertPermission = this.buildEntityPermissionInsertSQL(newEntityID, RoleID, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete);
|
|
5108
5271
|
await this.LogSQLAndExecute(pool, sSQLInsertPermission, `SQL generated to add new permission for entity ${newEntityName} for role ${p.RoleName}`);
|
|
5109
5272
|
}
|
|
5110
5273
|
else
|
|
@@ -5125,6 +5288,15 @@ export class ManageMetadataBase {
|
|
|
5125
5288
|
if (errStack) {
|
|
5126
5289
|
LogError(` Stack trace: ${errStack}`);
|
|
5127
5290
|
}
|
|
5291
|
+
// Rethrow so createNewEntities stops, rolls back, and the run fails. Swallowing here let CodeGen
|
|
5292
|
+
// commit a PARTIAL set of new entities and report success: the table whose INSERT failed was simply
|
|
5293
|
+
// absent afterwards, with a log line as the only witness. It has bitten twice — a UQ_Entity_Name
|
|
5294
|
+
// collision (see resolveUniqueEntityName) and an AI-generated name of `-1` that dropped eleven of a
|
|
5295
|
+
// connector's twenty-seven tables while the schema update reported "complete". The transaction in
|
|
5296
|
+
// createNewEntities exists to give "all of it or none of it"; it can only do that if a failure
|
|
5297
|
+
// reaches it. A table that does not QUALIFY (no primary key) is still a skip, not a failure — that
|
|
5298
|
+
// path returns above and never gets here.
|
|
5299
|
+
throw e instanceof Error ? e : new Error(errMsg);
|
|
5128
5300
|
}
|
|
5129
5301
|
}
|
|
5130
5302
|
async isSchemaNew(pool, schemaName) {
|
|
@@ -5275,6 +5447,35 @@ export class ManageMetadataBase {
|
|
|
5275
5447
|
LogError(` >>>> WARNING: No application found for schema ${schemaName} to add entity ${entityName}`);
|
|
5276
5448
|
}
|
|
5277
5449
|
}
|
|
5450
|
+
/**
|
|
5451
|
+
* INSERT for one `EntityPermission` row, skipped when a row already exists for that
|
|
5452
|
+
* (EntityID, RoleID, Type).
|
|
5453
|
+
*
|
|
5454
|
+
* The guard is not defensive tidiness — `EntityPermission` carries a UNIQUE constraint on those
|
|
5455
|
+
* three columns (`UQ_EntityPermission_EntityID_RoleID_Type`), so an unguarded INSERT is a failed
|
|
5456
|
+
* CodeGen run rather than a duplicate row. All three call sites are "grant the configured default
|
|
5457
|
+
* permissions", which is naturally re-entrant: an entity re-detected as new, or a second CodeGen
|
|
5458
|
+
* pass over the same entity, reaches them again. Before the constraint existed this silently
|
|
5459
|
+
* accumulated duplicates — a live database showed one (entity, role) pair with rows created three
|
|
5460
|
+
* years apart, and pairs whose verb flags disagreed.
|
|
5461
|
+
*
|
|
5462
|
+
* `Type` is written explicitly rather than left to the column default so the row being inserted
|
|
5463
|
+
* and the row being tested for are keyed identically; a default that changed later would
|
|
5464
|
+
* otherwise put them out of step. Expressed as INSERT ... SELECT ... WHERE NOT EXISTS, which is
|
|
5465
|
+
* valid on both SQL Server and PostgreSQL, so no provider branch is needed.
|
|
5466
|
+
*/
|
|
5467
|
+
buildEntityPermissionInsertSQL(entityId, roleId, canRead, canCreate, canUpdate, canDelete) {
|
|
5468
|
+
const table = this.qs(mj_core_schema(), 'EntityPermission');
|
|
5469
|
+
const entityLit = this.uuidLit(entityId);
|
|
5470
|
+
const roleLit = this.uuidLit(roleId);
|
|
5471
|
+
return `INSERT INTO ${table}
|
|
5472
|
+
(${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('Type')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')})
|
|
5473
|
+
SELECT ${entityLit}, ${roleLit}, 'Allow', ${this.boolLit(canRead)}, ${this.boolLit(canCreate)}, ${this.boolLit(canUpdate)}, ${this.boolLit(canDelete)}, ${this.utcNow()}, ${this.utcNow()}
|
|
5474
|
+
WHERE NOT EXISTS (
|
|
5475
|
+
SELECT 1 FROM ${table}
|
|
5476
|
+
WHERE ${this.qi('EntityID')} = ${entityLit} AND ${this.qi('RoleID')} = ${roleLit} AND ${this.qi('Type')} = 'Allow'
|
|
5477
|
+
)`;
|
|
5478
|
+
}
|
|
5278
5479
|
/**
|
|
5279
5480
|
* Adds default permissions for a newly created entity based on config settings.
|
|
5280
5481
|
* Shared by both table-backed entity creation and virtual entity creation.
|
|
@@ -5288,9 +5489,7 @@ export class ManageMetadataBase {
|
|
|
5288
5489
|
for (const p of permissions) {
|
|
5289
5490
|
const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
|
|
5290
5491
|
if (RoleID) {
|
|
5291
|
-
const sSQLInsert =
|
|
5292
|
-
(${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
|
|
5293
|
-
('${entityId}', '${RoleID}', ${this.boolLit(p.CanRead)}, ${this.boolLit(p.CanCreate)}, ${this.boolLit(p.CanUpdate)}, ${this.boolLit(p.CanDelete)}, ${this.utcNow()}, ${this.utcNow()})`;
|
|
5492
|
+
const sSQLInsert = this.buildEntityPermissionInsertSQL(entityId, RoleID, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete);
|
|
5294
5493
|
await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add permission for entity ${entityName} for role ${p.RoleName}`);
|
|
5295
5494
|
}
|
|
5296
5495
|
else {
|
|
@@ -5452,9 +5651,7 @@ export class ManageMetadataBase {
|
|
|
5452
5651
|
logStatus(` > Materialized entity "${entityName}": role "${p.RoleName}" NOT granted read (it cannot read every source entity; the snapshot has no row-level scoping).`);
|
|
5453
5652
|
continue;
|
|
5454
5653
|
}
|
|
5455
|
-
const sSQLInsert =
|
|
5456
|
-
(${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
|
|
5457
|
-
('${entityId}', '${roleId}', ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.utcNow()}, ${this.utcNow()})`;
|
|
5654
|
+
const sSQLInsert = this.buildEntityPermissionInsertSQL(entityId, roleId, true, false, false, false);
|
|
5458
5655
|
await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add read permission for materialized entity ${entityName} for role ${p.RoleName}`);
|
|
5459
5656
|
}
|
|
5460
5657
|
}
|
|
@@ -5486,7 +5683,10 @@ export class ManageMetadataBase {
|
|
|
5486
5683
|
return ManageMetadataBase.EntityHasRoleReadRLS(entity);
|
|
5487
5684
|
}
|
|
5488
5685
|
/** Pure form of the role-RLS layer (see {@link entityHasRowLevelSecurity}). IO-free so it can be reused
|
|
5489
|
-
* verbatim by the runtime refresher's equivalent gate.
|
|
5686
|
+
* verbatim by the runtime refresher's equivalent gate. Deliberately WIDER than the runtime's own reader
|
|
5687
|
+
* (`EntityInfo.GetUserRowLevelSecurityInfo` collects a filter only from an Allow row whose `CanRead` is
|
|
5688
|
+
* set, since #4358): a leftover filter beside a cleared flag still counts as "protected" here, which errs
|
|
5689
|
+
* conservative for a leak gate. */
|
|
5490
5690
|
static EntityHasRoleReadRLS(entity) {
|
|
5491
5691
|
return entity.Permissions.some((p) => !!p.ReadRLSFilterID && p.ReadRLSFilterID.trim().length > 0);
|
|
5492
5692
|
}
|
|
@@ -5775,7 +5975,8 @@ export class ManageMetadataBase {
|
|
|
5775
5975
|
WHERE
|
|
5776
5976
|
${whereClause}
|
|
5777
5977
|
ORDER BY
|
|
5778
|
-
e.Name
|
|
5978
|
+
e.Name,
|
|
5979
|
+
e.ID
|
|
5779
5980
|
`;
|
|
5780
5981
|
const entitiesResult = await this.runQuery(pool, entitiesSQL);
|
|
5781
5982
|
const entities = entitiesResult.recordset;
|
|
@@ -5801,11 +6002,16 @@ export class ManageMetadataBase {
|
|
|
5801
6002
|
ef.AutoUpdateIncludeInUserSearchAPI,
|
|
5802
6003
|
ef.AutoUpdateCategory,
|
|
5803
6004
|
ef.AutoUpdateDisplayName,
|
|
6005
|
+
ef.AutoUpdateExtendedType,
|
|
6006
|
+
ef.ExtendedType,
|
|
6007
|
+
ef.CodeType,
|
|
6008
|
+
ef.GeneratedFormSection,
|
|
5804
6009
|
ef.EntityIDFieldName,
|
|
5805
6010
|
ef.RelatedEntity,
|
|
5806
6011
|
ef.IsVirtual,
|
|
5807
6012
|
ef.AllowUpdateAPI,
|
|
5808
6013
|
ef.IsNameField,
|
|
6014
|
+
ef.ValueListType,
|
|
5809
6015
|
ef.DefaultInView,
|
|
5810
6016
|
ef.IncludeInUserSearchAPI,
|
|
5811
6017
|
ef.UserSearchPredicateAPI,
|
|
@@ -5819,7 +6025,9 @@ export class ManageMetadataBase {
|
|
|
5819
6025
|
ef.EntityID IN (${entityIds})
|
|
5820
6026
|
ORDER BY
|
|
5821
6027
|
ef.EntityID,
|
|
5822
|
-
ef.Sequence
|
|
6028
|
+
ef.Sequence,
|
|
6029
|
+
ef.Name,
|
|
6030
|
+
ef.ID
|
|
5823
6031
|
`;
|
|
5824
6032
|
const fieldsResult = await this.runQuery(pool, fieldsSQL);
|
|
5825
6033
|
const allFields = fieldsResult.recordset;
|
|
@@ -5834,6 +6042,9 @@ export class ManageMetadataBase {
|
|
|
5834
6042
|
WHERE
|
|
5835
6043
|
es.EntityID IN (${entityIds})
|
|
5836
6044
|
AND es.Name = 'FieldCategoryInfo'
|
|
6045
|
+
ORDER BY
|
|
6046
|
+
es.EntityID,
|
|
6047
|
+
es.Name
|
|
5837
6048
|
`;
|
|
5838
6049
|
const settingsResult = await this.runQuery(pool, settingsSQL);
|
|
5839
6050
|
const allSettings = settingsResult.recordset;
|
|
@@ -5931,16 +6142,26 @@ export class ManageMetadataBase {
|
|
|
5931
6142
|
// Fields for this entity — O(1) lookup into the pre-grouped map (keyed on the normalized
|
|
5932
6143
|
// EntityID) instead of a linear scan of the full pooled array.
|
|
5933
6144
|
const fields = fieldsByEntity.get(String(entity.ID ?? '').trim().toLowerCase()) ?? [];
|
|
6145
|
+
// Decorate fields with change state from field tracking (C1/C3)
|
|
6146
|
+
for (const f of fields) {
|
|
6147
|
+
f.IsNew = ManageMetadataBase.isFieldNew(entity.ID, f.Name);
|
|
6148
|
+
f.DescriptionReopened = ManageMetadataBase.isDisplayNameReopened(entity.ID, f.Name);
|
|
6149
|
+
f.TypeReopened = ManageMetadataBase.isTypeReopened(entity.ID, f.Name);
|
|
6150
|
+
}
|
|
6151
|
+
const newFieldNames = new Set(fields.filter((f) => f.IsNew).map((f) => String(f.Name)));
|
|
6152
|
+
const typeReopenedNames = new Set(fields.filter((f) => f.TypeReopened).map((f) => String(f.Name)));
|
|
6153
|
+
const hasNewFields = newFieldNames.size > 0;
|
|
6154
|
+
const hasTypeReopened = typeReopenedNames.size > 0;
|
|
6155
|
+
const hasDescriptionReopened = fields.some((f) => f.DescriptionReopened);
|
|
5934
6156
|
// Determine if this is a new entity (for DefaultForNewUser decision)
|
|
5935
6157
|
const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
|
|
5936
|
-
// Smart Field Identification
|
|
5937
|
-
|
|
5938
|
-
// or if entity-level search/FTS auto-update flags are set
|
|
5939
|
-
const needsFieldAnalysis = fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
|
|
6158
|
+
// Smart Field Identification — FM4. Runs only when there is something new to decide about.
|
|
6159
|
+
const needsFieldAnalysis = (isNewEntity || hasNewFields || hasTypeReopened) && fields.some((f) => f.AutoUpdateIsNameField || f.AutoUpdateDefaultInView || f.AutoUpdateIncludeInUserSearchAPI || f.AutoUpdateUserSearchPredicate || f.AutoUpdateFullTextSearch);
|
|
5940
6160
|
// entity-level auto-update flags come from the raw SQL query, not EntityInfo
|
|
5941
6161
|
const entityRecord = entity;
|
|
5942
|
-
const needsEntitySearchConfig = entityRecord.AutoUpdateAllowUserSearchAPI || entityRecord.AutoUpdateFullTextSearch;
|
|
6162
|
+
const needsEntitySearchConfig = isNewEntity && (Boolean(entityRecord.AutoUpdateAllowUserSearchAPI) || Boolean(entityRecord.AutoUpdateFullTextSearch));
|
|
5943
6163
|
if (needsFieldAnalysis || needsEntitySearchConfig) {
|
|
6164
|
+
CodeGenReporter.Instance.counter('ai.smartFieldCalls');
|
|
5944
6165
|
const fieldAnalysis = await ag.identifyFields({
|
|
5945
6166
|
Name: entity.Name,
|
|
5946
6167
|
Description: entity.Description,
|
|
@@ -5955,13 +6176,17 @@ export class ManageMetadataBase {
|
|
|
5955
6176
|
AutoUpdateAllowUserSearchAPI: entityRecord.AutoUpdateAllowUserSearchAPI,
|
|
5956
6177
|
FullTextSearchEnabled: entityRecord.FullTextSearchEnabled,
|
|
5957
6178
|
AutoUpdateFullTextSearch: entityRecord.AutoUpdateFullTextSearch,
|
|
5958
|
-
}, fields, fieldAnalysis);
|
|
6179
|
+
}, fields, fieldAnalysis, { isNewEntity, newFieldNames, typeReopenedNames });
|
|
5959
6180
|
}
|
|
5960
6181
|
}
|
|
5961
6182
|
// Form Layout Generation
|
|
5962
|
-
//
|
|
5963
|
-
|
|
6183
|
+
// Fires only on change events (new entity, new fields, reopened description/type) AND when there is work to do
|
|
6184
|
+
// (blank categories with AutoUpdateCategory=1, or reopened fields to review). Gated on events so omitted blank fields don't loop (§3.4).
|
|
6185
|
+
const hasCategoryNeeded = fields.some((f) => Boolean(f.AutoUpdateCategory) && (!f.Category || String(f.Category).trim() === ''));
|
|
6186
|
+
const needsCategoryGeneration = (isNewEntity || hasNewFields || hasDescriptionReopened || hasTypeReopened)
|
|
6187
|
+
&& (hasCategoryNeeded || hasDescriptionReopened || hasTypeReopened);
|
|
5964
6188
|
if (needsCategoryGeneration) {
|
|
6189
|
+
CodeGenReporter.Instance.counter('ai.formLayoutCalls');
|
|
5965
6190
|
// Build IS-A parent chain context if this entity has a parent
|
|
5966
6191
|
const parentChainContext = this.buildParentChainContext(entity, fields);
|
|
5967
6192
|
const layoutAnalysis = await ag.generateFormLayout({
|
|
@@ -6046,19 +6271,21 @@ export class ManageMetadataBase {
|
|
|
6046
6271
|
/**
|
|
6047
6272
|
* Apply smart field identification results to entity fields and entity-level search configuration
|
|
6048
6273
|
*/
|
|
6049
|
-
async applySmartFieldIdentification(pool, entity, fields, result) {
|
|
6274
|
+
async applySmartFieldIdentification(pool, entity, fields, result, ctx) {
|
|
6050
6275
|
// Run the guardrail pipeline once up front so every downstream applier
|
|
6051
6276
|
// sees the same already-normalized result. See search-guardrails.ts for
|
|
6052
6277
|
// the rules. Mutates result in place — see normalizeSearchFlagsInPlace.
|
|
6053
6278
|
this.normalizeSearchFlagsInPlace(entity, fields, result);
|
|
6054
6279
|
const sqlStatements = [];
|
|
6055
|
-
this.applyNameFieldUpdates(sqlStatements, fields, result);
|
|
6056
|
-
this.applyDefaultInViewUpdates(sqlStatements, fields, result);
|
|
6057
|
-
this.applySearchableFieldUpdates(sqlStatements, fields, result, entity);
|
|
6058
|
-
this.applySearchPredicateUpdates(sqlStatements, fields, result);
|
|
6059
|
-
|
|
6280
|
+
this.applyNameFieldUpdates(sqlStatements, fields, result, entity);
|
|
6281
|
+
this.applyDefaultInViewUpdates(sqlStatements, fields, result, entity, ctx);
|
|
6282
|
+
this.applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx);
|
|
6283
|
+
this.applySearchPredicateUpdates(sqlStatements, fields, result, entity, ctx);
|
|
6284
|
+
if (!ctx || ctx.isNewEntity) {
|
|
6285
|
+
this.applyEntitySearchConfig(sqlStatements, entity, result);
|
|
6286
|
+
}
|
|
6060
6287
|
if (configInfo.advancedGeneration?.allowFullTextSearchAutoUpdate) {
|
|
6061
|
-
this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result);
|
|
6288
|
+
this.applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx);
|
|
6062
6289
|
}
|
|
6063
6290
|
// Execute all updates in one batch
|
|
6064
6291
|
if (sqlStatements.length > 0) {
|
|
@@ -6197,11 +6424,11 @@ export class ManageMetadataBase {
|
|
|
6197
6424
|
* `AutoUpdateIsNameField=0` are never touched in either direction — the user owns
|
|
6198
6425
|
* them, and `EntityInfo.NameField`'s literal-`Name` preference arbitrates at runtime.
|
|
6199
6426
|
*/
|
|
6200
|
-
applyNameFieldUpdates(sqlStatements, fields, result) {
|
|
6427
|
+
applyNameFieldUpdates(sqlStatements, fields, result, entity) {
|
|
6201
6428
|
const nameFieldNames = result.nameFields ?? [];
|
|
6202
6429
|
// Surface LLM candidates that don't exist on the entity (same diagnostics as before).
|
|
6203
6430
|
for (const nfName of nameFieldNames) {
|
|
6204
|
-
if (!
|
|
6431
|
+
if (!this.findFieldByName(fields, nfName)) {
|
|
6205
6432
|
logError(`Smart field identification returned invalid nameField: '${nfName}' not found in entity fields`);
|
|
6206
6433
|
}
|
|
6207
6434
|
}
|
|
@@ -6247,7 +6474,7 @@ export class ManageMetadataBase {
|
|
|
6247
6474
|
return this.preferLiteralNameField(eligibleFlagged);
|
|
6248
6475
|
}
|
|
6249
6476
|
for (const candidateName of rankedCandidates) {
|
|
6250
|
-
const candidate =
|
|
6477
|
+
const candidate = this.findFieldByName(fields, candidateName);
|
|
6251
6478
|
if (candidate && this.isFieldEligibleForNameField(candidate)) {
|
|
6252
6479
|
return candidate;
|
|
6253
6480
|
}
|
|
@@ -6355,12 +6582,15 @@ export class ManageMetadataBase {
|
|
|
6355
6582
|
/**
|
|
6356
6583
|
* Generate SQL UPDATEs for DefaultInView on the identified default view fields
|
|
6357
6584
|
*/
|
|
6358
|
-
applyDefaultInViewUpdates(sqlStatements, fields, result) {
|
|
6585
|
+
applyDefaultInViewUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
|
|
6586
|
+
const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
|
|
6587
|
+
const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
|
|
6588
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6359
6589
|
// Guard against a malformed LLM result that omits the array entirely.
|
|
6360
6590
|
// Mirrors the defensive handling in the sibling apply* methods.
|
|
6361
6591
|
const defaultInView = result.defaultInView ?? [];
|
|
6362
|
-
const defaultInViewFields = fields.filter(f => defaultInView.
|
|
6363
|
-
const missingFields = defaultInView.filter(name => !
|
|
6592
|
+
const defaultInViewFields = fields.filter(f => defaultInView.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateDefaultInView && f.ID && inScope(f));
|
|
6593
|
+
const missingFields = defaultInView.filter(name => !this.findFieldByName(fields, name));
|
|
6364
6594
|
if (missingFields.length > 0) {
|
|
6365
6595
|
logError(`Smart field identification returned invalid defaultInView fields: ${missingFields.join(', ')} not found in entity`);
|
|
6366
6596
|
}
|
|
@@ -6386,12 +6616,13 @@ export class ManageMetadataBase {
|
|
|
6386
6616
|
* always-wrong cases — they cause the data provider to emit unindexed scans on
|
|
6387
6617
|
* every search.
|
|
6388
6618
|
*/
|
|
6389
|
-
applySearchableFieldUpdates(sqlStatements, fields, result, entity) {
|
|
6619
|
+
applySearchableFieldUpdates(sqlStatements, fields, result, entity, ctx) {
|
|
6620
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6390
6621
|
if (!result.searchableFields || result.searchableFields.length === 0) {
|
|
6391
6622
|
return;
|
|
6392
6623
|
}
|
|
6393
|
-
const searchableFields = fields.filter(f => result.searchableFields.
|
|
6394
|
-
const missingSearchableFields = result.searchableFields.filter(name => !
|
|
6624
|
+
const searchableFields = fields.filter(f => result.searchableFields.some(n => n.toLowerCase() === String(f.Name ?? '').toLowerCase()) && f.AutoUpdateIncludeInUserSearchAPI && f.ID && inScope(f));
|
|
6625
|
+
const missingSearchableFields = result.searchableFields.filter(name => !this.findFieldByName(fields, name));
|
|
6395
6626
|
if (missingSearchableFields.length > 0) {
|
|
6396
6627
|
logError(`Smart field identification returned invalid searchableFields: ${missingSearchableFields.join(', ')} not found in entity`);
|
|
6397
6628
|
}
|
|
@@ -6426,7 +6657,7 @@ export class ManageMetadataBase {
|
|
|
6426
6657
|
if (!textTypes.has(type))
|
|
6427
6658
|
return false;
|
|
6428
6659
|
// Unbounded text on a non-FTX entity cannot be index-seeked.
|
|
6429
|
-
const length = field.Length;
|
|
6660
|
+
const length = (field.Length ?? field.MaxLength);
|
|
6430
6661
|
if (length === -1 && !ftxEnabled)
|
|
6431
6662
|
return false;
|
|
6432
6663
|
return true;
|
|
@@ -6434,17 +6665,20 @@ export class ManageMetadataBase {
|
|
|
6434
6665
|
/**
|
|
6435
6666
|
* Generate SQL UPDATEs for UserSearchPredicateAPI on each searchable field
|
|
6436
6667
|
*/
|
|
6437
|
-
applySearchPredicateUpdates(sqlStatements, fields, result) {
|
|
6668
|
+
applySearchPredicateUpdates(sqlStatements, fields, result, entityOrCtx, maybeCtx) {
|
|
6669
|
+
const entity = (entityOrCtx && 'Name' in entityOrCtx) ? entityOrCtx : undefined;
|
|
6670
|
+
const ctx = (entityOrCtx && 'newFieldNames' in entityOrCtx) ? entityOrCtx : maybeCtx;
|
|
6671
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6438
6672
|
if (!result.searchPredicates || result.searchPredicates.length === 0) {
|
|
6439
6673
|
return;
|
|
6440
6674
|
}
|
|
6441
6675
|
for (const sp of result.searchPredicates) {
|
|
6442
|
-
const field =
|
|
6676
|
+
const field = this.findFieldByName(fields, sp.field);
|
|
6443
6677
|
if (!field) {
|
|
6444
6678
|
logError(`Smart field identification returned invalid searchPredicate field: '${sp.field}' not found in entity fields`);
|
|
6445
6679
|
continue;
|
|
6446
6680
|
}
|
|
6447
|
-
if (!field.AutoUpdateUserSearchPredicate || !field.ID) {
|
|
6681
|
+
if (!field.AutoUpdateUserSearchPredicate || !field.ID || !inScope(field)) {
|
|
6448
6682
|
continue;
|
|
6449
6683
|
}
|
|
6450
6684
|
// Only update if the current value differs from the recommended predicate
|
|
@@ -6532,9 +6766,10 @@ export class ManageMetadataBase {
|
|
|
6532
6766
|
/**
|
|
6533
6767
|
* Generate SQL UPDATEs for entity-level and field-level FullTextSearch configuration
|
|
6534
6768
|
*/
|
|
6535
|
-
applyFullTextSearchUpdates(sqlStatements, entity, fields, result) {
|
|
6536
|
-
|
|
6537
|
-
|
|
6769
|
+
applyFullTextSearchUpdates(sqlStatements, entity, fields, result, ctx) {
|
|
6770
|
+
const inScope = (f) => !ctx || ctx.isNewEntity || ctx.newFieldNames.has(String(f.Name)) || ctx.typeReopenedNames.has(String(f.Name));
|
|
6771
|
+
// Entity-level FullTextSearchEnabled (only for new entities)
|
|
6772
|
+
if ((!ctx || ctx.isNewEntity) && result.enableFullTextSearch != null && entity.AutoUpdateFullTextSearch) {
|
|
6538
6773
|
const newValue = result.enableFullTextSearch;
|
|
6539
6774
|
const currentValue = !!entity.FullTextSearchEnabled;
|
|
6540
6775
|
if (newValue !== currentValue) {
|
|
@@ -6551,12 +6786,12 @@ export class ManageMetadataBase {
|
|
|
6551
6786
|
return;
|
|
6552
6787
|
}
|
|
6553
6788
|
for (const ftsFieldName of result.fullTextSearchFields) {
|
|
6554
|
-
const field =
|
|
6789
|
+
const field = this.findFieldByName(fields, ftsFieldName);
|
|
6555
6790
|
if (!field) {
|
|
6556
6791
|
logError(`Smart field identification returned invalid fullTextSearchField: '${ftsFieldName}' not found in entity fields`);
|
|
6557
6792
|
continue;
|
|
6558
6793
|
}
|
|
6559
|
-
if (!field.AutoUpdateFullTextSearch || !field.ID) {
|
|
6794
|
+
if (!field.AutoUpdateFullTextSearch || !field.ID || !inScope(field)) {
|
|
6560
6795
|
continue;
|
|
6561
6796
|
}
|
|
6562
6797
|
if (!field.FullTextSearchEnabled) {
|
|
@@ -6583,29 +6818,29 @@ export class ManageMetadataBase {
|
|
|
6583
6818
|
// Defensive: a malformed LLM result can leave fieldCategories undefined or
|
|
6584
6819
|
// non-array; both consumers below iterate it, so coerce to a safe array.
|
|
6585
6820
|
const fieldCategories = Array.isArray(result.fieldCategories) ? result.fieldCategories : [];
|
|
6586
|
-
await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories);
|
|
6821
|
+
await this.applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, { isNewEntity });
|
|
6587
6822
|
// Auto-detect geo-capable entities and set SupportsGeoCoding
|
|
6588
|
-
await this.detectAndSetGeoCodingSupport(pool, entity
|
|
6823
|
+
await this.detectAndSetGeoCodingSupport(pool, entity);
|
|
6589
6824
|
if (result.entityIcon) {
|
|
6590
|
-
await this.applyEntityIcon(pool, entity.ID, result.entityIcon);
|
|
6825
|
+
await this.applyEntityIcon(pool, entity.ID, result.entityIcon, entity.Name);
|
|
6591
6826
|
}
|
|
6592
6827
|
// Resolve categoryInfo from new or legacy format
|
|
6593
6828
|
const categoryInfoToStore = result.categoryInfo ||
|
|
6594
6829
|
(result.categoryIcons ?
|
|
6595
6830
|
Object.fromEntries(Object.entries(result.categoryIcons).map(([cat, icon]) => [cat, { icon, description: '' }])) : null);
|
|
6596
6831
|
if (categoryInfoToStore) {
|
|
6597
|
-
await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore);
|
|
6832
|
+
await this.applyCategoryInfoSettings(pool, entity.ID, categoryInfoToStore, entity.Name);
|
|
6598
6833
|
}
|
|
6599
6834
|
if (isNewEntity && result.entityImportance) {
|
|
6600
|
-
await this.applyEntityImportance(pool, entity.ID, result.entityImportance);
|
|
6835
|
+
await this.applyEntityImportance(pool, entity.ID, result.entityImportance, entity.Name);
|
|
6601
6836
|
}
|
|
6602
6837
|
}
|
|
6603
6838
|
/**
|
|
6604
|
-
* Detect if an entity has geo-capable fields based on
|
|
6839
|
+
* Detect if an entity has geo-capable fields based on persisted ExtendedType values in the database.
|
|
6605
6840
|
* If any field has a Geo* ExtendedType, set Entity.SupportsGeoCoding = 1.
|
|
6606
6841
|
* Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
|
|
6607
6842
|
*/
|
|
6608
|
-
async detectAndSetGeoCodingSupport(pool, entity
|
|
6843
|
+
async detectAndSetGeoCodingSupport(pool, entity) {
|
|
6609
6844
|
const schema = mj_core_schema();
|
|
6610
6845
|
// Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
|
|
6611
6846
|
const autoUpdateResult = await pool.query(`
|
|
@@ -6617,14 +6852,8 @@ export class ManageMetadataBase {
|
|
|
6617
6852
|
if (!row || !row.AutoUpdateSupportsGeoCoding) {
|
|
6618
6853
|
return; // Flag is locked, don't modify
|
|
6619
6854
|
}
|
|
6620
|
-
//
|
|
6621
|
-
|
|
6622
|
-
'Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
|
|
6623
|
-
'GeoCity', 'GeoPostalCode', 'GeoAddress'
|
|
6624
|
-
]);
|
|
6625
|
-
const hasGeoFields = fieldCategories.some(fc => fc.extendedType != null && geoExtendedTypes.has(fc.extendedType));
|
|
6626
|
-
// Also check existing fields in the database for Geo* ExtendedTypes
|
|
6627
|
-
// (in case the LLM didn't re-assign them this run but they were previously set)
|
|
6855
|
+
// Check existing fields in the database for Geo* ExtendedTypes
|
|
6856
|
+
// (relies on persisted state after applyFieldCategories has run)
|
|
6628
6857
|
const existingGeoResult = await pool.query(`
|
|
6629
6858
|
SELECT COUNT(*) AS ${this.qi('GeoFieldCount')}
|
|
6630
6859
|
FROM ${this.qs(schema, 'EntityField')}
|
|
@@ -6632,7 +6861,7 @@ export class ManageMetadataBase {
|
|
|
6632
6861
|
AND ${this.qi('ExtendedType')} IN ('Geo', 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince', 'GeoCity', 'GeoPostalCode', 'GeoAddress')
|
|
6633
6862
|
`);
|
|
6634
6863
|
const existingGeoCount = existingGeoResult?.recordset?.[0]?.GeoFieldCount ?? 0;
|
|
6635
|
-
const shouldSupportGeo =
|
|
6864
|
+
const shouldSupportGeo = existingGeoCount > 0;
|
|
6636
6865
|
const currentValue = row.SupportsGeoCoding ? true : false;
|
|
6637
6866
|
if (shouldSupportGeo !== currentValue) {
|
|
6638
6867
|
await this.LogSQLAndExecute(pool, `
|
|
@@ -6640,7 +6869,7 @@ export class ManageMetadataBase {
|
|
|
6640
6869
|
SET ${this.qi('SupportsGeoCoding')} = ${this.boolLit(shouldSupportGeo)}
|
|
6641
6870
|
WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = ${this.boolLit(true)}
|
|
6642
6871
|
`, `Set SupportsGeoCoding = ${shouldSupportGeo} for ${entity.Name}`);
|
|
6643
|
-
logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from
|
|
6872
|
+
logStatus(` Entity ${entity.Name}: SupportsGeoCoding = ${shouldSupportGeo ? 1 : 0} (auto-detected from persisted geo fields)`);
|
|
6644
6873
|
// Queue for late-phase view regeneration — the view was already generated
|
|
6645
6874
|
// before this flag was set, so it needs to be regenerated with the geo JOIN
|
|
6646
6875
|
ManageMetadataBase.AddEntityRequiringViewRegen(entity.Name, 'Geocoding');
|
|
@@ -6687,55 +6916,67 @@ export class ManageMetadataBase {
|
|
|
6687
6916
|
* Enforces stability rules: fields with existing categories cannot move to NEW categories.
|
|
6688
6917
|
* All SQL updates are batched into a single execution for performance.
|
|
6689
6918
|
*/
|
|
6690
|
-
async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories) {
|
|
6919
|
+
async applyFieldCategories(pool, entity, fields, fieldCategories, existingCategories, ctx) {
|
|
6691
6920
|
const sqlStatements = [];
|
|
6921
|
+
const reporter = CodeGenReporter.Instance;
|
|
6922
|
+
// Count fields with blank Category that the LLM omitted (FM2 re-trigger loop tracking)
|
|
6923
|
+
const proposedNames = new Set(fieldCategories.map(fc => (fc.fieldName ?? '').trim().toLowerCase()));
|
|
6924
|
+
let omittedBlankCount = 0;
|
|
6925
|
+
for (const field of fields) {
|
|
6926
|
+
const isBlankCategory = !field.Category || field.Category.trim() === '';
|
|
6927
|
+
if (isBlankCategory && field.AutoUpdateCategory && !proposedNames.has(field.Name.trim().toLowerCase())) {
|
|
6928
|
+
omittedBlankCount++;
|
|
6929
|
+
}
|
|
6930
|
+
}
|
|
6931
|
+
if (omittedBlankCount > 0) {
|
|
6932
|
+
reporter.counter('ai.fieldsOmittedByLLM', omittedBlankCount);
|
|
6933
|
+
}
|
|
6692
6934
|
for (const fieldCategory of fieldCategories) {
|
|
6693
|
-
const field =
|
|
6694
|
-
if (field
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
setClauses.push(`Category = '${category.replace(/'/g, "''")}'`);
|
|
6710
|
-
}
|
|
6711
|
-
if (field.GeneratedFormSection !== 'Category') {
|
|
6712
|
-
setClauses.push(`GeneratedFormSection = 'Category'`);
|
|
6713
|
-
}
|
|
6714
|
-
if (fieldCategory.displayName && field.AutoUpdateDisplayName && field.DisplayName !== fieldCategory.displayName) {
|
|
6715
|
-
setClauses.push(`DisplayName = '${fieldCategory.displayName.replace(/'/g, "''")}'`);
|
|
6716
|
-
}
|
|
6717
|
-
if (fieldCategory.extendedType !== undefined && field.ExtendedType !== fieldCategory.extendedType) {
|
|
6718
|
-
const extendedType = fieldCategory.extendedType === null ? 'NULL' : `'${String(fieldCategory.extendedType).replace(/'/g, "''")}'`;
|
|
6719
|
-
setClauses.push(`ExtendedType = ${extendedType}`);
|
|
6935
|
+
const field = this.findFieldByName(fields, fieldCategory.fieldName);
|
|
6936
|
+
if (!field) {
|
|
6937
|
+
logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
|
|
6938
|
+
continue;
|
|
6939
|
+
}
|
|
6940
|
+
const lockCtx = {
|
|
6941
|
+
isNewEntity: ctx.isNewEntity,
|
|
6942
|
+
isNewField: ManageMetadataBase.isFieldNew(entity.ID, field.Name),
|
|
6943
|
+
descriptionReopened: ManageMetadataBase.isDisplayNameReopened(entity.ID, field.Name),
|
|
6944
|
+
typeReopened: ManageMetadataBase.isTypeReopened(entity.ID, field.Name),
|
|
6945
|
+
existingCategories,
|
|
6946
|
+
};
|
|
6947
|
+
const update = computeFieldMetadataUpdate(field, fieldCategory, lockCtx, this.validateExtendedType.bind(this), (val, fn) => this.sanitizeCodeType(val, fn ?? field.Name, entity.Name));
|
|
6948
|
+
for (const skip of update.skipped) {
|
|
6949
|
+
if (skip.reason === 'locked') {
|
|
6950
|
+
reporter.counter('ai.fieldsLocked');
|
|
6720
6951
|
}
|
|
6721
|
-
if (
|
|
6722
|
-
|
|
6723
|
-
if (field.CodeType !== sanitized) {
|
|
6724
|
-
const codeType = sanitized == null ? 'NULL' : `'${sanitized.replace(/'/g, "''")}'`;
|
|
6725
|
-
setClauses.push(`CodeType = ${codeType}`);
|
|
6726
|
-
}
|
|
6952
|
+
else if (skip.reason === 'invalid') {
|
|
6953
|
+
reporter.counter('ai.fieldsInvalidProposal');
|
|
6727
6954
|
}
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6955
|
+
}
|
|
6956
|
+
const setClauses = [];
|
|
6957
|
+
if (update.Category !== undefined) {
|
|
6958
|
+
setClauses.push(`Category = '${update.Category.replace(/'/g, "''")}'`);
|
|
6959
|
+
}
|
|
6960
|
+
if (update.GeneratedFormSection !== undefined) {
|
|
6961
|
+
setClauses.push(`GeneratedFormSection = 'Category'`);
|
|
6962
|
+
}
|
|
6963
|
+
if (update.DisplayName !== undefined) {
|
|
6964
|
+
setClauses.push(`DisplayName = '${update.DisplayName.replace(/'/g, "''")}'`);
|
|
6965
|
+
}
|
|
6966
|
+
if (update.ExtendedType !== undefined) {
|
|
6967
|
+
const extVal = update.ExtendedType === null ? 'NULL' : `'${update.ExtendedType.replace(/'/g, "''")}'`;
|
|
6968
|
+
setClauses.push(`ExtendedType = ${extVal}`);
|
|
6969
|
+
}
|
|
6970
|
+
if (update.CodeType !== undefined) {
|
|
6971
|
+
const codeVal = update.CodeType === null ? 'NULL' : `'${update.CodeType.replace(/'/g, "''")}'`;
|
|
6972
|
+
setClauses.push(`CodeType = ${codeVal}`);
|
|
6973
|
+
}
|
|
6974
|
+
if (setClauses.length > 0) {
|
|
6975
|
+
sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
|
|
6731
6976
|
SET
|
|
6732
6977
|
${setClauses.join(',\n ')}
|
|
6733
6978
|
WHERE
|
|
6734
|
-
ID = '${field.ID}'
|
|
6735
|
-
}
|
|
6736
|
-
}
|
|
6737
|
-
else if (!field) {
|
|
6738
|
-
logError(`Form layout returned invalid fieldName: '${fieldCategory.fieldName}' not found in entity`);
|
|
6979
|
+
ID = '${field.ID}'`);
|
|
6739
6980
|
}
|
|
6740
6981
|
}
|
|
6741
6982
|
if (sqlStatements.length > 0) {
|
|
@@ -6750,7 +6991,7 @@ WHERE
|
|
|
6750
6991
|
/**
|
|
6751
6992
|
* Sets the entity icon if the entity doesn't already have one.
|
|
6752
6993
|
*/
|
|
6753
|
-
async applyEntityIcon(pool, entityId, entityIcon) {
|
|
6994
|
+
async applyEntityIcon(pool, entityId, entityIcon, entityName) {
|
|
6754
6995
|
if (!entityIcon || entityIcon.trim().length === 0)
|
|
6755
6996
|
return;
|
|
6756
6997
|
const checkSQL = `SELECT Icon FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ID = '${entityId}'`;
|
|
@@ -6777,32 +7018,46 @@ WHERE
|
|
|
6777
7018
|
/**
|
|
6778
7019
|
* Upserts FieldCategoryInfo (new format) and FieldCategoryIcons (legacy format) in EntitySetting.
|
|
6779
7020
|
*/
|
|
6780
|
-
async applyCategoryInfoSettings(pool, entityId, categoryInfo) {
|
|
7021
|
+
async applyCategoryInfoSettings(pool, entityId, categoryInfo, _entityName) {
|
|
6781
7022
|
if (!categoryInfo || Object.keys(categoryInfo).length === 0)
|
|
6782
7023
|
return;
|
|
6783
|
-
const
|
|
7024
|
+
const canonicalInfo = canonicalJSONStringify(categoryInfo, 2);
|
|
7025
|
+
const infoJSON = canonicalInfo.replace(/'/g, "''");
|
|
7026
|
+
const ENTITY_SETTING_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
6784
7027
|
// Upsert FieldCategoryInfo (new format)
|
|
6785
|
-
const checkNewSQL = `SELECT ${this.qi('ID')} FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
7028
|
+
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'`;
|
|
6786
7029
|
const existingNew = await this.runQuery(pool, checkNewSQL);
|
|
6787
7030
|
if (existingNew.recordset.length > 0) {
|
|
7031
|
+
let isMatch = false;
|
|
6788
7032
|
try {
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
|
|
6793
|
-
`, `Update FieldCategoryInfo setting for entity`, false);
|
|
7033
|
+
const rawVal = existingNew.recordset[0].Value;
|
|
7034
|
+
const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
|
|
7035
|
+
isMatch = deepEqualJSON(parsed, categoryInfo);
|
|
6794
7036
|
}
|
|
6795
|
-
catch
|
|
6796
|
-
|
|
7037
|
+
catch {
|
|
7038
|
+
isMatch = false;
|
|
7039
|
+
}
|
|
7040
|
+
if (!isMatch) {
|
|
7041
|
+
try {
|
|
7042
|
+
await this.LogSQLAndExecute(pool, `
|
|
7043
|
+
UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
|
|
7044
|
+
SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7045
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
|
|
7046
|
+
`, `Update FieldCategoryInfo setting for entity`, false);
|
|
7047
|
+
}
|
|
7048
|
+
catch (ex) {
|
|
7049
|
+
logError('Error Applying Category Info Settings: Part 1', ex);
|
|
7050
|
+
}
|
|
6797
7051
|
}
|
|
6798
7052
|
}
|
|
6799
7053
|
else {
|
|
6800
|
-
const newId =
|
|
7054
|
+
const newId = uuidv5(`${entityId}|FieldCategoryInfo`, ENTITY_SETTING_NAMESPACE);
|
|
6801
7055
|
try {
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
|
|
6805
|
-
|
|
7056
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
7057
|
+
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)})
|
|
7058
|
+
VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
7059
|
+
const sSQL = this.conditionalInsert(checkQuery, insertSQL);
|
|
7060
|
+
await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryInfo setting for entity`, false);
|
|
6806
7061
|
}
|
|
6807
7062
|
catch (ex) {
|
|
6808
7063
|
logError('Error Applying Category Info Settings: Part 2', ex);
|
|
@@ -6815,28 +7070,41 @@ WHERE
|
|
|
6815
7070
|
iconsOnly[category] = info.icon;
|
|
6816
7071
|
}
|
|
6817
7072
|
}
|
|
6818
|
-
const
|
|
6819
|
-
const
|
|
7073
|
+
const canonicalIcons = canonicalJSONStringify(iconsOnly, 2);
|
|
7074
|
+
const iconsJSON = canonicalIcons.replace(/'/g, "''");
|
|
7075
|
+
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'`;
|
|
6820
7076
|
const existingLegacy = await this.runQuery(pool, checkLegacySQL);
|
|
6821
7077
|
if (existingLegacy.recordset.length > 0) {
|
|
7078
|
+
let isMatch = false;
|
|
6822
7079
|
try {
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
|
|
6827
|
-
`, `Update FieldCategoryIcons setting (legacy)`, false);
|
|
7080
|
+
const rawVal = existingLegacy.recordset[0].Value;
|
|
7081
|
+
const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
|
|
7082
|
+
isMatch = deepEqualJSON(parsed, iconsOnly);
|
|
6828
7083
|
}
|
|
6829
|
-
catch
|
|
6830
|
-
|
|
7084
|
+
catch {
|
|
7085
|
+
isMatch = false;
|
|
7086
|
+
}
|
|
7087
|
+
if (!isMatch) {
|
|
7088
|
+
try {
|
|
7089
|
+
await this.LogSQLAndExecute(pool, `
|
|
7090
|
+
UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
|
|
7091
|
+
SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7092
|
+
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
|
|
7093
|
+
`, `Update FieldCategoryIcons setting (legacy)`, false);
|
|
7094
|
+
}
|
|
7095
|
+
catch (ex) {
|
|
7096
|
+
logError('Error Applying Category Info Settings: Part 3', ex);
|
|
7097
|
+
}
|
|
6831
7098
|
}
|
|
6832
7099
|
}
|
|
6833
7100
|
else {
|
|
6834
|
-
const newId =
|
|
7101
|
+
const newId = uuidv5(`${entityId}|FieldCategoryIcons`, ENTITY_SETTING_NAMESPACE);
|
|
6835
7102
|
try {
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
|
|
6839
|
-
|
|
7103
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
|
|
7104
|
+
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)})
|
|
7105
|
+
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
7106
|
+
const sSQL = this.conditionalInsert(checkQuery, insertSQL);
|
|
7107
|
+
await this.LogSQLAndExecute(pool, sSQL, `Insert FieldCategoryIcons setting (legacy)`, false);
|
|
6840
7108
|
}
|
|
6841
7109
|
catch (ex) {
|
|
6842
7110
|
logError('Error Applying Category Info Settings: Part 4', ex);
|
|
@@ -6847,7 +7115,7 @@ WHERE
|
|
|
6847
7115
|
* Applies entity importance analysis to MJApplicationEntity records.
|
|
6848
7116
|
* Only called for NEW entities to set DefaultForNewUser.
|
|
6849
7117
|
*/
|
|
6850
|
-
async applyEntityImportance(pool, entityId, importance) {
|
|
7118
|
+
async applyEntityImportance(pool, entityId, importance, entityName) {
|
|
6851
7119
|
const updateSQL = `
|
|
6852
7120
|
UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
|
|
6853
7121
|
SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|