@memberjunction/codegen-lib 6.1.4 → 6.2.0-edge.1
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/dist/Angular/angular-codegen.d.ts +6 -0
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +26 -14
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Config/config.d.ts +45 -2
- package/dist/Config/config.d.ts.map +1 -1
- package/dist/Config/config.js +116 -39
- package/dist/Config/config.js.map +1 -1
- package/dist/Config/db-connection.d.ts +4 -0
- package/dist/Config/db-connection.d.ts.map +1 -1
- package/dist/Config/db-connection.js +11 -3
- package/dist/Config/db-connection.js.map +1 -1
- package/dist/Config/pg-connection.d.ts +2 -0
- package/dist/Config/pg-connection.d.ts.map +1 -1
- package/dist/Config/pg-connection.js +5 -1
- package/dist/Config/pg-connection.js.map +1 -1
- package/dist/Config/schema-output.d.ts +8 -0
- package/dist/Config/schema-output.d.ts.map +1 -1
- package/dist/Config/schema-output.js +19 -7
- package/dist/Config/schema-output.js.map +1 -1
- package/dist/Database/__tests__/exclude-schema-permissions.test.js +3 -3
- package/dist/Database/codeGenDatabaseProvider.d.ts +40 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.js +65 -7
- package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
- package/dist/Database/dbSchema.d.ts +4 -0
- package/dist/Database/dbSchema.d.ts.map +1 -1
- package/dist/Database/dbSchema.js +17 -9
- package/dist/Database/dbSchema.js.map +1 -1
- package/dist/Database/entity-field-change-tracking.d.ts +4 -0
- package/dist/Database/entity-field-change-tracking.d.ts.map +1 -1
- package/dist/Database/entity-field-change-tracking.js +12 -4
- package/dist/Database/entity-field-change-tracking.js.map +1 -1
- package/dist/Database/exclude-tables.d.ts +2 -0
- package/dist/Database/exclude-tables.d.ts.map +1 -1
- package/dist/Database/exclude-tables.js +5 -1
- package/dist/Database/exclude-tables.js.map +1 -1
- package/dist/Database/field-metadata-lock.d.ts +8 -6
- package/dist/Database/field-metadata-lock.d.ts.map +1 -1
- package/dist/Database/field-metadata-lock.js +29 -25
- package/dist/Database/field-metadata-lock.js.map +1 -1
- package/dist/Database/heal-schema-params.d.ts +14 -2
- package/dist/Database/heal-schema-params.d.ts.map +1 -1
- package/dist/Database/heal-schema-params.js +21 -5
- package/dist/Database/heal-schema-params.js.map +1 -1
- package/dist/Database/manage-metadata.d.ts +227 -4
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +883 -384
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/materializationAnalysis.d.ts +61 -2
- package/dist/Database/materializationAnalysis.d.ts.map +1 -1
- package/dist/Database/materializationAnalysis.js +43 -23
- package/dist/Database/materializationAnalysis.js.map +1 -1
- package/dist/Database/materializationBroadRender.d.ts +5 -3
- package/dist/Database/materializationBroadRender.d.ts.map +1 -1
- package/dist/Database/materializationBroadRender.js +17 -13
- package/dist/Database/materializationBroadRender.js.map +1 -1
- package/dist/Database/materializationDrift.d.ts +2 -0
- package/dist/Database/materializationDrift.d.ts.map +1 -1
- package/dist/Database/materializationDrift.js +5 -1
- package/dist/Database/materializationDrift.js.map +1 -1
- package/dist/Database/materializationParamClassifier.d.ts +17 -2
- package/dist/Database/materializationParamClassifier.d.ts.map +1 -1
- package/dist/Database/materializationParamClassifier.js +22 -10
- package/dist/Database/materializationParamClassifier.js.map +1 -1
- package/dist/Database/materializationParamVerifier.d.ts +2 -0
- package/dist/Database/materializationParamVerifier.d.ts.map +1 -1
- package/dist/Database/materializationParamVerifier.js +18 -14
- package/dist/Database/materializationParamVerifier.js.map +1 -1
- package/dist/Database/materializationSqlAst.d.ts +19 -1
- package/dist/Database/materializationSqlAst.d.ts.map +1 -1
- package/dist/Database/materializationSqlAst.js +53 -17
- package/dist/Database/materializationSqlAst.js.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +18 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +76 -30
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/postgresql/metadataSupportObjects.d.ts +2 -0
- package/dist/Database/providers/postgresql/metadataSupportObjects.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/metadataSupportObjects.js +5 -1
- package/dist/Database/providers/postgresql/metadataSupportObjects.js.map +1 -1
- package/dist/Database/providers/postgresql/viewDependencyCapture.d.ts +10 -0
- package/dist/Database/providers/postgresql/viewDependencyCapture.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/viewDependencyCapture.js +25 -5
- package/dist/Database/providers/postgresql/viewDependencyCapture.js.map +1 -1
- package/dist/Database/providers/postgresql/viewFallback.d.ts +11 -7
- package/dist/Database/providers/postgresql/viewFallback.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/viewFallback.js +18 -12
- package/dist/Database/providers/postgresql/viewFallback.js.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +9 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +27 -13
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
- package/dist/Database/reconcileFieldLevelSecurity.d.ts +2 -0
- package/dist/Database/reconcileFieldLevelSecurity.d.ts.map +1 -1
- package/dist/Database/reconcileFieldLevelSecurity.js +5 -1
- package/dist/Database/reconcileFieldLevelSecurity.js.map +1 -1
- package/dist/Database/reorder-columns.d.ts +2 -0
- package/dist/Database/reorder-columns.d.ts.map +1 -1
- package/dist/Database/reorder-columns.js +5 -1
- package/dist/Database/reorder-columns.js.map +1 -1
- package/dist/Database/schema-filters.d.ts +6 -0
- package/dist/Database/schema-filters.d.ts.map +1 -1
- package/dist/Database/schema-filters.js +17 -5
- package/dist/Database/schema-filters.js.map +1 -1
- package/dist/Database/schema-scope.d.ts +4 -0
- package/dist/Database/schema-scope.d.ts.map +1 -1
- package/dist/Database/schema-scope.js +13 -5
- package/dist/Database/schema-scope.js.map +1 -1
- package/dist/Database/search-guardrails.d.ts +52 -0
- package/dist/Database/search-guardrails.d.ts.map +1 -1
- package/dist/Database/search-guardrails.js +77 -18
- package/dist/Database/search-guardrails.js.map +1 -1
- package/dist/Database/sql.d.ts +20 -0
- package/dist/Database/sql.d.ts.map +1 -1
- package/dist/Database/sql.js +72 -32
- package/dist/Database/sql.js.map +1 -1
- package/dist/Database/sql_codegen.d.ts +92 -0
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +197 -113
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/EntityNameScanner/EntityNameScanner.d.ts +16 -0
- package/dist/EntityNameScanner/EntityNameScanner.d.ts.map +1 -1
- package/dist/EntityNameScanner/EntityNameScanner.js +47 -15
- package/dist/EntityNameScanner/EntityNameScanner.js.map +1 -1
- package/dist/EntityNameScanner/HtmlEntityNameScanner.d.ts +6 -0
- package/dist/EntityNameScanner/HtmlEntityNameScanner.d.ts.map +1 -1
- package/dist/EntityNameScanner/HtmlEntityNameScanner.js +19 -7
- package/dist/EntityNameScanner/HtmlEntityNameScanner.js.map +1 -1
- package/dist/EntityNameScanner/MetadataNameScanner.d.ts +6 -0
- package/dist/EntityNameScanner/MetadataNameScanner.d.ts.map +1 -1
- package/dist/EntityNameScanner/MetadataNameScanner.js +19 -7
- package/dist/EntityNameScanner/MetadataNameScanner.js.map +1 -1
- package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts +6 -0
- package/dist/Manifest/GenerateClassRegistrationsManifest.d.ts.map +1 -1
- package/dist/Manifest/GenerateClassRegistrationsManifest.js +16 -4
- package/dist/Manifest/GenerateClassRegistrationsManifest.js.map +1 -1
- package/dist/Misc/action_subclasses_codegen.d.ts +4 -0
- package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/action_subclasses_codegen.js +11 -3
- package/dist/Misc/action_subclasses_codegen.js.map +1 -1
- package/dist/Misc/advanced_generation.d.ts +48 -2
- package/dist/Misc/advanced_generation.d.ts.map +1 -1
- package/dist/Misc/advanced_generation.js +53 -15
- package/dist/Misc/advanced_generation.js.map +1 -1
- package/dist/Misc/codegen-reporter.d.ts +53 -0
- package/dist/Misc/codegen-reporter.d.ts.map +1 -1
- package/dist/Misc/codegen-reporter.js +106 -22
- package/dist/Misc/codegen-reporter.js.map +1 -1
- package/dist/Misc/createNewUser.d.ts +6 -0
- package/dist/Misc/createNewUser.d.ts.map +1 -1
- package/dist/Misc/createNewUser.js +5 -1
- package/dist/Misc/createNewUser.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts +8 -0
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +52 -36
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/externalTypeMapping.d.ts +2 -0
- package/dist/Misc/externalTypeMapping.d.ts.map +1 -1
- package/dist/Misc/externalTypeMapping.js +5 -1
- package/dist/Misc/externalTypeMapping.js.map +1 -1
- package/dist/Misc/file-write.d.ts +2 -0
- package/dist/Misc/file-write.d.ts.map +1 -1
- package/dist/Misc/file-write.js +5 -1
- package/dist/Misc/file-write.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts +14 -0
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +51 -27
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/remote_operations_codegen.d.ts +12 -0
- package/dist/Misc/remote_operations_codegen.d.ts.map +1 -1
- package/dist/Misc/remote_operations_codegen.js +21 -5
- package/dist/Misc/remote_operations_codegen.js.map +1 -1
- package/dist/Misc/runCommand.d.ts +6 -0
- package/dist/Misc/runCommand.d.ts.map +1 -1
- package/dist/Misc/runCommand.js +16 -4
- package/dist/Misc/runCommand.js.map +1 -1
- package/dist/Misc/schema-emit.d.ts +28 -0
- package/dist/Misc/schema-emit.d.ts.map +1 -1
- package/dist/Misc/schema-emit.js +75 -23
- package/dist/Misc/schema-emit.js.map +1 -1
- package/dist/Misc/sql_logging.d.ts +44 -11
- package/dist/Misc/sql_logging.d.ts.map +1 -1
- package/dist/Misc/sql_logging.js +132 -51
- package/dist/Misc/sql_logging.js.map +1 -1
- package/dist/Misc/sql_text.d.ts +22 -0
- package/dist/Misc/sql_text.d.ts.map +1 -0
- package/dist/Misc/sql_text.js +46 -0
- package/dist/Misc/sql_text.js.map +1 -0
- package/dist/Misc/status_logging.d.ts +36 -0
- package/dist/Misc/status_logging.d.ts.map +1 -1
- package/dist/Misc/status_logging.js +95 -23
- package/dist/Misc/status_logging.js.map +1 -1
- package/dist/Misc/system_integrity.js +4 -4
- package/dist/Misc/system_integrity.js.map +1 -1
- package/dist/Misc/temp_batch_file.d.ts +12 -0
- package/dist/Misc/temp_batch_file.d.ts.map +1 -1
- package/dist/Misc/temp_batch_file.js +35 -11
- package/dist/Misc/temp_batch_file.js.map +1 -1
- package/dist/Misc/util.d.ts +33 -0
- package/dist/Misc/util.d.ts.map +1 -1
- package/dist/Misc/util.js +53 -13
- package/dist/Misc/util.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +11 -1
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +9 -1
- package/dist/plugins/index.js.map +1 -1
- package/dist/runCodeGen.d.ts +6 -0
- package/dist/runCodeGen.d.ts.map +1 -1
- package/dist/runCodeGen.js +150 -138
- package/dist/runCodeGen.js.map +1 -1
- package/package.json +29 -29
|
@@ -1,53 +1,54 @@
|
|
|
1
1
|
import { SQLServerDialect, PostgreSQLDialect } from '@memberjunction/sql-dialect';
|
|
2
2
|
import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { AnalyzeQueryForMaterialization, DetectAggregationKeyColumns, DetectAdditiveMeasures, MATERIALIZATION_SURROGATE_COLUMN } from './materializationAnalysis.js';
|
|
4
|
+
import { EvaluateMaterializationDrift } from './materializationDrift.js';
|
|
5
|
+
import { ClassifyQueryParameters, BuildHeldValues } from './materializationParamClassifier.js';
|
|
6
|
+
import { BuildBroadRowFilterSQL } from './materializationBroadRender.js';
|
|
7
7
|
import { QueryParameterProcessor } from '@memberjunction/query-processor';
|
|
8
8
|
// Side-effect import — registers `SQLServerCodeGenProvider` with `MJGlobal.ClassFactory`
|
|
9
9
|
// under the `'sqlserver'` key via its `@RegisterClass` decorator. Without this import,
|
|
10
10
|
// `ClassFactory.CreateInstance(CodeGenDatabaseProvider, 'sqlserver')` returns nothing
|
|
11
11
|
// and the SS code path silently fails.
|
|
12
12
|
import './providers/sqlserver/SQLServerCodeGenProvider.js';
|
|
13
|
-
import { configInfo, currentWorkingDirectory,
|
|
13
|
+
import { configInfo, currentWorkingDirectory, DbPlatform, GetSettingValue, MjCoreSchema, OutputDir } from '../Config/config.js';
|
|
14
14
|
import { CodeNameFromString, EntityFieldInfo, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
|
|
15
15
|
import { MJEntityFieldSchema } from "@memberjunction/core-entities";
|
|
16
|
-
import { logError,
|
|
16
|
+
import { logError, LogMessage, logStatus, StartSpinner, UpdateSpinner, SucceedSpinner } from "../Misc/status_logging.js";
|
|
17
17
|
import { SQLUtilityBase } from "./sql.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { AdvancedGeneration,
|
|
18
|
+
import { ApplyIncludeSchemaScope } from "./schema-scope.js";
|
|
19
|
+
import { BuildHealSchemaRoutineParams, GetAuthoredExcludeSchemas, SnapshotAuthoredExcludeSchemas } from "./heal-schema-params.js";
|
|
20
|
+
import { AdvancedGeneration, IsPlausibleEntityName } from "../Misc/advanced_generation.js";
|
|
21
21
|
import { CodeGenReporter } from "../Misc/codegen-reporter.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { ApplySearchableFieldsCap, DefaultPredicateFor, EntityLevelEnableBlockedReason, IsNarrativeFieldName, MAX_SEARCHABLE_FIELDS_PER_ENTITY, NAME_LIKE_FIELD_NAMES, NormalizePredicate, NormalizeSmartFieldResultShape, } from "./search-guardrails.js";
|
|
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
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 {
|
|
29
|
+
import { CanonicalJSONStringify, DeepEqualJSON } from "../Misc/util.js";
|
|
30
|
+
import { TrimTrailingStatementTerminators } from "../Misc/sql_text.js";
|
|
30
31
|
import { SQLLogging } from "../Misc/sql_logging.js";
|
|
31
|
-
import {
|
|
32
|
+
import { ComputeFieldMetadataUpdate } from "./field-metadata-lock.js";
|
|
32
33
|
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';
|
|
34
|
+
export { TRACKED_FIELD_COLUMNS, DISPLAYNAME_REOPEN_REASONS, TYPE_REOPEN_REASONS, DiffEntityFieldSnapshots, diffEntityFieldSnapshots, } from './entity-field-change-tracking.js';
|
|
34
35
|
export class ValidatorResult {
|
|
35
36
|
constructor() {
|
|
36
|
-
this.entityName = "";
|
|
37
|
-
this.sourceCheckConstraint = "";
|
|
38
|
-
this.functionText = "";
|
|
39
|
-
this.functionName = "";
|
|
40
|
-
this.functionDescription = "";
|
|
37
|
+
this.entityName = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
38
|
+
this.sourceCheckConstraint = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
39
|
+
this.functionText = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
40
|
+
this.functionName = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
41
|
+
this.functionDescription = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
41
42
|
/**
|
|
42
43
|
* The ID value in the Generated Codes entity that was created for this validator.
|
|
43
44
|
*/
|
|
44
|
-
this.generatedCodeId = "";
|
|
45
|
+
this.generatedCodeId = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
45
46
|
/**
|
|
46
47
|
* The ID for the AI Model that was used to generate the code
|
|
47
48
|
*/
|
|
48
|
-
this.aiModelID = "";
|
|
49
|
-
this.wasGenerated = true;
|
|
50
|
-
this.success = false;
|
|
49
|
+
this.aiModelID = ""; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
50
|
+
this.wasGenerated = true; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
51
|
+
this.success = false; // case-violation-ok-legacy-back-compat: object literals are assigned to this class, so an accessor stub changes what they must supply
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
export class ManageMetadataBase {
|
|
@@ -97,7 +98,7 @@ export class ManageMetadataBase {
|
|
|
97
98
|
*/
|
|
98
99
|
get dbProvider() {
|
|
99
100
|
if (!this._dbProvider) {
|
|
100
|
-
const platform =
|
|
101
|
+
const platform = DbPlatform();
|
|
101
102
|
const provider = MJGlobal.Instance.ClassFactory.CreateInstance(CodeGenDatabaseProvider, platform);
|
|
102
103
|
// CreateInstance returns the abstract base class on a missed key (it
|
|
103
104
|
// doesn't throw), so disambiguate by constructor identity: anything
|
|
@@ -190,7 +191,7 @@ export class ManageMetadataBase {
|
|
|
190
191
|
*/
|
|
191
192
|
applyTimeEntityFieldSequenceSQL(entityID) {
|
|
192
193
|
// 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(
|
|
194
|
+
return `(SELECT COALESCE(MAX(${this.qi('Sequence')}), 0) + 1 FROM ${this.qs(MjCoreSchema(), 'EntityField')} WHERE ${this.qi('EntityID')} = '${entityID}')`;
|
|
194
195
|
}
|
|
195
196
|
/**
|
|
196
197
|
* Wraps a SELECT query with a row limit.
|
|
@@ -212,7 +213,7 @@ export class ManageMetadataBase {
|
|
|
212
213
|
* (avoids hardcoding runtime UUIDs that may differ on a fresh database).
|
|
213
214
|
*/
|
|
214
215
|
entityIdSubquery(tableName, schemaName) {
|
|
215
|
-
const schema =
|
|
216
|
+
const schema = MjCoreSchema();
|
|
216
217
|
return `(${this.selectTop(1, 'ID', `FROM ${this.qs(schema, 'vwEntities')} WHERE BaseTable = '${tableName}' AND SchemaName = '${schemaName}'`)})`;
|
|
217
218
|
}
|
|
218
219
|
/**
|
|
@@ -262,22 +263,38 @@ export class ManageMetadataBase {
|
|
|
262
263
|
/**
|
|
263
264
|
* Globally scoped list of entities that have been created during the metadata management process.
|
|
264
265
|
*/
|
|
265
|
-
static get
|
|
266
|
+
static get NewEntityList() {
|
|
266
267
|
return this._newEntityList;
|
|
267
268
|
}
|
|
268
|
-
static set
|
|
269
|
+
static set NewEntityList(value) {
|
|
269
270
|
this._newEntityList = value;
|
|
270
271
|
}
|
|
272
|
+
/** @deprecated Use {@link NewEntityList}. */
|
|
273
|
+
static get newEntityList() {
|
|
274
|
+
return this.NewEntityList;
|
|
275
|
+
}
|
|
276
|
+
/** @deprecated Use {@link NewEntityList}. */
|
|
277
|
+
static set newEntityList(value) {
|
|
278
|
+
this.NewEntityList = value;
|
|
279
|
+
}
|
|
271
280
|
static { this._modifiedEntityList = []; }
|
|
272
281
|
/**
|
|
273
282
|
* Globally scoped list of entities that have been modified during the metadata management process.
|
|
274
283
|
*/
|
|
275
|
-
static get
|
|
284
|
+
static get ModifiedEntityList() {
|
|
276
285
|
return this._modifiedEntityList;
|
|
277
286
|
}
|
|
278
|
-
static set
|
|
287
|
+
static set ModifiedEntityList(value) {
|
|
279
288
|
this._modifiedEntityList = value;
|
|
280
289
|
}
|
|
290
|
+
/** @deprecated Use {@link ModifiedEntityList}. */
|
|
291
|
+
static get modifiedEntityList() {
|
|
292
|
+
return this.ModifiedEntityList;
|
|
293
|
+
}
|
|
294
|
+
/** @deprecated Use {@link ModifiedEntityList}. */
|
|
295
|
+
static set modifiedEntityList(value) {
|
|
296
|
+
this.ModifiedEntityList = value;
|
|
297
|
+
}
|
|
281
298
|
static { this._newFieldSet = new Set(); }
|
|
282
299
|
static { this._changedFields = new Map(); }
|
|
283
300
|
static { this._changedFieldReport = []; }
|
|
@@ -289,13 +306,17 @@ export class ManageMetadataBase {
|
|
|
289
306
|
}
|
|
290
307
|
return `${eid}:${fld}`;
|
|
291
308
|
}
|
|
292
|
-
static
|
|
309
|
+
static RegisterNewField(entityID, name) {
|
|
293
310
|
const key = this.fieldKey(entityID, name);
|
|
294
311
|
if (key) {
|
|
295
312
|
this._newFieldSet.add(key);
|
|
296
313
|
}
|
|
297
314
|
}
|
|
298
|
-
|
|
315
|
+
/** @deprecated Use {@link RegisterNewField}. */
|
|
316
|
+
static registerNewField(entityID, name) {
|
|
317
|
+
return this.RegisterNewField(entityID, name);
|
|
318
|
+
}
|
|
319
|
+
static RegisterFieldChange(entityID, name, reasons) {
|
|
299
320
|
const key = this.fieldKey(entityID, name);
|
|
300
321
|
if (key) {
|
|
301
322
|
let set = this._changedFields.get(key);
|
|
@@ -308,17 +329,29 @@ export class ManageMetadataBase {
|
|
|
308
329
|
}
|
|
309
330
|
}
|
|
310
331
|
}
|
|
311
|
-
|
|
332
|
+
/** @deprecated Use {@link RegisterFieldChange}. */
|
|
333
|
+
static registerFieldChange(entityID, name, reasons) {
|
|
334
|
+
return this.RegisterFieldChange(entityID, name, reasons);
|
|
335
|
+
}
|
|
336
|
+
static IsFieldNew(entityID, name) {
|
|
312
337
|
const key = this.fieldKey(entityID, name);
|
|
313
338
|
return key ? this._newFieldSet.has(key) : false;
|
|
314
339
|
}
|
|
315
|
-
|
|
340
|
+
/** @deprecated Use {@link IsFieldNew}. */
|
|
341
|
+
static isFieldNew(entityID, name) {
|
|
342
|
+
return this.IsFieldNew(entityID, name);
|
|
343
|
+
}
|
|
344
|
+
static FieldChangeReasons(entityID, name) {
|
|
316
345
|
const key = this.fieldKey(entityID, name);
|
|
317
346
|
const set = key ? this._changedFields.get(key) : undefined;
|
|
318
347
|
return set ?? new Set();
|
|
319
348
|
}
|
|
320
|
-
|
|
321
|
-
|
|
349
|
+
/** @deprecated Use {@link FieldChangeReasons}. */
|
|
350
|
+
static fieldChangeReasons(entityID, name) {
|
|
351
|
+
return this.FieldChangeReasons(entityID, name);
|
|
352
|
+
}
|
|
353
|
+
static IsDisplayNameReopened(entityID, name) {
|
|
354
|
+
const reasons = this.FieldChangeReasons(entityID, name);
|
|
322
355
|
for (const r of reasons) {
|
|
323
356
|
if (DISPLAYNAME_REOPEN_REASONS.has(r)) {
|
|
324
357
|
return true;
|
|
@@ -326,8 +359,12 @@ export class ManageMetadataBase {
|
|
|
326
359
|
}
|
|
327
360
|
return false;
|
|
328
361
|
}
|
|
329
|
-
|
|
330
|
-
|
|
362
|
+
/** @deprecated Use {@link IsDisplayNameReopened}. */
|
|
363
|
+
static isDisplayNameReopened(entityID, name) {
|
|
364
|
+
return this.IsDisplayNameReopened(entityID, name);
|
|
365
|
+
}
|
|
366
|
+
static IsTypeReopened(entityID, name) {
|
|
367
|
+
const reasons = this.FieldChangeReasons(entityID, name);
|
|
331
368
|
for (const r of reasons) {
|
|
332
369
|
if (TYPE_REOPEN_REASONS.has(r)) {
|
|
333
370
|
return true;
|
|
@@ -335,20 +372,40 @@ export class ManageMetadataBase {
|
|
|
335
372
|
}
|
|
336
373
|
return false;
|
|
337
374
|
}
|
|
338
|
-
|
|
375
|
+
/** @deprecated Use {@link IsTypeReopened}. */
|
|
376
|
+
static isTypeReopened(entityID, name) {
|
|
377
|
+
return this.IsTypeReopened(entityID, name);
|
|
378
|
+
}
|
|
379
|
+
static get NewFieldCount() {
|
|
339
380
|
return this._newFieldSet.size;
|
|
340
381
|
}
|
|
341
|
-
|
|
382
|
+
/** @deprecated Use {@link NewFieldCount}. */
|
|
383
|
+
static get newFieldCount() {
|
|
384
|
+
return this.NewFieldCount;
|
|
385
|
+
}
|
|
386
|
+
static get ChangedFieldCount() {
|
|
342
387
|
return this._changedFields.size;
|
|
343
388
|
}
|
|
344
|
-
|
|
389
|
+
/** @deprecated Use {@link ChangedFieldCount}. */
|
|
390
|
+
static get changedFieldCount() {
|
|
391
|
+
return this.ChangedFieldCount;
|
|
392
|
+
}
|
|
393
|
+
static get ChangedFieldReport() {
|
|
345
394
|
return this._changedFieldReport;
|
|
346
395
|
}
|
|
347
|
-
|
|
396
|
+
/** @deprecated Use {@link ChangedFieldReport}. */
|
|
397
|
+
static get changedFieldReport() {
|
|
398
|
+
return this.ChangedFieldReport;
|
|
399
|
+
}
|
|
400
|
+
static ClearFieldTracking() {
|
|
348
401
|
this._newFieldSet.clear();
|
|
349
402
|
this._changedFields.clear();
|
|
350
403
|
this._changedFieldReport = [];
|
|
351
404
|
}
|
|
405
|
+
/** @deprecated Use {@link ClearFieldTracking}. */
|
|
406
|
+
static clearFieldTracking() {
|
|
407
|
+
return this.ClearFieldTracking();
|
|
408
|
+
}
|
|
352
409
|
/**
|
|
353
410
|
* Takes a snapshot of EntityField rows from vwEntityFields for diffing.
|
|
354
411
|
* Only queries columns in TRACKED_FIELD_COLUMNS (Sequence is excluded).
|
|
@@ -360,7 +417,7 @@ export class ManageMetadataBase {
|
|
|
360
417
|
: '';
|
|
361
418
|
const cols = TRACKED_FIELD_COLUMNS.map(c => this.qi(c)).join(', ');
|
|
362
419
|
const sSQL = `SELECT ${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Entity')} AS ${this.qi('EntityName')}, ${this.qi('Name')}, ${cols}
|
|
363
|
-
FROM ${this.qs(
|
|
420
|
+
FROM ${this.qs(MjCoreSchema(), 'vwEntityFields')}
|
|
364
421
|
${filter}`;
|
|
365
422
|
const result = await this.runQuery(pool, sSQL);
|
|
366
423
|
const map = new Map();
|
|
@@ -390,9 +447,13 @@ export class ManageMetadataBase {
|
|
|
390
447
|
* the dead class keeps a live `@RegisterClass` registration on disk. The old monolith could
|
|
391
448
|
* not drift this way: it was rewritten in full every run.
|
|
392
449
|
*/
|
|
393
|
-
static get
|
|
450
|
+
static get DeletedEntitySchemaList() {
|
|
394
451
|
return this._deletedEntitySchemaList;
|
|
395
452
|
}
|
|
453
|
+
/** @deprecated Use {@link DeletedEntitySchemaList}. */
|
|
454
|
+
static get deletedEntitySchemaList() {
|
|
455
|
+
return this.DeletedEntitySchemaList;
|
|
456
|
+
}
|
|
396
457
|
static { this._entitiesRequiringViewRegen = []; }
|
|
397
458
|
/**
|
|
398
459
|
* Entities that had late-phase changes requiring their base views to be
|
|
@@ -411,9 +472,13 @@ export class ManageMetadataBase {
|
|
|
411
472
|
/**
|
|
412
473
|
* Globally scoped list of validators that have been generated during the metadata management process.
|
|
413
474
|
*/
|
|
414
|
-
static get
|
|
475
|
+
static get GeneratedValidators() {
|
|
415
476
|
return this._generatedValidators;
|
|
416
477
|
}
|
|
478
|
+
/** @deprecated Use {@link GeneratedValidators}. */
|
|
479
|
+
static get generatedValidators() {
|
|
480
|
+
return this.GeneratedValidators;
|
|
481
|
+
}
|
|
417
482
|
static { this._softPKFKConfigCache = null; }
|
|
418
483
|
static { this._softPKFKConfigPath = ''; }
|
|
419
484
|
/**
|
|
@@ -428,16 +493,20 @@ export class ManageMetadataBase {
|
|
|
428
493
|
* Deterministic + event-driven (no mtime/TOCTOU race). The CLI `Run()` path does not call this, so its
|
|
429
494
|
* load-once-per-process behavior is unchanged.
|
|
430
495
|
*/
|
|
431
|
-
static
|
|
496
|
+
static InvalidateSoftPKFKConfigCache() {
|
|
432
497
|
ManageMetadataBase._softPKFKConfigCache = null;
|
|
433
498
|
ManageMetadataBase._softPKFKConfigPath = '';
|
|
434
499
|
}
|
|
500
|
+
/** @deprecated Use {@link InvalidateSoftPKFKConfigCache}. */
|
|
501
|
+
static invalidateSoftPKFKConfigCache() {
|
|
502
|
+
return this.InvalidateSoftPKFKConfigCache();
|
|
503
|
+
}
|
|
435
504
|
/**
|
|
436
505
|
* Loads and caches the soft PK/FK configuration from the additionalSchemaInfo file.
|
|
437
506
|
* Cached per process to avoid repeated I/O within a CodeGen run; the in-process (RSU) path calls
|
|
438
507
|
* {@link invalidateSoftPKFKConfigCache} at the start of each run so a rewritten file is picked up.
|
|
439
508
|
*/
|
|
440
|
-
static
|
|
509
|
+
static GetSoftPKFKConfig() {
|
|
441
510
|
// Return cached config if path hasn't changed
|
|
442
511
|
const configPath = configInfo.additionalSchemaInfo
|
|
443
512
|
? path.join(currentWorkingDirectory, configInfo.additionalSchemaInfo)
|
|
@@ -463,6 +532,10 @@ export class ManageMetadataBase {
|
|
|
463
532
|
return null;
|
|
464
533
|
}
|
|
465
534
|
}
|
|
535
|
+
/** @deprecated Use {@link GetSoftPKFKConfig}. */
|
|
536
|
+
static getSoftPKFKConfig() {
|
|
537
|
+
return this.GetSoftPKFKConfig();
|
|
538
|
+
}
|
|
466
539
|
/**
|
|
467
540
|
* Extracts a flat array of table configs from the config file, handling both formats:
|
|
468
541
|
* 1. Schema-as-key (template format): { "dbo": [{ "TableName": "Orders", ... }] }
|
|
@@ -668,35 +741,47 @@ export class ManageMetadataBase {
|
|
|
668
741
|
async processOrganicKeyConfig(pool) {
|
|
669
742
|
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
670
743
|
if (!config)
|
|
671
|
-
return { success: true, createdCount: 0, updatedCount: 0 };
|
|
744
|
+
return { success: true, createdCount: 0, updatedCount: 0, failedCount: 0 };
|
|
672
745
|
const allOrganicKeys = this.extractOrganicKeysFromConfig(config);
|
|
673
746
|
if (allOrganicKeys.length === 0)
|
|
674
|
-
return { success: true, createdCount: 0, updatedCount: 0 };
|
|
675
|
-
const schema =
|
|
747
|
+
return { success: true, createdCount: 0, updatedCount: 0, failedCount: 0 };
|
|
748
|
+
const schema = MjCoreSchema();
|
|
676
749
|
let createdCount = 0;
|
|
677
750
|
let updatedCount = 0;
|
|
751
|
+
let failedCount = 0;
|
|
678
752
|
for (const tableConfig of allOrganicKeys) {
|
|
679
753
|
// Resolve the owning entity
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (ownerResult.recordset.length === 0) {
|
|
686
|
-
logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping`);
|
|
754
|
+
const owner = await this.findOrganicKeyEntity(pool, tableConfig.SchemaName, tableConfig.TableName);
|
|
755
|
+
if (!owner) {
|
|
756
|
+
// Every key declared on this table goes unapplied, so each one counts as a failure.
|
|
757
|
+
failedCount += tableConfig.OrganicKeys.length;
|
|
758
|
+
logError(` > Organic keys config: entity "${tableConfig.SchemaName}.${tableConfig.TableName}" not found — skipping its ${tableConfig.OrganicKeys.length} organic key(s)`);
|
|
687
759
|
continue;
|
|
688
760
|
}
|
|
689
|
-
const ownerEntityId =
|
|
690
|
-
const ownerEntityName =
|
|
761
|
+
const ownerEntityId = owner.ID;
|
|
762
|
+
const ownerEntityName = owner.Name;
|
|
691
763
|
for (const okConfig of tableConfig.OrganicKeys) {
|
|
692
764
|
try {
|
|
765
|
+
// Resolve every related entity BEFORE writing anything for this key. Step 1 creates
|
|
766
|
+
// bridge views (in the database and the migration log) and step 2 records the key, so
|
|
767
|
+
// discovering a missing related entity in step 3 would leave an orphan view and a key
|
|
768
|
+
// with a mapping missing.
|
|
769
|
+
const relatedEntities = await this.resolveOrganicKeyRelatedEntities(pool, okConfig);
|
|
770
|
+
if (!relatedEntities) {
|
|
771
|
+
failedCount++;
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
693
774
|
// Step 1: Create transitive views if defined
|
|
694
775
|
for (const re of okConfig.RelatedEntities) {
|
|
695
776
|
if (re.TransitiveView) {
|
|
696
777
|
const viewSchema = re.TransitiveView.SchemaName || re.SchemaName;
|
|
697
778
|
const viewFullName = `${viewSchema}.${re.TransitiveView.Name}`;
|
|
698
|
-
const viewSQL =
|
|
699
|
-
|
|
779
|
+
const viewSQL = this.dbProvider.generateCreateOrReplaceViewSQL(viewSchema, re.TransitiveView.Name, re.TransitiveView.SQL);
|
|
780
|
+
// T-SQL requires CREATE [OR ALTER] VIEW to be the ONLY statement in its batch — first as
|
|
781
|
+
// well as last — and the metadata DML logged just before it (entity-config UPDATEs, the
|
|
782
|
+
// previous key's INSERTs) has no trailing GO. requiresOwnBatch puts the provider's
|
|
783
|
+
// separator on both sides in the migration file ('' on PostgreSQL: nothing is added).
|
|
784
|
+
await this.logSQLAndExecute(pool, viewSQL, `Create transitive bridge view ${viewFullName} for organic key "${okConfig.Name}" on ${ownerEntityName}`, false, true, this.dbProvider.BatchSeparator, true);
|
|
700
785
|
// Auto-populate TransitiveObject from the view definition
|
|
701
786
|
re.TransitiveObject = viewFullName;
|
|
702
787
|
}
|
|
@@ -716,7 +801,7 @@ export class ManageMetadataBase {
|
|
|
716
801
|
Sequence = ${okConfig.Sequence ?? 0},
|
|
717
802
|
Status = 'Active'
|
|
718
803
|
WHERE ID = '${organicKeyId}'`;
|
|
719
|
-
await this.
|
|
804
|
+
await this.logSQLAndExecute(pool, updateSQL, `Update organic key "${okConfig.Name}" on ${ownerEntityName}`);
|
|
720
805
|
updatedCount++;
|
|
721
806
|
logStatus(` > Organic key: Updated "${okConfig.Name}" on ${ownerEntityName}`);
|
|
722
807
|
}
|
|
@@ -726,7 +811,7 @@ export class ManageMetadataBase {
|
|
|
726
811
|
const insertSQL = `INSERT INTO ${this.qs(schema, 'EntityOrganicKey')}
|
|
727
812
|
(EntityID, Name, ${okConfig.Description ? 'Description, ' : ''}MatchFieldNames, NormalizationStrategy, ${okConfig.CustomNormalizationExpression ? 'CustomNormalizationExpression, ' : ''}Sequence, Status)
|
|
728
813
|
VALUES (${ownerSubquery}, '${okConfig.Name}', ${okConfig.Description ? `'${okConfig.Description.replace(/'/g, "''")}', ` : ''}'${matchFieldNames}', '${okConfig.NormalizationStrategy || 'LowerCaseTrim'}', ${okConfig.CustomNormalizationExpression ? `'${okConfig.CustomNormalizationExpression.replace(/'/g, "''")}', ` : ''}${okConfig.Sequence ?? 0}, 'Active')`;
|
|
729
|
-
await this.
|
|
814
|
+
await this.logSQLAndExecute(pool, insertSQL, `Insert organic key "${okConfig.Name}" on ${ownerEntityName}`);
|
|
730
815
|
createdCount++;
|
|
731
816
|
logStatus(` > Organic key: Created "${okConfig.Name}" on ${ownerEntityName}`);
|
|
732
817
|
// Fetch the new ID
|
|
@@ -734,18 +819,9 @@ export class ManageMetadataBase {
|
|
|
734
819
|
organicKeyId = newKey.recordset[0].ID;
|
|
735
820
|
}
|
|
736
821
|
// Step 3: Upsert EntityOrganicKeyRelatedEntity for each related entity
|
|
737
|
-
for (const reConfig of
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
|
|
741
|
-
OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
|
|
742
|
-
`, { 'TableName': reConfig.TableName, 'SchemaName': reConfig.SchemaName });
|
|
743
|
-
if (relResult.recordset.length === 0) {
|
|
744
|
-
logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — skipping`);
|
|
745
|
-
continue;
|
|
746
|
-
}
|
|
747
|
-
const relEntityId = relResult.recordset[0].ID;
|
|
748
|
-
const relEntityName = relResult.recordset[0].Name;
|
|
822
|
+
for (const { config: reConfig, entity: relEntity } of relatedEntities) {
|
|
823
|
+
const relEntityId = relEntity.ID;
|
|
824
|
+
const relEntityName = relEntity.Name;
|
|
749
825
|
// Check if this related entity mapping already exists
|
|
750
826
|
const existingRel = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'EntityOrganicKeyRelatedEntity')} WHERE EntityOrganicKeyID = @KeyID AND RelatedEntityID = @RelEntityID`, { 'KeyID': organicKeyId, 'RelEntityID': relEntityId });
|
|
751
827
|
// Build field values
|
|
@@ -765,7 +841,7 @@ export class ManageMetadataBase {
|
|
|
765
841
|
DisplayLocation = '${reConfig.DisplayLocation || 'After Field Tabs'}',
|
|
766
842
|
Sequence = ${reConfig.Sequence ?? 0}
|
|
767
843
|
WHERE ID = '${relId}'`;
|
|
768
|
-
await this.
|
|
844
|
+
await this.logSQLAndExecute(pool, updateRelSQL, `Update organic key related entity: "${okConfig.Name}" → ${relEntityName}`);
|
|
769
845
|
}
|
|
770
846
|
else {
|
|
771
847
|
// Use subqueries for FK references so the logged SQL is portable across databases
|
|
@@ -785,18 +861,55 @@ export class ManageMetadataBase {
|
|
|
785
861
|
${reConfig.DisplayName ? `'${reConfig.DisplayName.replace(/'/g, "''")}'` : 'NULL'},
|
|
786
862
|
'${reConfig.DisplayLocation || 'After Field Tabs'}',
|
|
787
863
|
${reConfig.Sequence ?? 0})`;
|
|
788
|
-
await this.
|
|
864
|
+
await this.logSQLAndExecute(pool, insertRelSQL, `Insert organic key related entity: "${okConfig.Name}" → ${relEntityName}`);
|
|
789
865
|
}
|
|
790
866
|
logStatus(` > Organic key "${okConfig.Name}": ${existingRel.recordset.length > 0 ? 'updated' : 'created'} → ${relEntityName} (${isDirect ? 'direct' : 'transitive'})`);
|
|
791
867
|
}
|
|
792
868
|
}
|
|
793
869
|
catch (err) {
|
|
870
|
+
// Keep going so one bad key doesn't block the others — but count it: a key that failed
|
|
871
|
+
// here (bridge-view DDL the database rejected, a refused view drop, …) is missing from
|
|
872
|
+
// the database, and the run must not report success for it.
|
|
873
|
+
failedCount++;
|
|
794
874
|
const errMessage = err instanceof Error ? err.message : String(err);
|
|
795
875
|
logError(` > Organic key config: Failed to process "${okConfig.Name}" on ${ownerEntityName}: ${errMessage}`);
|
|
796
876
|
}
|
|
797
877
|
}
|
|
798
878
|
}
|
|
799
|
-
return { success:
|
|
879
|
+
return { success: failedCount === 0, createdCount, updatedCount, failedCount };
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Finds the entity an organic-key config names: by base table + schema, else by entity name.
|
|
883
|
+
* Returns `null` when neither matches.
|
|
884
|
+
*/
|
|
885
|
+
async findOrganicKeyEntity(pool, schemaName, tableName) {
|
|
886
|
+
const result = await this.runQueryWithParams(pool, `
|
|
887
|
+
${this.selectTop(1, 'ID, Name', `FROM ${this.qs(MjCoreSchema(), 'vwEntities')}
|
|
888
|
+
WHERE (BaseTable = @TableName AND SchemaName = @SchemaName)
|
|
889
|
+
OR Name = @TableName`, 'CASE WHEN BaseTable = @TableName AND SchemaName = @SchemaName THEN 0 ELSE 1 END')}
|
|
890
|
+
`, { 'TableName': tableName, 'SchemaName': schemaName });
|
|
891
|
+
const row = result.recordset[0];
|
|
892
|
+
return row ? { ID: row.ID, Name: row.Name } : null;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Resolves every related entity of an organic key, in config order. Logs each one that doesn't
|
|
896
|
+
* resolve and returns `null` if any is missing, so the caller can skip the key before writing any
|
|
897
|
+
* of it.
|
|
898
|
+
*/
|
|
899
|
+
async resolveOrganicKeyRelatedEntities(pool, okConfig) {
|
|
900
|
+
const resolved = [];
|
|
901
|
+
let missing = 0;
|
|
902
|
+
for (const reConfig of okConfig.RelatedEntities) {
|
|
903
|
+
const entity = await this.findOrganicKeyEntity(pool, reConfig.SchemaName, reConfig.TableName);
|
|
904
|
+
if (entity) {
|
|
905
|
+
resolved.push({ config: reConfig, entity });
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
missing++;
|
|
909
|
+
logError(` > Organic key "${okConfig.Name}": related entity "${reConfig.SchemaName}.${reConfig.TableName}" not found — the key is not applied`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return missing === 0 ? resolved : null;
|
|
800
913
|
}
|
|
801
914
|
/**
|
|
802
915
|
* Builds the entity lookup behind {@link processISARelationshipConfig}: match on Name first, else
|
|
@@ -840,7 +953,7 @@ export class ManageMetadataBase {
|
|
|
840
953
|
if (relationships.length === 0)
|
|
841
954
|
return { success: true, updatedCount: 0 };
|
|
842
955
|
let updatedCount = 0;
|
|
843
|
-
const schema =
|
|
956
|
+
const schema = MjCoreSchema();
|
|
844
957
|
for (const rel of relationships) {
|
|
845
958
|
try {
|
|
846
959
|
// Look up the parent entity — try by Name first, then by BaseTable within the given schema
|
|
@@ -884,7 +997,7 @@ export class ManageMetadataBase {
|
|
|
884
997
|
SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
|
|
885
998
|
${this.qi('ParentID')} = '${parentId}'
|
|
886
999
|
WHERE ${this.qi('ID')} = '${childId}'`;
|
|
887
|
-
await this.
|
|
1000
|
+
await this.logSQLAndExecute(pool, updateSQL, `Set IS-A ParentID for "${childName}" → "${parentName}"`);
|
|
888
1001
|
if (existingParentId) {
|
|
889
1002
|
logStatus(` > IS-A: Updated "${childName}" ParentID from previous value to "${parentName}"`);
|
|
890
1003
|
}
|
|
@@ -932,7 +1045,7 @@ export class ManageMetadataBase {
|
|
|
932
1045
|
* so a broken declaration fails before it produces generated code.
|
|
933
1046
|
*/
|
|
934
1047
|
async validateISARelationships(pool) {
|
|
935
|
-
const schema =
|
|
1048
|
+
const schema = MjCoreSchema();
|
|
936
1049
|
let errorCount = 0;
|
|
937
1050
|
try {
|
|
938
1051
|
const results = await this.runQuery(pool, this.buildISAValidationSQL(schema));
|
|
@@ -1023,7 +1136,7 @@ export class ManageMetadataBase {
|
|
|
1023
1136
|
if (entityConfigs.length === 0)
|
|
1024
1137
|
return { success: true, updatedCount: 0 };
|
|
1025
1138
|
let updatedCount = 0;
|
|
1026
|
-
const schema =
|
|
1139
|
+
const schema = MjCoreSchema();
|
|
1027
1140
|
const reservedKeys = new Set(['BaseTable', 'SchemaName']);
|
|
1028
1141
|
for (const ec of entityConfigs) {
|
|
1029
1142
|
try {
|
|
@@ -1088,7 +1201,7 @@ export class ManageMetadataBase {
|
|
|
1088
1201
|
const updateSQL = `UPDATE ${this.qs(schema, 'Entity')}
|
|
1089
1202
|
SET ${setClauses.join(', ')}
|
|
1090
1203
|
WHERE ${this.qi('ID')} = '${entityId}'`;
|
|
1091
|
-
await this.
|
|
1204
|
+
await this.logSQLAndExecute(pool, updateSQL, `Update attributes on "${entityName}" from additionalSchemaInfo`);
|
|
1092
1205
|
const attrSummary = differingAttrs.map(a => a.summary).join(', ');
|
|
1093
1206
|
logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
|
|
1094
1207
|
updatedCount++;
|
|
@@ -1114,7 +1227,7 @@ export class ManageMetadataBase {
|
|
|
1114
1227
|
if (virtualEntities.length === 0)
|
|
1115
1228
|
return { success: true, createdCount: 0 };
|
|
1116
1229
|
let createdCount = 0;
|
|
1117
|
-
const schema =
|
|
1230
|
+
const schema = MjCoreSchema();
|
|
1118
1231
|
for (const ve of virtualEntities) {
|
|
1119
1232
|
const viewSchema = ve.SchemaName || schema;
|
|
1120
1233
|
const viewName = ve.ViewName;
|
|
@@ -1190,7 +1303,7 @@ export class ManageMetadataBase {
|
|
|
1190
1303
|
// Refresh so entity.Fields reflect this run's field sync before we snapshot the shape.
|
|
1191
1304
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1192
1305
|
await md.Refresh();
|
|
1193
|
-
const coreSchema =
|
|
1306
|
+
const coreSchema = MjCoreSchema();
|
|
1194
1307
|
const esc = (s) => s.replace(/'/g, "''");
|
|
1195
1308
|
const lit = (s) => (s ? `'${esc(s)}'` : 'NULL');
|
|
1196
1309
|
let processedCount = 0;
|
|
@@ -1253,8 +1366,8 @@ export class ManageMetadataBase {
|
|
|
1253
1366
|
// includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
|
|
1254
1367
|
// migration file needs a GO between statements for Flyway/sqlcmd. Pass the provider's
|
|
1255
1368
|
// separator, not the 'GO' default: on PostgreSQL it is '' and a literal GO breaks replay.
|
|
1256
|
-
await this.
|
|
1257
|
-
await this.
|
|
1369
|
+
await this.logSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
|
|
1370
|
+
await this.logSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true, this.dbProvider.BatchSeparator);
|
|
1258
1371
|
// 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
|
|
1259
1372
|
const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
|
|
1260
1373
|
if (existing.recordset.length > 0) {
|
|
@@ -1262,7 +1375,7 @@ export class ManageMetadataBase {
|
|
|
1262
1375
|
SET SchemaName='${esc(matSchema)}', TableName='${esc(tableName)}', ViewName='${esc(viewName)}',
|
|
1263
1376
|
RefreshSchedule=${lit(decl.RefreshSchedule)}, IntendedWorkload=${lit(decl.IntendedWorkload)}
|
|
1264
1377
|
WHERE ID='${existing.recordset[0].ID}'`;
|
|
1265
|
-
await this.
|
|
1378
|
+
await this.logSQLAndExecute(pool, sqlUpd, `Update MJ: Materialized Results for base-view materialization of ${entity.Name}`);
|
|
1266
1379
|
}
|
|
1267
1380
|
else {
|
|
1268
1381
|
const newId = this.createNewUUID();
|
|
@@ -1274,7 +1387,7 @@ export class ManageMetadataBase {
|
|
|
1274
1387
|
VALUES ( '${newId}', 'EntityBaseView', '${entity.ID}', '${esc(matSchema)}', '${esc(tableName)}', '${esc(viewName)}',
|
|
1275
1388
|
'None', 'FullRebuild', ${lit(decl.RefreshSchedule)}, 'Building', ${lit(decl.IntendedWorkload)},
|
|
1276
1389
|
${this.utcNow()}, ${this.utcNow()} )`;
|
|
1277
|
-
await this.
|
|
1390
|
+
await this.logSQLAndExecute(pool, sqlIns, `Insert MJ: Materialized Results for base-view materialization of ${entity.Name}`);
|
|
1278
1391
|
}
|
|
1279
1392
|
processedCount++;
|
|
1280
1393
|
logStatus(` > Base-view materialization ready for entity "${entity.Name}" → [${matSchema}].[${viewName}]`);
|
|
@@ -1288,7 +1401,7 @@ export class ManageMetadataBase {
|
|
|
1288
1401
|
* fields, or a precise refusal (per-parameter reasons) so non-qualifying queries are skipped loudly.
|
|
1289
1402
|
*/
|
|
1290
1403
|
async classifyParameterizedQueryForMaterialization(pool, queryId, queryName, outputColumns, currentUser) {
|
|
1291
|
-
const coreSchema =
|
|
1404
|
+
const coreSchema = MjCoreSchema();
|
|
1292
1405
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1293
1406
|
// Load the query template + parameter definitions (typed entities for the renderer).
|
|
1294
1407
|
const qRes = await this.runQueryWithParams(pool, `SELECT SQL, UsesTemplate FROM ${this.qs(coreSchema, 'Query')} WHERE ID = @QID`, { QID: queryId });
|
|
@@ -1320,31 +1433,31 @@ export class ManageMetadataBase {
|
|
|
1320
1433
|
// Phase 2 is shipped: enable Bucket-1 row-filter broad materialization. The provider now auto-injects
|
|
1321
1434
|
// the read-time predicate from the persisted ReadFilterSpec (bound params), so a RowFilterBroad
|
|
1322
1435
|
// materialization is safe to mint. `allowRowFilterBroad` remains the build-level kill switch.
|
|
1323
|
-
const classification =
|
|
1324
|
-
if (!classification.
|
|
1325
|
-
const detail = classification.
|
|
1326
|
-
return { qualifies: false, reason: `${classification.
|
|
1436
|
+
const classification = ClassifyQueryParameters({ queryName, params: paramDefs, outputColumns, dialect, render, allowRowFilterBroad: true });
|
|
1437
|
+
if (!classification.Qualification.qualifies) {
|
|
1438
|
+
const detail = classification.PerParam.map((pp) => `${pp.name}: ${pp.verdict.reason}`).join('; ');
|
|
1439
|
+
return { qualifies: false, reason: `${classification.Qualification.reason ?? 'parameters not materializable'}${detail ? ` — [${detail}]` : ''}` };
|
|
1327
1440
|
}
|
|
1328
|
-
if (classification.
|
|
1329
|
-
return { qualifies: false, reason: `parameterization mode '${classification.
|
|
1441
|
+
if (classification.Qualification.paramMode !== 'RowFilterBroad') {
|
|
1442
|
+
return { qualifies: false, reason: `parameterization mode '${classification.Qualification.paramMode}' is not supported for materialization in v1 (only RowFilterBroad)` };
|
|
1330
1443
|
}
|
|
1331
1444
|
// Build the broad source SQL: render a concrete instance (held values) then strip the row-filter predicates.
|
|
1332
1445
|
let renderedHeld;
|
|
1333
1446
|
try {
|
|
1334
|
-
renderedHeld = render(
|
|
1447
|
+
renderedHeld = render(BuildHeldValues(paramDefs));
|
|
1335
1448
|
}
|
|
1336
1449
|
catch (e) {
|
|
1337
1450
|
return { qualifies: false, reason: `could not render the query to build broad SQL: ${e instanceof Error ? e.message : String(e)}` };
|
|
1338
1451
|
}
|
|
1339
|
-
const expectedRemovals = classification.
|
|
1340
|
-
const broad =
|
|
1341
|
-
if (broad.
|
|
1342
|
-
return { qualifies: false, reason: `expected to strip row-filter predicate(s) on [${classification.
|
|
1452
|
+
const expectedRemovals = classification.Qualification.rowFilterColumns.length;
|
|
1453
|
+
const broad = BuildBroadRowFilterSQL(renderedHeld, classification.Qualification.rowFilterColumns, dialect, expectedRemovals);
|
|
1454
|
+
if (broad.RemovedCount === 0) {
|
|
1455
|
+
return { qualifies: false, reason: `expected to strip row-filter predicate(s) on [${classification.Qualification.rowFilterColumns.join(', ')}] but none were removed from the rendered SQL — refusing to avoid a wrongly-filtered materialization` };
|
|
1343
1456
|
}
|
|
1344
|
-
if (broad.
|
|
1345
|
-
return { qualifies: false, reason: `expected to strip exactly ${expectedRemovals} row-filter parameter predicate(s) on [${classification.
|
|
1457
|
+
if (broad.Ambiguous) {
|
|
1458
|
+
return { qualifies: false, reason: `expected to strip exactly ${expectedRemovals} row-filter parameter predicate(s) on [${classification.Qualification.rowFilterColumns.join(', ')}] but matched ${broad.RemovedCount} — the parameter predicate(s) cannot be cleanly isolated from other static or same-named predicates on those columns, so a broad materialization would include or exclude rows the live query never would. Refusing (query stays live-only).` };
|
|
1346
1459
|
}
|
|
1347
|
-
return { qualifies: true, rowFilterColumns: classification.
|
|
1460
|
+
return { qualifies: true, rowFilterColumns: classification.Qualification.rowFilterColumns, broadSQL: broad.Sql, readFilterSpec: classification.Qualification.readFilterSpec };
|
|
1348
1461
|
}
|
|
1349
1462
|
/**
|
|
1350
1463
|
* Processes query materialization (CodeGen materialization phase, sub-step C — plan §4.2).
|
|
@@ -1363,7 +1476,7 @@ export class ManageMetadataBase {
|
|
|
1363
1476
|
* Parameterized queries are skipped with a log (deferred to Phase 2).
|
|
1364
1477
|
*/
|
|
1365
1478
|
async processQueryMaterializations(pool, currentUser) {
|
|
1366
|
-
const coreSchema =
|
|
1479
|
+
const coreSchema = MjCoreSchema();
|
|
1367
1480
|
// Gate the whole method on the IsMaterialized column existing — the flagged-query SELECT below filters on it,
|
|
1368
1481
|
// and it (like the MaterializedResult table) is added by the materialization Foundation migration. On a DB
|
|
1369
1482
|
// where that migration hasn't run yet the SELECT would throw and abort the entire codegen pass; with no such
|
|
@@ -1427,7 +1540,7 @@ export class ManageMetadataBase {
|
|
|
1427
1540
|
// Detected BEFORE the shape analysis so the surrogate column can be typed for the keyed case.
|
|
1428
1541
|
const detectSQL = paramMode === 'RowFilterBroad' ? (broadSQL ?? '') : querySQL;
|
|
1429
1542
|
const matDialect = this.dbProvider.PlatformKey === 'postgresql' ? new PostgreSQLDialect() : new SQLServerDialect();
|
|
1430
|
-
const detectedKeyColumns = detectSQL ?
|
|
1543
|
+
const detectedKeyColumns = detectSQL ? DetectAggregationKeyColumns({ sql: detectSQL, dialect: matDialect, fields }) : null;
|
|
1431
1544
|
// An EXTERNAL query refreshes via rebuildFromExternalQuery, which fetches all rows through the EDS
|
|
1432
1545
|
// driver and writes a SYNTHETIC row-index surrogate — it cannot compute the per-group combined-key
|
|
1433
1546
|
// hash and ignores KeyColumns entirely. So minting a keyed (hash-surrogate) entity for an external
|
|
@@ -1453,7 +1566,7 @@ export class ManageMetadataBase {
|
|
|
1453
1566
|
// The materialized table's column shape is the query's output columns — independent of
|
|
1454
1567
|
// parameterization (a row-filter query is materialized broad over the same columns). Pass
|
|
1455
1568
|
// isParameterized:false so the analyzer yields the shape; the param gate above already ran.
|
|
1456
|
-
const analysis =
|
|
1569
|
+
const analysis = AnalyzeQueryForMaterialization({ queryName, isParameterized: false, fields, surrogateSQLType });
|
|
1457
1570
|
if (!analysis.qualifies) {
|
|
1458
1571
|
logStatus(` > Skipping materialization of query "${queryName}": ${analysis.reason}`);
|
|
1459
1572
|
continue;
|
|
@@ -1471,7 +1584,7 @@ export class ManageMetadataBase {
|
|
|
1471
1584
|
const isKeyedSingleSource = isKeyed && !!detectSQL && SQLParser.ExtractTableRefs(detectSQL, matDialect).length === 1;
|
|
1472
1585
|
const refreshStrategy = !isKeyedSingleSource
|
|
1473
1586
|
? 'FullRebuild'
|
|
1474
|
-
:
|
|
1587
|
+
: DetectAdditiveMeasures(detectSQL)
|
|
1475
1588
|
? 'Incremental'
|
|
1476
1589
|
: 'DirtyGroupRecompute';
|
|
1477
1590
|
// RLS downgrade gate (§6.2, ships day one) — a materialized query entity does NOT inherit its
|
|
@@ -1510,10 +1623,10 @@ export class ManageMetadataBase {
|
|
|
1510
1623
|
logError(` > REFUSING to materialize query "${queryName}": its derived table [${coreSchema}].[${tableName}] (from CodeName "${codeName}") is already owned by a different materialization (SourceType=${foreignOwner.SourceType}, QueryID=${foreignOwner.QueryID ?? 'n/a'}). Two sources whose names normalize to the same CodeName would clobber each other's snapshot — rename one so their CodeNames differ. Skipping.`);
|
|
1511
1624
|
continue;
|
|
1512
1625
|
}
|
|
1513
|
-
const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.
|
|
1626
|
+
const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.Columns);
|
|
1514
1627
|
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
|
|
1515
|
-
await this.
|
|
1516
|
-
await this.
|
|
1628
|
+
await this.logSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
|
|
1629
|
+
await this.logSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true, this.dbProvider.BatchSeparator);
|
|
1517
1630
|
// 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
|
|
1518
1631
|
const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
|
|
1519
1632
|
let generatedEntityId = existingVE.recordset[0]?.ID;
|
|
@@ -1523,7 +1636,7 @@ export class ManageMetadataBase {
|
|
|
1523
1636
|
Name: queryName,
|
|
1524
1637
|
BaseView: viewName,
|
|
1525
1638
|
SchemaName: coreSchema,
|
|
1526
|
-
PrimaryKeyFieldName: analysis.
|
|
1639
|
+
PrimaryKeyFieldName: analysis.SurrogateColumnName,
|
|
1527
1640
|
Description: `Materialized result of query "${queryName}".`,
|
|
1528
1641
|
});
|
|
1529
1642
|
generatedEntityId = createRes.recordset?.[0]?.[''] || createRes.recordset?.[0]?.ID || createRes.recordset?.[0]?.Column0;
|
|
@@ -1562,7 +1675,7 @@ export class ManageMetadataBase {
|
|
|
1562
1675
|
SET GeneratedEntityID=${idLit(generatedEntityId)}, SchemaName='${esc(coreSchema)}', TableName='${esc(tableName)}', ViewName='${esc(viewName)}',
|
|
1563
1676
|
${this.qi('ParamMode')}='${paramMode}', ${this.qi('RowFilterColumns')}=${rowFilterColumnsLit}, ${this.qi('BroadSQL')}=${broadSQLLit}, ${this.qi('ReadFilterSpec')}=${readFilterSpecLit}, ${this.qi('KeyColumns')}=${keyColumnsLit}, ${this.qi('RefreshStrategy')}='${refreshStrategy}'
|
|
1564
1677
|
WHERE ID='${matResultId}'`;
|
|
1565
|
-
await this.
|
|
1678
|
+
await this.logSQLAndExecute(pool, sqlUpd, `Update MJ: Materialized Results for query "${queryName}"`);
|
|
1566
1679
|
}
|
|
1567
1680
|
else {
|
|
1568
1681
|
matResultId = this.createNewUUID();
|
|
@@ -1572,13 +1685,13 @@ export class ManageMetadataBase {
|
|
|
1572
1685
|
${c('ParamMode')}, ${c('RowFilterColumns')}, ${c('BroadSQL')}, ${c('ReadFilterSpec')}, ${c('KeyColumns')}, ${c('RefreshStrategy')}, ${c('Status')}, ${c('__mj_CreatedAt')}, ${c('__mj_UpdatedAt')} )
|
|
1573
1686
|
VALUES ( '${matResultId}', 'Query', ${idLit(generatedEntityId)}, '${esc(coreSchema)}', '${esc(tableName)}', '${esc(viewName)}',
|
|
1574
1687
|
'${paramMode}', ${rowFilterColumnsLit}, ${broadSQLLit}, ${readFilterSpecLit}, ${keyColumnsLit}, '${refreshStrategy}', 'Building', ${this.utcNow()}, ${this.utcNow()} )`;
|
|
1575
|
-
await this.
|
|
1688
|
+
await this.logSQLAndExecute(pool, sqlIns, `Insert MJ: Materialized Results for query "${queryName}"`);
|
|
1576
1689
|
// Link the new materialization to its source Query via the join table.
|
|
1577
1690
|
const joinId = this.createNewUUID();
|
|
1578
1691
|
const sqlJoinIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResultQuery')} (
|
|
1579
1692
|
${c('ID')}, ${c('MaterializedResultID')}, ${c('QueryID')}, ${c('__mj_CreatedAt')}, ${c('__mj_UpdatedAt')} )
|
|
1580
1693
|
VALUES ( '${joinId}', '${matResultId}', '${queryId}', ${this.utcNow()}, ${this.utcNow()} )`;
|
|
1581
|
-
await this.
|
|
1694
|
+
await this.logSQLAndExecute(pool, sqlJoinIns, `Link MJ: Materialized Results to Query "${queryName}" via join row`);
|
|
1582
1695
|
}
|
|
1583
1696
|
processedCount++;
|
|
1584
1697
|
}
|
|
@@ -1595,7 +1708,7 @@ export class ManageMetadataBase {
|
|
|
1595
1708
|
* auto-rebuilds. Already-held (`DriftHold`) and `Disabled` rows are skipped.
|
|
1596
1709
|
*/
|
|
1597
1710
|
async detectMaterializationDrift(pool) {
|
|
1598
|
-
const coreSchema =
|
|
1711
|
+
const coreSchema = MjCoreSchema();
|
|
1599
1712
|
// Gate on MaterializedResult existing — the SELECT below reads it. Absent (materialization migration not yet
|
|
1600
1713
|
// applied on this DB) ⇒ no materializations exist to check for drift; skip cleanly rather than throwing and
|
|
1601
1714
|
// aborting the codegen pass.
|
|
@@ -1700,14 +1813,14 @@ export class ManageMetadataBase {
|
|
|
1700
1813
|
// would only stop FUTURE refreshes while leaving the already-populated rows queryable via raw SQL
|
|
1701
1814
|
// over the wrapper view. Emptying (not dropping) removes the leaked data without breaking any object
|
|
1702
1815
|
// dependency; the wrapper view remains but returns nothing. Then DriftHold so refresh never refills it.
|
|
1703
|
-
await this.
|
|
1704
|
-
await this.
|
|
1816
|
+
await this.logSQLAndExecute(pool, `DELETE FROM ${this.qs(r.SchemaName, r.TableName)}`, `Empty external RLS base-view mirror "${r.TableName}" (leak guard — mirror exposed RLS-refused rows)`);
|
|
1817
|
+
await this.logSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag external RLS base-view materialization "${r.TableName}" as DriftHold (leak guard)`);
|
|
1705
1818
|
logError(` > RLS LEAK GUARD: base-view materialization "${r.TableName}" mirrors an EXTERNAL row-restricted entity ("${bvEntity.Name}" — role RLS and/or an API-key row filter) → mirror EMPTIED + DriftHold (refresh stopped). It exposed rows the live path refuses under RLS.`);
|
|
1706
1819
|
return true;
|
|
1707
1820
|
}
|
|
1708
1821
|
}
|
|
1709
1822
|
const facts = await this.gatherDriftFacts(pool, md, r, coreSchema);
|
|
1710
|
-
const verdict =
|
|
1823
|
+
const verdict = EvaluateMaterializationDrift(facts);
|
|
1711
1824
|
if (verdict.drift) {
|
|
1712
1825
|
// Fail-closed on ANY hold of a QUERY materialization: once held, the row drops out of
|
|
1713
1826
|
// detectMaterializationDrift's `Status NOT IN ('DriftHold', ...)` scan, so the C1 RLS re-check and the
|
|
@@ -1719,7 +1832,7 @@ export class ManageMetadataBase {
|
|
|
1719
1832
|
if (r.SourceType === 'Query' && r.GeneratedEntityID) {
|
|
1720
1833
|
await this.revokeMaterializedEntityReadAccess(pool, r.GeneratedEntityID, r.TableName, `drift hold — ${verdict.reason ?? 'shape/provenance drift'}`);
|
|
1721
1834
|
}
|
|
1722
|
-
await this.
|
|
1835
|
+
await this.logSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag materialization "${r.TableName}" as DriftHold`);
|
|
1723
1836
|
logStatus(` > DRIFT: materialization "${r.TableName}" → DriftHold — ${verdict.reason}`);
|
|
1724
1837
|
return true;
|
|
1725
1838
|
}
|
|
@@ -1753,7 +1866,7 @@ export class ManageMetadataBase {
|
|
|
1753
1866
|
if (r.GeneratedEntityID) {
|
|
1754
1867
|
await this.revokeMaterializedEntityReadAccess(pool, r.GeneratedEntityID, r.TableName, reason);
|
|
1755
1868
|
}
|
|
1756
|
-
await this.
|
|
1869
|
+
await this.logSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag materialization "${r.TableName}" as DriftHold (${label.toLowerCase()})`);
|
|
1757
1870
|
logError(` > ${label}: materialization "${r.TableName}" → read access revoked + DriftHold — ${reason}`);
|
|
1758
1871
|
}
|
|
1759
1872
|
/** Gathers the drift-relevant existence facts for one materialization against current metadata. */
|
|
@@ -1864,7 +1977,7 @@ export class ManageMetadataBase {
|
|
|
1864
1977
|
* @param pool - the ConnectionPool object to use for querying and updating the database
|
|
1865
1978
|
* @returns
|
|
1866
1979
|
*/
|
|
1867
|
-
async
|
|
1980
|
+
async ManageMetadata(pool, currentUser) {
|
|
1868
1981
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1869
1982
|
// Auto-exclude platform-specific system schemas.
|
|
1870
1983
|
// We mutate configInfo.excludeSchemas directly so that all downstream code
|
|
@@ -1885,7 +1998,7 @@ export class ManageMetadataBase {
|
|
|
1885
1998
|
// machinery — when the provider supplies DDL (PostgreSQL), we install it
|
|
1886
1999
|
// idempotently here rather than depending on migrations to have shipped
|
|
1887
2000
|
// it. Nothing below can work without them, so a failure here is fatal.
|
|
1888
|
-
const supportObjectsSQL = this.dbProvider.getMetadataSupportObjectsSQL(
|
|
2001
|
+
const supportObjectsSQL = this.dbProvider.getMetadataSupportObjectsSQL(MjCoreSchema());
|
|
1889
2002
|
if (supportObjectsSQL) {
|
|
1890
2003
|
try {
|
|
1891
2004
|
await pool.query(supportObjectsSQL);
|
|
@@ -1899,7 +2012,7 @@ export class ManageMetadataBase {
|
|
|
1899
2012
|
// Authored exclude list (sys, staging, …) must be captured BEFORE includeSchemas is
|
|
1900
2013
|
// compiled into excludeSchemas. Heal EXEC statements use that original list plus
|
|
1901
2014
|
// @IncludedSchemaNames — never the sibling snapshot of this machine's database.
|
|
1902
|
-
|
|
2015
|
+
SnapshotAuthoredExcludeSchemas(configInfo.excludeSchemas);
|
|
1903
2016
|
// Resolve the opt-in `includeSchemas` positive scope into excludeSchemas, BEFORE the exclude
|
|
1904
2017
|
// snapshot below and before createNewEntities() runs. The universe is queried from the DATABASE
|
|
1905
2018
|
// rather than taken from loaded metadata on purpose: createNewEntities() discovers tables with no
|
|
@@ -1909,12 +2022,12 @@ export class ManageMetadataBase {
|
|
|
1909
2022
|
// every run after, orphaning its entity records). No-op when includeSchemas is unset.
|
|
1910
2023
|
if (configInfo.includeSchemas && configInfo.includeSchemas.length > 0) {
|
|
1911
2024
|
try {
|
|
1912
|
-
const schemaSQL = `SELECT DISTINCT ${this.qi('SchemaName')} FROM ${this.qs(
|
|
2025
|
+
const schemaSQL = `SELECT DISTINCT ${this.qi('SchemaName')} FROM ${this.qs(MjCoreSchema(), 'vwSQLTablesAndEntities')}`;
|
|
1913
2026
|
const schemaResult = await this.runQuery(pool, schemaSQL);
|
|
1914
2027
|
const allSchemas = (schemaResult.recordset ?? [])
|
|
1915
2028
|
.map((r) => r.SchemaName)
|
|
1916
2029
|
.filter((s) => !!s);
|
|
1917
|
-
const newlyExcluded =
|
|
2030
|
+
const newlyExcluded = ApplyIncludeSchemaScope(allSchemas, configInfo);
|
|
1918
2031
|
logStatus(` Applied includeSchemas scope [${configInfo.includeSchemas.join(', ')}] — excluded ${newlyExcluded.length} other schema(s) present in the database`);
|
|
1919
2032
|
}
|
|
1920
2033
|
catch (e) {
|
|
@@ -1964,12 +2077,12 @@ export class ManageMetadataBase {
|
|
|
1964
2077
|
start = new Date();
|
|
1965
2078
|
logStatus(' Recompiling base views...');
|
|
1966
2079
|
const sqlUtility = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
|
|
1967
|
-
const adminSchema =
|
|
1968
|
-
const schemasToExclude =
|
|
2080
|
+
const adminSchema = GetSettingValue('mj_core_schema', '__mj');
|
|
2081
|
+
const schemasToExclude = GetSettingValue('recompile_mj_views', true)
|
|
1969
2082
|
? excludeSchemas.filter((s) => s !== adminSchema)
|
|
1970
2083
|
: excludeSchemas;
|
|
1971
2084
|
if (!await sqlUtility.recompileAllBaseViews(pool, schemasToExclude, true, ManageMetadataBase._newEntityList /*exclude the newly created entities from the above step the first time we run as those views don't exist yet*/)) {
|
|
1972
|
-
|
|
2085
|
+
LogMessage(' Warning: Non-Fatal error recompiling base views', SeverityType.Warning, false);
|
|
1973
2086
|
// many times the former versions of base views will NOT succesfully recompile, so don't consider that scenario to be a
|
|
1974
2087
|
// failure for this entire function
|
|
1975
2088
|
}
|
|
@@ -1980,7 +2093,7 @@ export class ManageMetadataBase {
|
|
|
1980
2093
|
// Also skip deleting unneeded fields on this first pass — base views haven't been regenerated yet,
|
|
1981
2094
|
// so virtual fields (which come from view JOINs) would be incorrectly identified as orphaned and deleted.
|
|
1982
2095
|
// Deletion runs on the second pass (in sql_codegen.ts) after views are current.
|
|
1983
|
-
if (!await this.
|
|
2096
|
+
if (!await this.ManageEntityFields(pool, excludeSchemas, false, false, currentUser, true, true)) {
|
|
1984
2097
|
logError(' Error managing entity fields');
|
|
1985
2098
|
bSuccess = false;
|
|
1986
2099
|
}
|
|
@@ -2062,6 +2175,10 @@ export class ManageMetadataBase {
|
|
|
2062
2175
|
if (organicKeyResult.createdCount > 0 || organicKeyResult.updatedCount > 0) {
|
|
2063
2176
|
logStatus(` > Organic keys: ${organicKeyResult.createdCount} created, ${organicKeyResult.updatedCount} updated from config`);
|
|
2064
2177
|
}
|
|
2178
|
+
if (!organicKeyResult.success) {
|
|
2179
|
+
logError(` Error processing organic keys: ${organicKeyResult.failedCount} key(s) failed and were not applied — see the errors above`);
|
|
2180
|
+
bSuccess = false;
|
|
2181
|
+
}
|
|
2065
2182
|
// Config-driven base-view materialization — emit the physical table + wrapper view and
|
|
2066
2183
|
// attach an "MJ: Materialized Results" row to the existing entity. Runs AFTER entity fields
|
|
2067
2184
|
// are managed so the snapshot shape reflects the current base view (plan §4.1).
|
|
@@ -2089,6 +2206,10 @@ export class ManageMetadataBase {
|
|
|
2089
2206
|
await this.checkEntityFieldSequenceIntegrity(pool, excludeSchemas);
|
|
2090
2207
|
return bSuccess;
|
|
2091
2208
|
}
|
|
2209
|
+
/** @deprecated Use {@link ManageMetadata}. */
|
|
2210
|
+
async manageMetadata(pool, currentUser) {
|
|
2211
|
+
return this.ManageMetadata(pool, currentUser);
|
|
2212
|
+
}
|
|
2092
2213
|
/**
|
|
2093
2214
|
* INTEGRITY CHECK — in a well-formed entity every base (non-virtual) field sequences BEFORE the
|
|
2094
2215
|
* virtual/related fields, so the EntityField order matches the base view's `SELECT [base].*, <joins>`
|
|
@@ -2102,10 +2223,10 @@ export class ManageMetadataBase {
|
|
|
2102
2223
|
async checkEntityFieldSequenceIntegrity(pool, excludeSchemas) {
|
|
2103
2224
|
try {
|
|
2104
2225
|
const excl = excludeSchemas.length > 0
|
|
2105
|
-
? `WHERE ${this.qi('EntityID')} NOT IN (SELECT ${this.qi('ID')} FROM ${this.qs(
|
|
2226
|
+
? `WHERE ${this.qi('EntityID')} NOT IN (SELECT ${this.qi('ID')} FROM ${this.qs(MjCoreSchema(), 'Entity')} WHERE ${this.qi('SchemaName')} IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))`
|
|
2106
2227
|
: '';
|
|
2107
2228
|
const sSQL = `SELECT ${this.qi('EntityID')}, ${this.qi('Entity')}, ${this.qi('Name')}, ${this.qi('Sequence')}, ${this.qi('IsVirtual')}
|
|
2108
|
-
FROM ${this.qs(
|
|
2229
|
+
FROM ${this.qs(MjCoreSchema(), 'vwEntityFields')}
|
|
2109
2230
|
${excl}
|
|
2110
2231
|
ORDER BY ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('Name')}`;
|
|
2111
2232
|
const result = await this.runQuery(pool, sSQL);
|
|
@@ -2157,7 +2278,7 @@ export class ManageMetadataBase {
|
|
|
2157
2278
|
// virtual entities are records defined in the entity metadata and do NOT define a distinct base table
|
|
2158
2279
|
// but they do specify a base view. We DO NOT generate a base view for a virtual entity, we simply use it to figure
|
|
2159
2280
|
// out the fields that should be in the entity definition and add/update/delete the entity definition to match what's in the view when this runs
|
|
2160
|
-
const sql = `SELECT * FROM ${this.qs(
|
|
2281
|
+
const sql = `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntities')} WHERE VirtualEntity = ${this.boolLit(true)}`;
|
|
2161
2282
|
const virtualEntitiesResult = await this.runQuery(pool, sql);
|
|
2162
2283
|
const virtualEntities = virtualEntitiesResult.recordset;
|
|
2163
2284
|
let anyUpdates = false;
|
|
@@ -2217,7 +2338,7 @@ export class ManageMetadataBase {
|
|
|
2217
2338
|
async materializedResultTableExists(pool) {
|
|
2218
2339
|
if (this._materializedResultTableExists === null) {
|
|
2219
2340
|
const sql = `SELECT COUNT(*) AS TblExists FROM INFORMATION_SCHEMA.TABLES ` +
|
|
2220
|
-
`WHERE TABLE_SCHEMA = '${
|
|
2341
|
+
`WHERE TABLE_SCHEMA = '${MjCoreSchema()}' AND TABLE_NAME = 'MaterializedResult'`;
|
|
2221
2342
|
const result = await this.runQuery(pool, sql);
|
|
2222
2343
|
const row = (result.recordset?.[0] ?? {});
|
|
2223
2344
|
const cnt = row.TblExists ?? row.tblexists ?? 0;
|
|
@@ -2235,7 +2356,7 @@ export class ManageMetadataBase {
|
|
|
2235
2356
|
if (!(await this.entityHasExternalDataSourceColumn(pool))) {
|
|
2236
2357
|
return { success: true, anyUpdates: false, relationshipsUpdated: false };
|
|
2237
2358
|
}
|
|
2238
|
-
const sql = `SELECT * FROM ${this.qs(
|
|
2359
|
+
const sql = `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntities')} WHERE ExternalDataSourceID IS NOT NULL`;
|
|
2239
2360
|
const result = await this.runQuery(pool, sql);
|
|
2240
2361
|
const externalEntities = result.recordset;
|
|
2241
2362
|
if (!externalEntities || externalEntities.length === 0) {
|
|
@@ -2352,16 +2473,16 @@ export class ManageMetadataBase {
|
|
|
2352
2473
|
}
|
|
2353
2474
|
// Map each introspected column into the veField shape that manageSingleVirtualEntityField consumes.
|
|
2354
2475
|
const eeFields = obj.Columns.map(c => {
|
|
2355
|
-
const t =
|
|
2476
|
+
const t = MapExternalNativeTypeToMJ(c.NativeType);
|
|
2356
2477
|
return { FieldName: c.Name, Type: t.Type, Length: t.Length, Precision: t.Precision, Scale: t.Scale, AllowsNull: c.Nullable, IsPrimaryKey: c.IsPrimaryKey };
|
|
2357
2478
|
});
|
|
2358
2479
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
2359
2480
|
const entity = md.EntityByName(externalEntity.Name);
|
|
2360
2481
|
if (entity) {
|
|
2361
2482
|
// remove EntityFields no longer present in the remote object
|
|
2362
|
-
const sqlRemove = this.buildExternalFieldRemoveSQL(
|
|
2483
|
+
const sqlRemove = this.buildExternalFieldRemoveSQL(MjCoreSchema(), entity.Fields, eeFields.map(ef => ef.FieldName));
|
|
2363
2484
|
if (sqlRemove) {
|
|
2364
|
-
await this.
|
|
2485
|
+
await this.logSQLAndExecute(pool, sqlRemove, `SQL text to remove fields from external entity ${externalEntity.Name}`);
|
|
2365
2486
|
bUpdated = true;
|
|
2366
2487
|
}
|
|
2367
2488
|
// create/update each remote column. Use the real PK info from introspection; fall back
|
|
@@ -2392,8 +2513,8 @@ export class ManageMetadataBase {
|
|
|
2392
2513
|
}
|
|
2393
2514
|
}
|
|
2394
2515
|
if (bUpdated) {
|
|
2395
|
-
const sqlUpdate = `UPDATE ${this.qs(
|
|
2396
|
-
await this.
|
|
2516
|
+
const sqlUpdate = `UPDATE ${this.qs(MjCoreSchema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${externalEntity.ID}'`;
|
|
2517
|
+
await this.logSQLAndExecute(pool, sqlUpdate, `SQL text to update external entity updated date for ${externalEntity.Name}`);
|
|
2397
2518
|
}
|
|
2398
2519
|
return { success: bSuccess, updatedEntity: bUpdated, relationshipsUpdated: bRelationshipsUpdated };
|
|
2399
2520
|
}
|
|
@@ -2450,7 +2571,7 @@ export class ManageMetadataBase {
|
|
|
2450
2571
|
if (alreadySet) {
|
|
2451
2572
|
continue;
|
|
2452
2573
|
}
|
|
2453
|
-
sqlStatements.push(`UPDATE ${this.qs(
|
|
2574
|
+
sqlStatements.push(`UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
2454
2575
|
SET RelatedEntityID='${relatedEntity.ID}',
|
|
2455
2576
|
RelatedEntityFieldName='${refColumn.replace(/'/g, "''")}',
|
|
2456
2577
|
IsSoftForeignKey=1
|
|
@@ -2460,7 +2581,7 @@ export class ManageMetadataBase {
|
|
|
2460
2581
|
if (sqlStatements.length === 0) {
|
|
2461
2582
|
return false;
|
|
2462
2583
|
}
|
|
2463
|
-
await this.
|
|
2584
|
+
await this.logSQLBatchAndExecute(pool, sqlStatements, `Set external foreign keys for ${externalEntity.Name}`);
|
|
2464
2585
|
return true;
|
|
2465
2586
|
}
|
|
2466
2587
|
async manageSingleVirtualEntity(pool, virtualEntity) {
|
|
@@ -2484,9 +2605,9 @@ export class ManageMetadataBase {
|
|
|
2484
2605
|
removeList.push(f.ID);
|
|
2485
2606
|
}
|
|
2486
2607
|
if (removeList.length > 0) {
|
|
2487
|
-
const sqlRemove = `DELETE FROM ${this.qs(
|
|
2608
|
+
const sqlRemove = `DELETE FROM ${this.qs(MjCoreSchema(), 'EntityField')} WHERE ID IN (${removeList.map(removeId => `'${removeId}'`).join(',')})`;
|
|
2488
2609
|
// this removes the fields that shouldn't be there anymore
|
|
2489
|
-
await this.
|
|
2610
|
+
await this.logSQLAndExecute(pool, sqlRemove, `SQL text to remove fields from entity ${virtualEntity.Name}`);
|
|
2490
2611
|
bUpdated = true;
|
|
2491
2612
|
}
|
|
2492
2613
|
// check to see if any of the fields in the virtual entity have Pkey attribute set. If not, we will default to the first field
|
|
@@ -2506,8 +2627,8 @@ export class ManageMetadataBase {
|
|
|
2506
2627
|
}
|
|
2507
2628
|
if (bUpdated) {
|
|
2508
2629
|
// finally make sure we update the UpdatedAt field for the entity if we made changes to its fields
|
|
2509
|
-
const sqlUpdate = `UPDATE ${this.qs(
|
|
2510
|
-
await this.
|
|
2630
|
+
const sqlUpdate = `UPDATE ${this.qs(MjCoreSchema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${virtualEntity.ID}'`;
|
|
2631
|
+
await this.logSQLAndExecute(pool, sqlUpdate, `SQL text to update virtual entity updated date for ${virtualEntity.Name}`);
|
|
2511
2632
|
}
|
|
2512
2633
|
return { success: bSuccess, updatedEntity: bUpdated };
|
|
2513
2634
|
}
|
|
@@ -2575,7 +2696,7 @@ export class ManageMetadataBase {
|
|
|
2575
2696
|
field.Sequence !== fieldSequence) {
|
|
2576
2697
|
// the field needs to be updated, so update it
|
|
2577
2698
|
const sqlUpdate = `UPDATE
|
|
2578
|
-
${this.qs(
|
|
2699
|
+
${this.qs(MjCoreSchema(), 'EntityField')}
|
|
2579
2700
|
SET
|
|
2580
2701
|
Sequence=${fieldSequence},
|
|
2581
2702
|
Type='${veField.Type}',
|
|
@@ -2586,7 +2707,7 @@ export class ManageMetadataBase {
|
|
|
2586
2707
|
Scale=${veField.Scale}
|
|
2587
2708
|
WHERE
|
|
2588
2709
|
ID = '${field.ID}'`; // don't need to update the __mj_UpdatedAt field here, that happens automatically via the trigger
|
|
2589
|
-
await this.
|
|
2710
|
+
await this.logSQLAndExecute(pool, sqlUpdate, `SQL text to update virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
|
|
2590
2711
|
didUpdate = true;
|
|
2591
2712
|
}
|
|
2592
2713
|
}
|
|
@@ -2596,7 +2717,7 @@ export class ManageMetadataBase {
|
|
|
2596
2717
|
// Apply-time Sequence: see applyTimeEntityFieldSequenceSQL. A literal here would be
|
|
2597
2718
|
// replayed verbatim from the CodeGen capture and collide on a fresh install.
|
|
2598
2719
|
const q = (n) => this.qi(n);
|
|
2599
|
-
const sqlAdd = `INSERT INTO ${this.qs(
|
|
2720
|
+
const sqlAdd = `INSERT INTO ${this.qs(MjCoreSchema(), 'EntityField')} (
|
|
2600
2721
|
${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
|
|
2601
2722
|
${q('Length')}, ${q('Precision')}, ${q('Scale')},
|
|
2602
2723
|
${q('Sequence')}, ${q('IsPrimaryKey')}, ${q('IsUnique')},
|
|
@@ -2606,7 +2727,7 @@ export class ManageMetadataBase {
|
|
|
2606
2727
|
${this.applyTimeEntityFieldSequenceSQL(entity.ID)}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
|
|
2607
2728
|
${this.utcNow()}, ${this.utcNow()}
|
|
2608
2729
|
)`;
|
|
2609
|
-
await this.
|
|
2730
|
+
await this.logSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
|
|
2610
2731
|
ManageMetadataBase.registerNewField(entity.ID, veField.FieldName);
|
|
2611
2732
|
didUpdate = true;
|
|
2612
2733
|
}
|
|
@@ -2702,7 +2823,7 @@ export class ManageMetadataBase {
|
|
|
2702
2823
|
return { decorated: false, skipped: false };
|
|
2703
2824
|
}
|
|
2704
2825
|
// Apply results to EntityField records
|
|
2705
|
-
const schema =
|
|
2826
|
+
const schema = MjCoreSchema();
|
|
2706
2827
|
let anyUpdated = false;
|
|
2707
2828
|
// Apply primary keys
|
|
2708
2829
|
anyUpdated = await this.applyLLMPrimaryKeys(pool, entity, result.primaryKeys, schema) || anyUpdated;
|
|
@@ -2714,7 +2835,7 @@ export class ManageMetadataBase {
|
|
|
2714
2835
|
anyUpdated = await this.applyVEFieldCategories(pool, entity, result) || anyUpdated;
|
|
2715
2836
|
if (anyUpdated) {
|
|
2716
2837
|
const sqlUpdate = `UPDATE ${this.qs(schema, 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${entity.ID}'`;
|
|
2717
|
-
await this.
|
|
2838
|
+
await this.logSQLAndExecute(pool, sqlUpdate, `Update entity timestamp for ${entity.Name} after LLM decoration`);
|
|
2718
2839
|
}
|
|
2719
2840
|
return { decorated: anyUpdated, skipped: false };
|
|
2720
2841
|
}
|
|
@@ -2768,7 +2889,7 @@ export class ManageMetadataBase {
|
|
|
2768
2889
|
return false;
|
|
2769
2890
|
}
|
|
2770
2891
|
// Load VE EntityField rows from DB (we need the ID and auto-update flags)
|
|
2771
|
-
const schema =
|
|
2892
|
+
const schema = MjCoreSchema();
|
|
2772
2893
|
const fieldsSQL = `
|
|
2773
2894
|
SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, AutoUpdateExtendedType, GeneratedFormSection, DisplayName, ExtendedType, CodeType, ValueListType
|
|
2774
2895
|
FROM ${this.qs(schema, 'EntityField')}
|
|
@@ -2832,7 +2953,7 @@ export class ManageMetadataBase {
|
|
|
2832
2953
|
WHERE EntityID='${entity.ID}' AND Name='${pk}'`);
|
|
2833
2954
|
logStatus(` ✓ Set PK for ${entity.Name}.${pk} (LLM-identified)`);
|
|
2834
2955
|
}
|
|
2835
|
-
await this.
|
|
2956
|
+
await this.logSQLBatchAndExecute(pool, sqlStatements, `Set LLM-identified PKs for ${entity.Name}: ${validPKs.join(', ')}`);
|
|
2836
2957
|
return true;
|
|
2837
2958
|
}
|
|
2838
2959
|
/**
|
|
@@ -2877,7 +2998,7 @@ export class ManageMetadataBase {
|
|
|
2877
2998
|
if (sqlStatements.length === 0) {
|
|
2878
2999
|
return false;
|
|
2879
3000
|
}
|
|
2880
|
-
await this.
|
|
3001
|
+
await this.logSQLBatchAndExecute(pool, sqlStatements, `Set LLM-identified FKs for ${entity.Name}`);
|
|
2881
3002
|
return true;
|
|
2882
3003
|
}
|
|
2883
3004
|
/**
|
|
@@ -2917,7 +3038,7 @@ export class ManageMetadataBase {
|
|
|
2917
3038
|
if (sqlStatements.length === 0) {
|
|
2918
3039
|
return false;
|
|
2919
3040
|
}
|
|
2920
|
-
await this.
|
|
3041
|
+
await this.logSQLBatchAndExecute(pool, sqlStatements, `Set LLM-generated descriptions for ${entity.Name} (${sqlStatements.length} fields)`);
|
|
2921
3042
|
return true;
|
|
2922
3043
|
}
|
|
2923
3044
|
/**
|
|
@@ -3042,7 +3163,7 @@ export class ManageMetadataBase {
|
|
|
3042
3163
|
// unquoted reference folds to lower case on PostgreSQL — `column "length" does not exist`.
|
|
3043
3164
|
// SQL Server's case-insensitive resolution hid this for as long as IS-A ran only there.
|
|
3044
3165
|
const existsResult = await this.runQueryWithParams(pool, `SELECT ${this.qi('ID')}, ${this.qi('IsVirtual')}, ${this.qi('Type')}, ${this.qi('Length')}, ${this.qi('Precision')}, ${this.qi('Scale')}, ${this.qi('AllowsNull')}, ${this.qi('AllowUpdateAPI')}
|
|
3045
|
-
FROM ${this.qs(
|
|
3166
|
+
FROM ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
3046
3167
|
WHERE ${this.qi('EntityID')} = @EntityID AND ${this.qi('Name')} = @FieldName`, { 'EntityID': childEntity.ID, 'FieldName': parentField.Name });
|
|
3047
3168
|
if (existsResult.recordset.length > 0) {
|
|
3048
3169
|
// Field already exists — update it to ensure it's marked as a virtual IS-A field
|
|
@@ -3055,7 +3176,7 @@ export class ManageMetadataBase {
|
|
|
3055
3176
|
existingRow.AllowsNull !== parentField.AllowsNull ||
|
|
3056
3177
|
!existingRow.AllowUpdateAPI;
|
|
3057
3178
|
if (needsUpdate) {
|
|
3058
|
-
const sqlUpdate = `UPDATE ${this.qs(
|
|
3179
|
+
const sqlUpdate = `UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
3059
3180
|
SET ${this.qi('IsVirtual')}=${this.boolLit(true)},
|
|
3060
3181
|
${this.qi('Type')}='${parentField.Type}',
|
|
3061
3182
|
${this.qi('Length')}=${parentField.Length},
|
|
@@ -3064,7 +3185,7 @@ export class ManageMetadataBase {
|
|
|
3064
3185
|
${this.qi('AllowsNull')}=${this.boolLit(parentField.AllowsNull)},
|
|
3065
3186
|
${this.qi('AllowUpdateAPI')}=${this.boolLit(true)}
|
|
3066
3187
|
WHERE ${this.qi('ID')}='${existingRow.ID}'`;
|
|
3067
|
-
await this.
|
|
3188
|
+
await this.logSQLAndExecute(pool, sqlUpdate, `Update IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
3068
3189
|
bUpdated = true;
|
|
3069
3190
|
}
|
|
3070
3191
|
}
|
|
@@ -3074,7 +3195,7 @@ export class ManageMetadataBase {
|
|
|
3074
3195
|
// Apply-time Sequence (see applyTimeEntityFieldSequenceSQL); reordered by
|
|
3075
3196
|
// updateExistingEntityFieldsFromSchema afterwards.
|
|
3076
3197
|
const q = (n) => this.qi(n);
|
|
3077
|
-
const sqlInsert = `INSERT INTO ${this.qs(
|
|
3198
|
+
const sqlInsert = `INSERT INTO ${this.qs(MjCoreSchema(), 'EntityField')} (
|
|
3078
3199
|
${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
|
|
3079
3200
|
${q('Length')}, ${q('Precision')}, ${q('Scale')},
|
|
3080
3201
|
${q('Sequence')}, ${q('IsVirtual')}, ${q('AllowUpdateAPI')},
|
|
@@ -3086,7 +3207,7 @@ export class ManageMetadataBase {
|
|
|
3086
3207
|
${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
|
|
3087
3208
|
${this.applyTimeEntityFieldSequenceSQL(childEntity.ID)}, ${this.boolLit(true)}, ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)},
|
|
3088
3209
|
${this.utcNow()}, ${this.utcNow()})`;
|
|
3089
|
-
await this.
|
|
3210
|
+
await this.logSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
3090
3211
|
ManageMetadataBase.registerNewField(childEntity.ID, parentField.Name);
|
|
3091
3212
|
bUpdated = true;
|
|
3092
3213
|
}
|
|
@@ -3098,13 +3219,13 @@ export class ManageMetadataBase {
|
|
|
3098
3219
|
!f.IsPrimaryKey && !f.Name.startsWith('__mj_') &&
|
|
3099
3220
|
!currentParentFieldNames.has(f.Name.toLowerCase()));
|
|
3100
3221
|
for (const staleField of staleFields) {
|
|
3101
|
-
const sqlDelete = `DELETE FROM ${this.qs(
|
|
3102
|
-
await this.
|
|
3222
|
+
const sqlDelete = `DELETE FROM ${this.qs(MjCoreSchema(), 'EntityField')} WHERE ID='${staleField.ID}'`;
|
|
3223
|
+
await this.logSQLAndExecute(pool, sqlDelete, `Remove stale IS-A parent field ${staleField.Name} from ${childEntity.Name}`);
|
|
3103
3224
|
bUpdated = true;
|
|
3104
3225
|
}
|
|
3105
3226
|
if (bUpdated) {
|
|
3106
|
-
const sqlUpdate = `UPDATE ${this.qs(
|
|
3107
|
-
await this.
|
|
3227
|
+
const sqlUpdate = `UPDATE ${this.qs(MjCoreSchema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${childEntity.ID}'`;
|
|
3228
|
+
await this.logSQLAndExecute(pool, sqlUpdate, `Update entity timestamp for ${childEntity.Name} after IS-A field sync`);
|
|
3108
3229
|
}
|
|
3109
3230
|
return { success: true, updated: bUpdated };
|
|
3110
3231
|
}
|
|
@@ -3155,16 +3276,16 @@ export class ManageMetadataBase {
|
|
|
3155
3276
|
try {
|
|
3156
3277
|
// STEP 1 - search for all foreign keys in the vwEntityFields view, we use the RelatedEntityID field to determine our FKs
|
|
3157
3278
|
const sSQL = `SELECT *
|
|
3158
|
-
FROM ${this.qs(
|
|
3279
|
+
FROM ${this.qs(MjCoreSchema(), 'vwEntityFields')}
|
|
3159
3280
|
WHERE
|
|
3160
3281
|
RelatedEntityID IS NOT NULL AND
|
|
3161
3282
|
IsVirtual = ${this.boolLit(false)} AND
|
|
3162
|
-
EntityID NOT IN (SELECT ID FROM ${this.qs(
|
|
3283
|
+
EntityID NOT IN (SELECT ID FROM ${this.qs(MjCoreSchema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
|
|
3163
3284
|
ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
|
|
3164
3285
|
const entityFieldsResult = await this.runQuery(pool, sSQL);
|
|
3165
3286
|
const entityFields = entityFieldsResult.recordset;
|
|
3166
3287
|
// Get the relationship counts for each entity
|
|
3167
|
-
const sSQLRelationshipCount = `SELECT ${this.qi('EntityID')}, COUNT(*) AS ${this.qi('Count')} FROM ${this.qs(
|
|
3288
|
+
const sSQLRelationshipCount = `SELECT ${this.qi('EntityID')}, COUNT(*) AS ${this.qi('Count')} FROM ${this.qs(MjCoreSchema(), 'EntityRelationship')} GROUP BY ${this.qi('EntityID')}`;
|
|
3168
3289
|
const relationshipCountsResult = await this.runQuery(pool, sSQLRelationshipCount);
|
|
3169
3290
|
const relationshipCounts = relationshipCountsResult.recordset;
|
|
3170
3291
|
const relationshipCountMap = new Map();
|
|
@@ -3173,7 +3294,7 @@ export class ManageMetadataBase {
|
|
|
3173
3294
|
relationshipCountMap.set(rc.EntityID, Number(rc.Count));
|
|
3174
3295
|
}
|
|
3175
3296
|
// get all relationships in one query for performance improvement
|
|
3176
|
-
const sSQLRelationship = `SELECT * FROM ${this.qs(
|
|
3297
|
+
const sSQLRelationship = `SELECT * FROM ${this.qs(MjCoreSchema(), 'EntityRelationship')}`;
|
|
3177
3298
|
const allRelationshipsResult = await this.runQuery(pool, sSQLRelationship);
|
|
3178
3299
|
const allRelationships = allRelationshipsResult.recordset;
|
|
3179
3300
|
// Group FK fields by entity pair (ParentEntityID -> ChildEntityID) so we can
|
|
@@ -3199,12 +3320,12 @@ export class ManageMetadataBase {
|
|
|
3199
3320
|
}
|
|
3200
3321
|
batchCount++;
|
|
3201
3322
|
if (batchCount % batchItems === 0 && batchSQL.length > 0) {
|
|
3202
|
-
await this.
|
|
3323
|
+
await this.logSQLAndExecute(pool, batchSQL);
|
|
3203
3324
|
batchSQL = '';
|
|
3204
3325
|
}
|
|
3205
3326
|
}
|
|
3206
3327
|
if (batchSQL.length > 0) {
|
|
3207
|
-
await this.
|
|
3328
|
+
await this.logSQLAndExecute(pool, batchSQL);
|
|
3208
3329
|
}
|
|
3209
3330
|
// NOTE: Stale relationship cleanup is intentionally NOT done here because this
|
|
3210
3331
|
// method runs BEFORE deleteUnneededEntityFields(). Stale EntityField records
|
|
@@ -3245,8 +3366,8 @@ export class ManageMetadataBase {
|
|
|
3245
3366
|
const relCount = relationshipCountMap.get(parentEntityID) || 0;
|
|
3246
3367
|
const sequence = relCount + 1;
|
|
3247
3368
|
const newEntityRelationshipUUID = this.createNewUUID();
|
|
3248
|
-
const checkQuery = `SELECT 1 FROM ${this.qs(
|
|
3249
|
-
const insertSQL = `INSERT INTO ${this.qs(
|
|
3369
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(MjCoreSchema(), 'EntityRelationship')} WHERE ${this.qi('ID')} = '${newEntityRelationshipUUID}'`;
|
|
3370
|
+
const insertSQL = `INSERT INTO ${this.qs(MjCoreSchema(), 'EntityRelationship')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('RelatedEntityID')}, ${this.qi('RelatedEntityJoinField')}, ${this.qi('Type')}, ${this.qi('BundleInAPI')}, ${this.qi('DisplayInForm')}, ${this.qi('Sequence')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')})
|
|
3250
3371
|
VALUES ('${newEntityRelationshipUUID}', '${f.RelatedEntityID}', '${f.EntityID}', '${f.Name}', 'One To Many', ${this.boolLit(true)}, ${this.boolLit(true)}, ${sequence}, ${this.utcNow()}, ${this.utcNow()})`;
|
|
3251
3372
|
relationshipCountMap.set(parentEntityID, sequence);
|
|
3252
3373
|
return `
|
|
@@ -3308,7 +3429,7 @@ export class ManageMetadataBase {
|
|
|
3308
3429
|
logStatus(` > Updating EntityRelationship join field: ${oldJoinField} -> ${newJoinField} (ID: ${r.ID})`);
|
|
3309
3430
|
sql += `
|
|
3310
3431
|
/* Update EntityRelationship join field from '${oldJoinField}' to '${newJoinField}' */
|
|
3311
|
-
UPDATE ${this.qs(
|
|
3432
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityRelationship')}
|
|
3312
3433
|
SET ${this.qi('RelatedEntityJoinField')} = '${newJoinField}',
|
|
3313
3434
|
${this.qi('__mj_UpdatedAt')} = ${this.utcNow()}
|
|
3314
3435
|
WHERE ${this.qi('ID')} = '${r.ID}';
|
|
@@ -3330,18 +3451,18 @@ export class ManageMetadataBase {
|
|
|
3330
3451
|
* @param pool - database connection
|
|
3331
3452
|
* @param excludeSchemas - schemas to exclude from FK field lookup
|
|
3332
3453
|
*/
|
|
3333
|
-
async
|
|
3454
|
+
async CleanupStaleEntityRelationships(pool, excludeSchemas) {
|
|
3334
3455
|
try {
|
|
3335
3456
|
const sSQL = `SELECT *
|
|
3336
|
-
FROM ${this.qs(
|
|
3457
|
+
FROM ${this.qs(MjCoreSchema(), 'vwEntityFields')}
|
|
3337
3458
|
WHERE
|
|
3338
3459
|
RelatedEntityID IS NOT NULL AND
|
|
3339
3460
|
IsVirtual = ${this.boolLit(false)} AND
|
|
3340
|
-
EntityID NOT IN (SELECT ID FROM ${this.qs(
|
|
3461
|
+
EntityID NOT IN (SELECT ID FROM ${this.qs(MjCoreSchema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
|
|
3341
3462
|
ORDER BY RelatedEntityID, EntityID, Sequence, ID`;
|
|
3342
3463
|
const entityFieldsResult = await this.runQuery(pool, sSQL);
|
|
3343
3464
|
const entityFields = entityFieldsResult.recordset;
|
|
3344
|
-
const sSQLRelationship = `SELECT * FROM ${this.qs(
|
|
3465
|
+
const sSQLRelationship = `SELECT * FROM ${this.qs(MjCoreSchema(), 'EntityRelationship')}`;
|
|
3345
3466
|
const allRelationshipsResult = await this.runQuery(pool, sSQLRelationship);
|
|
3346
3467
|
const allRelationships = allRelationshipsResult.recordset;
|
|
3347
3468
|
await this.removeStaleOneToManyRelationships(pool, allRelationships, entityFields);
|
|
@@ -3352,6 +3473,10 @@ export class ManageMetadataBase {
|
|
|
3352
3473
|
return false;
|
|
3353
3474
|
}
|
|
3354
3475
|
}
|
|
3476
|
+
/** @deprecated Use {@link CleanupStaleEntityRelationships}. */
|
|
3477
|
+
async cleanupStaleEntityRelationships(pool, excludeSchemas) {
|
|
3478
|
+
return this.CleanupStaleEntityRelationships(pool, excludeSchemas);
|
|
3479
|
+
}
|
|
3355
3480
|
/**
|
|
3356
3481
|
* Removes stale One-To-Many EntityRelationship records whose RelatedEntityJoinField no longer
|
|
3357
3482
|
* corresponds to a valid FK field in the database. Only removes relationships where
|
|
@@ -3374,10 +3499,10 @@ export class ManageMetadataBase {
|
|
|
3374
3499
|
logStatus(` > Removing stale EntityRelationship: ${r.Entity} -> ${r.RelatedEntity} via '${r.RelatedEntityJoinField}' (ID: ${r.ID})`);
|
|
3375
3500
|
deleteSQL += `
|
|
3376
3501
|
/* Remove stale EntityRelationship: ${r.Entity} -> ${r.RelatedEntity} (FK field '${r.RelatedEntityJoinField}' no longer exists) */
|
|
3377
|
-
DELETE FROM ${this.qs(
|
|
3502
|
+
DELETE FROM ${this.qs(MjCoreSchema(), 'EntityRelationship')} WHERE ${this.qi('ID')} = '${r.ID}';
|
|
3378
3503
|
`;
|
|
3379
3504
|
}
|
|
3380
|
-
await this.
|
|
3505
|
+
await this.logSQLAndExecute(pool, deleteSQL, 'Remove stale One-To-Many EntityRelationships');
|
|
3381
3506
|
}
|
|
3382
3507
|
}
|
|
3383
3508
|
/**
|
|
@@ -3388,7 +3513,7 @@ export class ManageMetadataBase {
|
|
|
3388
3513
|
*/
|
|
3389
3514
|
async checkAndRemoveMetadataForDeletedTables(pool, excludeSchemas) {
|
|
3390
3515
|
try {
|
|
3391
|
-
const sql = `SELECT * FROM ${this.qs(
|
|
3516
|
+
const sql = `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntitiesWithMissingBaseTables')}${this.dbProvider.getEntitiesWithMissingBaseTablesFilter()}`;
|
|
3392
3517
|
const entitiesResult = await this.runQuery(pool, sql);
|
|
3393
3518
|
const entities = entitiesResult.recordset;
|
|
3394
3519
|
if (entities && entities.length > 0) {
|
|
@@ -3411,8 +3536,8 @@ export class ManageMetadataBase {
|
|
|
3411
3536
|
// discardResult: this routine only performs deletions — nothing below reads rows
|
|
3412
3537
|
// from it — and on PostgreSQL it is declared RETURNS SETOF record, which cannot be
|
|
3413
3538
|
// invoked through the default `SELECT * FROM` form at all.
|
|
3414
|
-
const sqlDelete = this.dbProvider.callRoutineSQL(
|
|
3415
|
-
await this.
|
|
3539
|
+
const sqlDelete = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spDeleteEntityWithCoreDependencies', [`'${e.ID}'`], ['EntityID'], true);
|
|
3540
|
+
await this.logSQLAndExecute(pool, sqlDelete, `SQL text to remove entity ${e.Name}`);
|
|
3416
3541
|
logStatus(` > Removed metadata for table ${e.SchemaName}.${e.BaseTable}`);
|
|
3417
3542
|
// Recorded only after the delete succeeds, so a failed removal (logged below for
|
|
3418
3543
|
// an admin to handle) does not mark a schema dirty for an entity still present.
|
|
@@ -3449,9 +3574,9 @@ export class ManageMetadataBase {
|
|
|
3449
3574
|
// Object type codes: P = Stored Procedure, V = View, FN = Scalar Function, IF/TF = Table-Valued Function
|
|
3450
3575
|
const upperType = type.toUpperCase();
|
|
3451
3576
|
const sqlDelete = this.dbProvider.dropObjectSQL(upperType, schemaName, name);
|
|
3452
|
-
await this.
|
|
3577
|
+
await this.logSQLAndExecute(pool, sqlDelete, `SQL text to remove ${type} ${schemaName}.${name}`);
|
|
3453
3578
|
// next up, we need to clean up the cache of saved DB objects that may exist for this entity in the appropriate sub-directory.
|
|
3454
|
-
const sqlOutputDir =
|
|
3579
|
+
const sqlOutputDir = OutputDir('SQL', true);
|
|
3455
3580
|
if (sqlOutputDir) {
|
|
3456
3581
|
// now do the same thing for the /schema directory within the provided directory
|
|
3457
3582
|
const fType = type === 'procedure' ? 'sp' : type === 'view' ? 'view' : 'full_text_search_function';
|
|
@@ -3525,7 +3650,7 @@ export class ManageMetadataBase {
|
|
|
3525
3650
|
* which is the typical "no schema changes since last run" case in Pass 2.
|
|
3526
3651
|
* `undefined` (default) preserves prior full-scan behavior.
|
|
3527
3652
|
*/
|
|
3528
|
-
async
|
|
3653
|
+
async ManageEntityFields(pool, excludeSchemas, skipCreatedAtUpdatedAtDeletedAtFieldValidation, skipEntityFieldValues, currentUser, skipAdvancedGeneration, skipDeleteUnneededFields = false, entityFilter) {
|
|
3529
3654
|
let bSuccess = true;
|
|
3530
3655
|
const startTime = new Date();
|
|
3531
3656
|
// Fast-exit: an explicit empty filter means "no entities changed in Pass 1, so Pass 2 has nothing to do"
|
|
@@ -3578,9 +3703,9 @@ export class ManageMetadataBase {
|
|
|
3578
3703
|
// Fix: derive virtual field AllowsNull from the FK column that drives the JOIN.
|
|
3579
3704
|
// The virtual field name = FK field name minus trailing "ID" (e.g. ClassID → Class).
|
|
3580
3705
|
if (this.dbProvider.NeedsVirtualFieldNullabilityFix) {
|
|
3581
|
-
const fixSQL = this.dbProvider.getFixVirtualFieldNullabilitySQL(
|
|
3706
|
+
const fixSQL = this.dbProvider.getFixVirtualFieldNullabilitySQL(MjCoreSchema());
|
|
3582
3707
|
if (fixSQL) {
|
|
3583
|
-
await this.
|
|
3708
|
+
await this.logSQLAndExecute(pool, fixSQL, 'SQL to fix virtual field nullability');
|
|
3584
3709
|
}
|
|
3585
3710
|
}
|
|
3586
3711
|
// AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
|
|
@@ -3649,17 +3774,37 @@ export class ManageMetadataBase {
|
|
|
3649
3774
|
// Advanced Generation - Smart field identification and form layout
|
|
3650
3775
|
if (!skipAdvancedGeneration) {
|
|
3651
3776
|
const step7StartTime = new Date();
|
|
3652
|
-
|
|
3777
|
+
StartSpinner('Applying AI-powered advanced generation (smart fields, form layout)...');
|
|
3653
3778
|
if (!await this.applyAdvancedGeneration(pool, excludeSchemas, currentUser)) {
|
|
3654
3779
|
logError('Error applying advanced generation features');
|
|
3655
3780
|
// Don't fail the entire process - advanced generation is optional
|
|
3656
3781
|
}
|
|
3657
3782
|
const step7Elapsed = ((new Date().getTime() - step7StartTime.getTime()) / 1000).toFixed(1);
|
|
3658
|
-
|
|
3783
|
+
SucceedSpinner(`Advanced generation completed (${step7Elapsed}s)`);
|
|
3784
|
+
}
|
|
3785
|
+
// Deterministic search-flag hygiene. Deliberately OUTSIDE the `skipAdvancedGeneration`
|
|
3786
|
+
// guard above: advanced generation is off by default, and an entity created under that
|
|
3787
|
+
// default is precisely the one that ends up flagged searchable with nothing searchable on
|
|
3788
|
+
// it. Ordered after it so the model's choices, where it did run, are already applied and
|
|
3789
|
+
// the seed only fills a genuine gap. See buildSearchFlagHygieneSQL.
|
|
3790
|
+
if (!await this.applySearchFlagHygiene(pool, excludeSchemas)) {
|
|
3791
|
+
logError('Error applying search flag hygiene');
|
|
3792
|
+
// FATAL, unlike advanced generation. This pass compares before it writes, so a failure
|
|
3793
|
+
// means the probes could not run — and because nothing is written in that case, the pass
|
|
3794
|
+
// failing is indistinguishable from the pass having nothing to do: no SQL, no artifact,
|
|
3795
|
+
// every gate green. Advanced generation can degrade quietly because it only ever ADDS
|
|
3796
|
+
// model-suggested metadata; this one turns search OFF on entities and is the only thing
|
|
3797
|
+
// keeping AllowUserSearchAPI honest, so a run where it did not execute must not be
|
|
3798
|
+
// reported as a clean run.
|
|
3799
|
+
bSuccess = false;
|
|
3659
3800
|
}
|
|
3660
3801
|
logStatus(` Total time to manage entity fields: ${(new Date().getTime() - startTime.getTime()) / 1000} seconds`);
|
|
3661
3802
|
return bSuccess;
|
|
3662
3803
|
}
|
|
3804
|
+
/** @deprecated Use {@link ManageEntityFields}. */
|
|
3805
|
+
async manageEntityFields(pool, excludeSchemas, skipCreatedAtUpdatedAtDeletedAtFieldValidation, skipEntityFieldValues, currentUser, skipAdvancedGeneration, skipDeleteUnneededFields = false, entityFilter) {
|
|
3806
|
+
return this.ManageEntityFields(pool, excludeSchemas, skipCreatedAtUpdatedAtDeletedAtFieldValidation, skipEntityFieldValues, currentUser, skipAdvancedGeneration, skipDeleteUnneededFields, entityFilter);
|
|
3807
|
+
}
|
|
3663
3808
|
/**
|
|
3664
3809
|
* This method ensures that the __mj_DeletedAt field exists in each entity that has DeleteType=Soft. If the field does not exist, it is created.
|
|
3665
3810
|
*/
|
|
@@ -3668,7 +3813,7 @@ export class ManageMetadataBase {
|
|
|
3668
3813
|
const sqlEntities = `SELECT
|
|
3669
3814
|
*
|
|
3670
3815
|
FROM
|
|
3671
|
-
${this.qs(
|
|
3816
|
+
${this.qs(MjCoreSchema(), 'vwEntities')}
|
|
3672
3817
|
WHERE
|
|
3673
3818
|
VirtualEntity=${this.boolLit(false)} AND
|
|
3674
3819
|
DeleteType='Soft' AND
|
|
@@ -3729,7 +3874,7 @@ export class ManageMetadataBase {
|
|
|
3729
3874
|
}
|
|
3730
3875
|
let totalPKs = 0;
|
|
3731
3876
|
let totalFKs = 0;
|
|
3732
|
-
const schema =
|
|
3877
|
+
const schema = MjCoreSchema();
|
|
3733
3878
|
// Config supports two formats:
|
|
3734
3879
|
// 1. Schema-as-key (template format): { "dbo": [{ "TableName": "Orders", ... }] }
|
|
3735
3880
|
// 2. Flat tables array (legacy format): { "tables": [{ "SchemaName": "dbo", "TableName": "Orders", ... }] }
|
|
@@ -3768,7 +3913,7 @@ export class ManageMetadataBase {
|
|
|
3768
3913
|
${this.qi('IsPrimaryKey')} = ${this.boolLit(true)},
|
|
3769
3914
|
${this.qi('IsSoftPrimaryKey')} = ${this.boolLit(true)}
|
|
3770
3915
|
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${pk.FieldName}'`;
|
|
3771
|
-
const result = await this.
|
|
3916
|
+
const result = await this.logSQLAndExecute(pool, sSQL, `Set soft PK for ${tableSchema}.${tableName}.${pk.FieldName}`);
|
|
3772
3917
|
if (result !== null) {
|
|
3773
3918
|
logStatus(` ✓ Set IsPrimaryKey=1, IsSoftPrimaryKey=1 for ${tableName}.${pk.FieldName}`);
|
|
3774
3919
|
totalPKs++;
|
|
@@ -3812,7 +3957,7 @@ export class ManageMetadataBase {
|
|
|
3812
3957
|
${this.qi('IsSoftForeignKey')} = ${this.boolLit(true)},
|
|
3813
3958
|
${this.qi('AutoUpdateRelatedEntityInfo')} = ${this.boolLit(false)}
|
|
3814
3959
|
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
|
|
3815
|
-
const result = await this.
|
|
3960
|
+
const result = await this.logSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
|
|
3816
3961
|
if (result !== null) {
|
|
3817
3962
|
logStatus(` ✓ Set soft FK for ${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
|
|
3818
3963
|
totalFKs++;
|
|
@@ -3843,7 +3988,7 @@ export class ManageMetadataBase {
|
|
|
3843
3988
|
return true;
|
|
3844
3989
|
try {
|
|
3845
3990
|
// Load all existing EntityFieldValue rows for dedup checking
|
|
3846
|
-
const efvSQL = `SELECT * FROM ${this.qs(
|
|
3991
|
+
const efvSQL = `SELECT * FROM ${this.qs(MjCoreSchema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
|
|
3847
3992
|
const allEFVResult = await this.runQuery(pool, efvSQL);
|
|
3848
3993
|
const allEntityFieldValues = allEFVResult.recordset;
|
|
3849
3994
|
let totalApplied = 0;
|
|
@@ -3869,7 +4014,7 @@ export class ManageMetadataBase {
|
|
|
3869
4014
|
* EntityFieldValue rows already exist (from CHECK constraints) and skips if so.
|
|
3870
4015
|
*/
|
|
3871
4016
|
async applyValueListsForTable(pool, schemaName, tableName, fields, allEntityFieldValues) {
|
|
3872
|
-
const schema =
|
|
4017
|
+
const schema = MjCoreSchema();
|
|
3873
4018
|
let applied = 0;
|
|
3874
4019
|
let skipped = 0;
|
|
3875
4020
|
// Look up entity ID
|
|
@@ -3915,7 +4060,7 @@ export class ManageMetadataBase {
|
|
|
3915
4060
|
const newType = fieldCfg.ValueListType;
|
|
3916
4061
|
if (currentValueListType !== newType.toLowerCase()) {
|
|
3917
4062
|
const updateSQL = `UPDATE ${this.qs(schema, 'EntityField')} SET ValueListType='${newType}' WHERE ID='${entityFieldID}'`;
|
|
3918
|
-
await this.
|
|
4063
|
+
await this.logSQLAndExecute(pool, updateSQL, `Set soft ValueListType='${newType}' for ${schemaName}.${tableName}.${fieldCfg.FieldName}`);
|
|
3919
4064
|
}
|
|
3920
4065
|
logStatus(` ✓ Applied soft value-list for ${tableName}.${fieldCfg.FieldName} (${sortedValues.length} values, type=${newType})`);
|
|
3921
4066
|
return 'applied';
|
|
@@ -3937,7 +4082,7 @@ export class ManageMetadataBase {
|
|
|
3937
4082
|
const sqlEntities = `SELECT
|
|
3938
4083
|
*
|
|
3939
4084
|
FROM
|
|
3940
|
-
${this.qs(
|
|
4085
|
+
${this.qs(MjCoreSchema(), 'vwEntities')}
|
|
3941
4086
|
WHERE
|
|
3942
4087
|
VirtualEntity = ${this.boolLit(false)} AND
|
|
3943
4088
|
${externalEntityClause}
|
|
@@ -4001,7 +4146,7 @@ export class ManageMetadataBase {
|
|
|
4001
4146
|
// and the subsequent UPDATE referencing that column run in separate batches.
|
|
4002
4147
|
// SQL Server compiles an entire batch before executing, so without the separator
|
|
4003
4148
|
// the UPDATE fails with "Invalid column name" since the column doesn't exist yet at compile time.
|
|
4004
|
-
await this.
|
|
4149
|
+
await this.logSQLAndExecute(pool, stmt, `SQL text to add special date field ${fieldName} to entity ${entity.SchemaName}.${entity.BaseTable}`, false, true, this.dbProvider.BatchSeparator);
|
|
4005
4150
|
}
|
|
4006
4151
|
}
|
|
4007
4152
|
else {
|
|
@@ -4014,7 +4159,7 @@ export class ManageMetadataBase {
|
|
|
4014
4159
|
// modifying the column, and finally adding the default constraint back in.
|
|
4015
4160
|
await this.dropExistingDefaultConstraint(pool, entity, fieldName);
|
|
4016
4161
|
const sql = this.dbProvider.alterColumnTypeAndNullabilitySQL(entity.SchemaName, entity.BaseTable, fieldName, this.timestampType, allowNull);
|
|
4017
|
-
await this.
|
|
4162
|
+
await this.logSQLAndExecute(pool, sql, `SQL text to update special date field ${fieldName} in entity ${entity.SchemaName}.${entity.BaseTable}`);
|
|
4018
4163
|
if (!allowNull)
|
|
4019
4164
|
await this.createDefaultConstraintForSpecialDateField(pool, entity, fieldName);
|
|
4020
4165
|
}
|
|
@@ -4044,7 +4189,7 @@ export class ManageMetadataBase {
|
|
|
4044
4189
|
async createDefaultConstraintForSpecialDateField(pool, entity, fieldName) {
|
|
4045
4190
|
try {
|
|
4046
4191
|
const sqlAddDefaultConstraint = this.dbProvider.addDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName, this.utcNow());
|
|
4047
|
-
await this.
|
|
4192
|
+
await this.logSQLAndExecute(pool, sqlAddDefaultConstraint, `SQL text to add default constraint for special date field ${fieldName} in entity ${entity.SchemaName}.${entity.BaseTable}`);
|
|
4048
4193
|
}
|
|
4049
4194
|
catch (e) {
|
|
4050
4195
|
logError(e);
|
|
@@ -4071,7 +4216,7 @@ export class ManageMetadataBase {
|
|
|
4071
4216
|
try {
|
|
4072
4217
|
const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
|
|
4073
4218
|
// DECLARE-bearing block: close its batch in the replayable log (see SQLLogging.appendToSQLLogFile).
|
|
4074
|
-
await this.
|
|
4219
|
+
await this.logSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`, false, true, this.dbProvider.BatchSeparator);
|
|
4075
4220
|
}
|
|
4076
4221
|
catch (e) {
|
|
4077
4222
|
logError(e);
|
|
@@ -4096,15 +4241,15 @@ export class ManageMetadataBase {
|
|
|
4096
4241
|
// "Unclosed quotation mark" (SQL Server) at the very end of an otherwise-complete
|
|
4097
4242
|
// pass. '' doubling is correct on both supported dialects.
|
|
4098
4243
|
const esc = (s) => s.replace(/'/g, "''");
|
|
4099
|
-
const dataResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(
|
|
4244
|
+
const dataResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntities')} WHERE Name = '${esc(e)}'`);
|
|
4100
4245
|
const data = dataResult.recordset;
|
|
4101
|
-
const fieldsResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(
|
|
4246
|
+
const fieldsResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntityFields')} WHERE EntityID='${data[0].ID}'`);
|
|
4102
4247
|
const fields = fieldsResult.recordset;
|
|
4103
4248
|
// Use new API to generate entity description
|
|
4104
4249
|
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);
|
|
4105
4250
|
if (result?.entityDescription && result.entityDescription.length > 0) {
|
|
4106
|
-
const sSQL = `UPDATE ${this.qs(
|
|
4107
|
-
await this.
|
|
4251
|
+
const sSQL = `UPDATE ${this.qs(MjCoreSchema(), 'Entity')} SET ${this.qi('Description')} = '${esc(result.entityDescription)}', ${this.qi('AutoUpdateDescription')} = ${this.boolLit(false)} WHERE ${this.qi('Name')} = '${esc(e)}'`;
|
|
4252
|
+
await this.logSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
|
|
4108
4253
|
}
|
|
4109
4254
|
else {
|
|
4110
4255
|
console.warn(' >>> Advanced Generation Error: LLM returned invalid result, skipping entity description for entity ' + e);
|
|
@@ -4125,9 +4270,9 @@ export class ManageMetadataBase {
|
|
|
4125
4270
|
const sql = `SELECT
|
|
4126
4271
|
ef.ID, ef.Name
|
|
4127
4272
|
FROM
|
|
4128
|
-
${this.qs(
|
|
4273
|
+
${this.qs(MjCoreSchema(), 'vwEntityFields')} ef
|
|
4129
4274
|
INNER JOIN
|
|
4130
|
-
${this.qs(
|
|
4275
|
+
${this.qs(MjCoreSchema(), 'vwEntities')} e
|
|
4131
4276
|
ON
|
|
4132
4277
|
ef.EntityID = e.ID
|
|
4133
4278
|
WHERE
|
|
@@ -4142,8 +4287,8 @@ export class ManageMetadataBase {
|
|
|
4142
4287
|
const namingOptions = configInfo.entityNaming?.normalizeFieldNames !== false ? this.getEntityNamingOptions() : undefined;
|
|
4143
4288
|
const sDisplayName = stripTrailingChars(createDisplayName(field.Name, namingOptions), 'ID', true).trim();
|
|
4144
4289
|
if (sDisplayName.length > 0 && sDisplayName.toLowerCase().trim() !== field.Name.toLowerCase().trim()) {
|
|
4145
|
-
const sSQL = `UPDATE ${this.qs(
|
|
4146
|
-
await this.
|
|
4290
|
+
const sSQL = `UPDATE ${this.qs(MjCoreSchema(), 'EntityField')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()}, DisplayName = '${sDisplayName}' WHERE ID = '${field.ID}'`;
|
|
4291
|
+
await this.logSQLAndExecute(pool, sSQL, `SQL text to update display name for field ${field.Name}`);
|
|
4147
4292
|
}
|
|
4148
4293
|
}
|
|
4149
4294
|
return true;
|
|
@@ -4163,12 +4308,12 @@ export class ManageMetadataBase {
|
|
|
4163
4308
|
*/
|
|
4164
4309
|
async setDefaultColumnWidthWhereNeeded(pool, excludeSchemas) {
|
|
4165
4310
|
try {
|
|
4166
|
-
const heal =
|
|
4167
|
-
authoredExclude:
|
|
4311
|
+
const heal = BuildHealSchemaRoutineParams({
|
|
4312
|
+
authoredExclude: GetAuthoredExcludeSchemas(excludeSchemas),
|
|
4168
4313
|
includeSchemas: configInfo.includeSchemas,
|
|
4169
4314
|
});
|
|
4170
|
-
const sSQL = this.dbProvider.callRoutineSQL(
|
|
4171
|
-
await this.
|
|
4315
|
+
const sSQL = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spSetDefaultColumnWidthWhereNeeded', heal.Values, heal.Names);
|
|
4316
|
+
await this.logSQLAndExecute(pool, sSQL, `SQL text to set default column width where needed`, true);
|
|
4172
4317
|
return true;
|
|
4173
4318
|
}
|
|
4174
4319
|
catch (e) {
|
|
@@ -4188,7 +4333,7 @@ export class ManageMetadataBase {
|
|
|
4188
4333
|
* @returns {string} - The SQL statement to retrieve pending entity fields.
|
|
4189
4334
|
*/
|
|
4190
4335
|
getPendingEntityFieldsSELECTSQL(entityIDs, excludeSchemas) {
|
|
4191
|
-
const schema =
|
|
4336
|
+
const schema = MjCoreSchema();
|
|
4192
4337
|
return this.dbProvider.getPendingEntityFieldsSQL(schema, entityIDs, excludeSchemas);
|
|
4193
4338
|
}
|
|
4194
4339
|
/**
|
|
@@ -4231,7 +4376,7 @@ export class ManageMetadataBase {
|
|
|
4231
4376
|
const quotedDefaultValue = (parsedDefaultValue == null || parsedDefaultValue.trim().length === 0)
|
|
4232
4377
|
? 'NULL'
|
|
4233
4378
|
: (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
|
|
4234
|
-
const conflictCheck = `SELECT 1 FROM ${this.qs(
|
|
4379
|
+
const conflictCheck = `SELECT 1 FROM ${this.qs(MjCoreSchema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
|
|
4235
4380
|
const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
|
|
4236
4381
|
// Sequence is an apply-time expression, never a literal: see applyTimeEntityFieldSequenceSQL.
|
|
4237
4382
|
// `n.Sequence` (MAX+100000+ordinal from the pending SELECT) is only that query's ORDER BY key
|
|
@@ -4239,7 +4384,7 @@ export class ManageMetadataBase {
|
|
|
4239
4384
|
const sequenceExpr = this.applyTimeEntityFieldSequenceSQL(n.EntityID);
|
|
4240
4385
|
return `
|
|
4241
4386
|
${guard.prefix}
|
|
4242
|
-
INSERT INTO ${this.qs(
|
|
4387
|
+
INSERT INTO ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
4243
4388
|
(
|
|
4244
4389
|
${this.qi('ID')},
|
|
4245
4390
|
${this.qi('EntityID')},
|
|
@@ -4354,7 +4499,7 @@ export class ManageMetadataBase {
|
|
|
4354
4499
|
for (let i = 0; i < inserts.length; i += CHUNK_SIZE) {
|
|
4355
4500
|
const chunk = inserts.slice(i, i + CHUNK_SIZE);
|
|
4356
4501
|
try {
|
|
4357
|
-
await this.
|
|
4502
|
+
await this.logSQLBatchAndExecute(pool, chunk, `SQL text to insert ${chunk.length} new entity field(s)`);
|
|
4358
4503
|
// an error blows up the transaction (all-or-nothing), which is what we want
|
|
4359
4504
|
}
|
|
4360
4505
|
catch (e) {
|
|
@@ -4388,10 +4533,10 @@ export class ManageMetadataBase {
|
|
|
4388
4533
|
* @param relatedEntityNameFieldMap
|
|
4389
4534
|
* @returns
|
|
4390
4535
|
*/
|
|
4391
|
-
async
|
|
4536
|
+
async UpdateEntityFieldRelatedEntityNameFieldMap(pool, entityFieldID, relatedEntityNameFieldMap) {
|
|
4392
4537
|
try {
|
|
4393
|
-
const sSQL = this.dbProvider.callRoutineSQL(
|
|
4394
|
-
await this.
|
|
4538
|
+
const sSQL = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spUpdateEntityFieldRelatedEntityNameFieldMap', [`'${entityFieldID}'`, `'${relatedEntityNameFieldMap}'`], ['EntityFieldID', 'RelatedEntityNameFieldMap']);
|
|
4539
|
+
await this.logSQLAndExecute(pool, sSQL, `SQL text to update entity field related entity name field map for entity field ID ${entityFieldID}`);
|
|
4395
4540
|
return true;
|
|
4396
4541
|
}
|
|
4397
4542
|
catch (e) {
|
|
@@ -4399,14 +4544,18 @@ export class ManageMetadataBase {
|
|
|
4399
4544
|
return false;
|
|
4400
4545
|
}
|
|
4401
4546
|
}
|
|
4547
|
+
/** @deprecated Use {@link UpdateEntityFieldRelatedEntityNameFieldMap}. */
|
|
4548
|
+
async updateEntityFieldRelatedEntityNameFieldMap(pool, entityFieldID, relatedEntityNameFieldMap) {
|
|
4549
|
+
return this.UpdateEntityFieldRelatedEntityNameFieldMap(pool, entityFieldID, relatedEntityNameFieldMap);
|
|
4550
|
+
}
|
|
4402
4551
|
async updateExistingEntitiesFromSchema(pool, excludeSchemas) {
|
|
4403
4552
|
try {
|
|
4404
|
-
const heal =
|
|
4405
|
-
authoredExclude:
|
|
4553
|
+
const heal = BuildHealSchemaRoutineParams({
|
|
4554
|
+
authoredExclude: GetAuthoredExcludeSchemas(excludeSchemas),
|
|
4406
4555
|
includeSchemas: configInfo.includeSchemas,
|
|
4407
4556
|
});
|
|
4408
|
-
const sSQL = this.dbProvider.callRoutineSQL(
|
|
4409
|
-
const result = await this.
|
|
4557
|
+
const sSQL = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spUpdateExistingEntitiesFromSchema', heal.Values, heal.Names);
|
|
4558
|
+
const result = await this.logSQLAndExecute(pool, sSQL, `SQL text to update existing entities from schema`, true);
|
|
4410
4559
|
// result contains the updated entities, and there is a property of each row called Name which has the entity name that was modified
|
|
4411
4560
|
// add these to the modified entity list if they're not already in there
|
|
4412
4561
|
if (result && result.length > 0) {
|
|
@@ -4422,30 +4571,34 @@ export class ManageMetadataBase {
|
|
|
4422
4571
|
/**
|
|
4423
4572
|
* Adds a list of entity names to the modified entity list if they're not already in there
|
|
4424
4573
|
*/
|
|
4425
|
-
static
|
|
4574
|
+
static AddNewEntitiesToModifiedList(entityNames) {
|
|
4426
4575
|
const distinctEntityNames = [...new Set(entityNames)];
|
|
4427
4576
|
const newlyModifiedEntityNames = distinctEntityNames.filter((e) => !ManageMetadataBase._modifiedEntityList.includes(e));
|
|
4428
4577
|
// now make sure that each of these entity names is in the modified entity list
|
|
4429
4578
|
ManageMetadataBase._modifiedEntityList = ManageMetadataBase._modifiedEntityList.concat(newlyModifiedEntityNames);
|
|
4430
4579
|
}
|
|
4580
|
+
/** @deprecated Use {@link AddNewEntitiesToModifiedList}. */
|
|
4581
|
+
static addNewEntitiesToModifiedList(entityNames) {
|
|
4582
|
+
return this.AddNewEntitiesToModifiedList(entityNames);
|
|
4583
|
+
}
|
|
4431
4584
|
async updateExistingEntityFieldsFromSchema(pool, excludeSchemas, entityIDs) {
|
|
4432
4585
|
try {
|
|
4433
4586
|
// One SP call regardless of scope: pass the entire entity ID list as a comma-delimited
|
|
4434
4587
|
// string (mirrors the @ExcludedSchemaNames pattern). The SP fans the list out into
|
|
4435
4588
|
// a table variable via STRING_SPLIT and joins once. This avoids both per-entity
|
|
4436
4589
|
// round-trips (slow) and parallel calls (page-level lock contention on EntityField).
|
|
4437
|
-
const heal =
|
|
4438
|
-
authoredExclude:
|
|
4590
|
+
const heal = BuildHealSchemaRoutineParams({
|
|
4591
|
+
authoredExclude: GetAuthoredExcludeSchemas(excludeSchemas),
|
|
4439
4592
|
includeSchemas: configInfo.includeSchemas,
|
|
4440
4593
|
entityIDs,
|
|
4441
4594
|
});
|
|
4442
|
-
const sSQL = this.dbProvider.callRoutineSQL(
|
|
4595
|
+
const sSQL = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spUpdateExistingEntityFieldsFromSchema', heal.Values, heal.Names);
|
|
4443
4596
|
const isScoped = entityIDs !== undefined && entityIDs.length > 0;
|
|
4444
4597
|
const label = isScoped
|
|
4445
4598
|
? `SQL text to update existing entity fields from schema (${entityIDs.length} scoped entities)`
|
|
4446
4599
|
: `SQL text to update existing entity fields from schema`;
|
|
4447
4600
|
const before = await this.snapshotEntityFields(pool, entityIDs);
|
|
4448
|
-
await this.
|
|
4601
|
+
await this.logSQLAndExecute(pool, sSQL, label, true);
|
|
4449
4602
|
const after = await this.snapshotEntityFields(pool, entityIDs);
|
|
4450
4603
|
const changes = diffEntityFieldSnapshots(before, after, (e, n) => ManageMetadataBase.isFieldNew(e, n));
|
|
4451
4604
|
if (changes.length > 0) {
|
|
@@ -4476,12 +4629,12 @@ export class ManageMetadataBase {
|
|
|
4476
4629
|
*/
|
|
4477
4630
|
async updateSchemaInfoFromDatabase(pool, excludeSchemas) {
|
|
4478
4631
|
try {
|
|
4479
|
-
const heal =
|
|
4480
|
-
authoredExclude:
|
|
4632
|
+
const heal = BuildHealSchemaRoutineParams({
|
|
4633
|
+
authoredExclude: GetAuthoredExcludeSchemas(excludeSchemas),
|
|
4481
4634
|
includeSchemas: configInfo.includeSchemas,
|
|
4482
4635
|
});
|
|
4483
|
-
const sSQL = this.dbProvider.callRoutineSQL(
|
|
4484
|
-
const result = await this.
|
|
4636
|
+
const sSQL = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spUpdateSchemaInfoFromDatabase', heal.Values, heal.Names);
|
|
4637
|
+
const result = await this.logSQLAndExecute(pool, sSQL, `SQL text to sync schema info from database schemas`, true);
|
|
4485
4638
|
if (result && result.length > 0) {
|
|
4486
4639
|
logStatus(` > Updated/created ${result.length} SchemaInfo records`);
|
|
4487
4640
|
this.cacheSchemaInfoRecords(result);
|
|
@@ -4511,7 +4664,7 @@ export class ManageMetadataBase {
|
|
|
4511
4664
|
*/
|
|
4512
4665
|
async loadSchemaInfoRecords(pool) {
|
|
4513
4666
|
try {
|
|
4514
|
-
const sSQL = `SELECT * FROM ${this.qs(
|
|
4667
|
+
const sSQL = `SELECT * FROM ${this.qs(MjCoreSchema(), 'SchemaInfo')}`;
|
|
4515
4668
|
const result = await this.runQuery(pool, sSQL);
|
|
4516
4669
|
if (result?.recordset?.length > 0) {
|
|
4517
4670
|
this.cacheSchemaInfoRecords(result.recordset);
|
|
@@ -4577,17 +4730,17 @@ export class ManageMetadataBase {
|
|
|
4577
4730
|
// string (mirrors the @ExcludedSchemaNames pattern). The SP fans the list out into
|
|
4578
4731
|
// a table variable via STRING_SPLIT and filters once. Avoids both per-entity
|
|
4579
4732
|
// round-trips and parallel calls (page-level lock contention on EntityField).
|
|
4580
|
-
const heal =
|
|
4581
|
-
authoredExclude:
|
|
4733
|
+
const heal = BuildHealSchemaRoutineParams({
|
|
4734
|
+
authoredExclude: GetAuthoredExcludeSchemas(excludeSchemas),
|
|
4582
4735
|
includeSchemas: configInfo.includeSchemas,
|
|
4583
4736
|
entityIDs,
|
|
4584
4737
|
});
|
|
4585
|
-
const sSQL = this.dbProvider.callRoutineSQL(
|
|
4738
|
+
const sSQL = this.dbProvider.callRoutineSQL(MjCoreSchema(), 'spDeleteUnneededEntityFields', heal.Values, heal.Names);
|
|
4586
4739
|
const isScoped = entityIDs !== undefined && entityIDs.length > 0;
|
|
4587
4740
|
const label = isScoped
|
|
4588
4741
|
? `SQL text to delete unneeded entity fields (${entityIDs.length} scoped entities)`
|
|
4589
4742
|
: `SQL text to delete unneeded entity fields`;
|
|
4590
|
-
const result = await this.
|
|
4743
|
+
const result = await this.logSQLAndExecute(pool, sSQL, label, true);
|
|
4591
4744
|
// result contains the DELETED entity fields. Get a distinct list of entity names
|
|
4592
4745
|
// and add them to the modified entity list if they're not already in there.
|
|
4593
4746
|
if (result && result.length > 0) {
|
|
@@ -4607,13 +4760,13 @@ export class ManageMetadataBase {
|
|
|
4607
4760
|
// for the field and sync that up with the EntityFieldValue table. If it is not a simple series of OR statements, we will not be able to parse it and we'll
|
|
4608
4761
|
// just ignore it.
|
|
4609
4762
|
const filter = this.dbProvider.getCheckConstraintsSchemaFilter(excludeSchemas);
|
|
4610
|
-
const sSQL = `SELECT * FROM ${this.qs(
|
|
4763
|
+
const sSQL = `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
|
|
4611
4764
|
const resultResult = await this.runQuery(pool, sSQL);
|
|
4612
4765
|
const result = resultResult.recordset;
|
|
4613
|
-
const efvSQL = `SELECT * FROM ${this.qs(
|
|
4766
|
+
const efvSQL = `SELECT * FROM ${this.qs(MjCoreSchema(), 'EntityFieldValue')} ORDER BY EntityFieldID, Sequence, Value, ID`;
|
|
4614
4767
|
const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
|
|
4615
4768
|
const allEntityFieldValues = allEntityFieldValuesResult.recordset;
|
|
4616
|
-
const efSQL = `SELECT * FROM ${this.qs(
|
|
4769
|
+
const efSQL = `SELECT * FROM ${this.qs(MjCoreSchema(), 'vwEntityFields')} ORDER BY EntityID, Sequence, Name, ID`;
|
|
4617
4770
|
const allEntityFieldsResult = await this.runQuery(pool, efSQL);
|
|
4618
4771
|
const allEntityFields = allEntityFieldsResult.recordset;
|
|
4619
4772
|
const generationPromises = [];
|
|
@@ -4631,25 +4784,30 @@ export class ManageMetadataBase {
|
|
|
4631
4784
|
for (const r of columnLevelResults) {
|
|
4632
4785
|
// now, for each of the constraints we get back here, loop through and evaluate if they're simple and if they're simple, parse and sync with entity field values for that field
|
|
4633
4786
|
if (r.ConstraintDefinition && r.ConstraintDefinition.length > 0) {
|
|
4634
|
-
|
|
4787
|
+
// parse first, then decide whether THIS field should carry the list (bit, PK singleton guards).
|
|
4788
|
+
// UUIDsEqual, not ===: PG returns lowercase UUIDs and SQL Server uppercase, and a missed
|
|
4789
|
+
// lookup here fails OPEN (the exclusions stop applying), so a case mismatch would be silent.
|
|
4790
|
+
const field = allEntityFields.find((f) => UUIDsEqual(f.ID, r.EntityFieldID));
|
|
4791
|
+
const parsedValues = ManageMetadataBase.valueListForField(field, this.parseCheckConstraintValues(r.ConstraintDefinition, r.ColumnName, r.EntityName));
|
|
4635
4792
|
if (parsedValues) {
|
|
4636
4793
|
if (!skipDBUpdate) {
|
|
4637
4794
|
// we only do this part if we are not skiping the database update as this code will sync values from the CHECK
|
|
4638
4795
|
// with the EntityFieldValues in the database.
|
|
4639
|
-
// Sort values
|
|
4640
|
-
//
|
|
4641
|
-
//
|
|
4642
|
-
|
|
4796
|
+
// Sort the values to ensure consistent sequences across all databases. This guarantees the
|
|
4797
|
+
// same value always gets the same sequence number regardless of how SQL Server returns
|
|
4798
|
+
// CHECK constraint values (which can vary). An all-numeric list sorts numerically so the
|
|
4799
|
+
// dropdown reads 1, 2, 10 rather than the lexical 1, 10, 2 — still fully deterministic.
|
|
4800
|
+
ManageMetadataBase.sortCheckConstraintValues(parsedValues);
|
|
4643
4801
|
// we have parsed values from the check constraint, so sync them with the entity field values
|
|
4644
4802
|
await this.syncEntityFieldValues(pool, r.EntityFieldID, parsedValues, allEntityFieldValues);
|
|
4645
4803
|
// finally, make sure the ValueListType column within the EntityField table is set to "List" because for check constraints we only allow the values specified in the list.
|
|
4646
4804
|
// check to see if the ValueListType is already set to "List", if not, update it
|
|
4647
|
-
const sSQLCheck = `SELECT ValueListType FROM ${this.qs(
|
|
4805
|
+
const sSQLCheck = `SELECT ValueListType FROM ${this.qs(MjCoreSchema(), 'EntityField')} WHERE ID='${r.EntityFieldID}'`;
|
|
4648
4806
|
const checkResultResult = await this.runQuery(pool, sSQLCheck);
|
|
4649
4807
|
const checkResult = checkResultResult.recordset;
|
|
4650
4808
|
if (checkResult && checkResult.length > 0 && checkResult[0].ValueListType.trim().toLowerCase() !== 'list') {
|
|
4651
|
-
const sSQL = `UPDATE ${this.qs(
|
|
4652
|
-
await this.
|
|
4809
|
+
const sSQL = `UPDATE ${this.qs(MjCoreSchema(), 'EntityField')} SET ValueListType='List' WHERE ID='${r.EntityFieldID}'`;
|
|
4810
|
+
await this.logSQLAndExecute(pool, sSQL, `SQL text to update ValueListType for entity field ID ${r.EntityFieldID}`);
|
|
4653
4811
|
}
|
|
4654
4812
|
}
|
|
4655
4813
|
else {
|
|
@@ -4691,7 +4849,7 @@ export class ManageMetadataBase {
|
|
|
4691
4849
|
* @param pool
|
|
4692
4850
|
* @param currentUser
|
|
4693
4851
|
*/
|
|
4694
|
-
async
|
|
4852
|
+
async LoadGeneratedCode(pool, currentUser) {
|
|
4695
4853
|
try {
|
|
4696
4854
|
// right now we're just doing validator functions which are handled here
|
|
4697
4855
|
return await this.manageEntityFieldValuesAndValidatorFunctions(pool, [], currentUser, true);
|
|
@@ -4701,6 +4859,10 @@ export class ManageMetadataBase {
|
|
|
4701
4859
|
return false;
|
|
4702
4860
|
}
|
|
4703
4861
|
}
|
|
4862
|
+
/** @deprecated Use {@link LoadGeneratedCode}. */
|
|
4863
|
+
async loadGeneratedCode(pool, currentUser) {
|
|
4864
|
+
return this.LoadGeneratedCode(pool, currentUser);
|
|
4865
|
+
}
|
|
4704
4866
|
async runValidationGeneration(r, allEntityFields, generateNewCode, currentUser) {
|
|
4705
4867
|
const generatedFunction = await this.generateValidatorFunctionFromCheckConstraint(r, allEntityFields, currentUser, generateNewCode);
|
|
4706
4868
|
if (generatedFunction?.success) {
|
|
@@ -4781,8 +4943,8 @@ export class ManageMetadataBase {
|
|
|
4781
4943
|
for (const ev of existingValues) {
|
|
4782
4944
|
if (!possibleValues.find(v => v === ev.Value)) {
|
|
4783
4945
|
// delete the value from the database
|
|
4784
|
-
const sSQLDelete = `DELETE FROM ${this.qs(
|
|
4785
|
-
await this.
|
|
4946
|
+
const sSQLDelete = `DELETE FROM ${this.qs(MjCoreSchema(), 'EntityFieldValue')} WHERE ID='${ev.ID}'`;
|
|
4947
|
+
await this.logSQLAndExecute(ds, sSQLDelete, `SQL text to delete entity field value ID ${ev.ID}`);
|
|
4786
4948
|
numRemoved++;
|
|
4787
4949
|
}
|
|
4788
4950
|
}
|
|
@@ -4793,11 +4955,11 @@ export class ManageMetadataBase {
|
|
|
4793
4955
|
// Generate a UUID for this new EntityFieldValue record
|
|
4794
4956
|
const newId = uuidv4();
|
|
4795
4957
|
// add the value to the database with explicit ID
|
|
4796
|
-
const sSQLInsert = `INSERT INTO ${this.qs(
|
|
4958
|
+
const sSQLInsert = `INSERT INTO ${this.qs(MjCoreSchema(), 'EntityFieldValue')}
|
|
4797
4959
|
(${this.qi('ID')}, ${this.qi('EntityFieldID')}, ${this.qi('Sequence')}, ${this.qi('Value')}, ${this.qi('Code')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')})
|
|
4798
4960
|
VALUES
|
|
4799
4961
|
('${newId}', '${entityFieldID}', ${1 + possibleValues.indexOf(v)}, '${v}', '${v}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
4800
|
-
await this.
|
|
4962
|
+
await this.logSQLAndExecute(ds, sSQLInsert, `SQL text to insert entity field value with ID ${newId}`);
|
|
4801
4963
|
numAdded++;
|
|
4802
4964
|
}
|
|
4803
4965
|
}
|
|
@@ -4809,8 +4971,8 @@ export class ManageMetadataBase {
|
|
|
4809
4971
|
// DB returns a string for Sequence instead of a number.
|
|
4810
4972
|
if (ev && ev.Sequence != 1 + possibleValues.indexOf(v)) {
|
|
4811
4973
|
// update the sequence to match the order in the possible values list, if it doesn't already match
|
|
4812
|
-
const sSQLUpdate = `UPDATE ${this.qs(
|
|
4813
|
-
await this.
|
|
4974
|
+
const sSQLUpdate = `UPDATE ${this.qs(MjCoreSchema(), 'EntityFieldValue')} SET Sequence=${1 + possibleValues.indexOf(v)} WHERE ID='${ev.ID}'`;
|
|
4975
|
+
await this.logSQLAndExecute(ds, sSQLUpdate, `SQL text to update entity field value sequence`);
|
|
4814
4976
|
numUpdated++;
|
|
4815
4977
|
}
|
|
4816
4978
|
}
|
|
@@ -4827,10 +4989,24 @@ export class ManageMetadataBase {
|
|
|
4827
4989
|
return false;
|
|
4828
4990
|
}
|
|
4829
4991
|
}
|
|
4992
|
+
/**
|
|
4993
|
+
* Parses a column-level CHECK constraint into the list of values it permits, or returns null when the
|
|
4994
|
+
* constraint is not a simple value list (a range, a `LEN()` predicate, a mixed condition, and so on).
|
|
4995
|
+
*
|
|
4996
|
+
* Both of the ways `IN (...)` is rendered by SQL Server are handled. String, date and GUID literals come
|
|
4997
|
+
* back quoted — `([Status]='Active' OR [Status]='Inactive')` — but numeric and `bit` literals come back
|
|
4998
|
+
* **unquoted and parenthesized**: `([Level]=(3) OR [Level]=(2) OR [Level]=(1))`. Matching only the quoted
|
|
4999
|
+
* form left a numeric IN-list with no value list at all, and so no dropdown in Explorer either (#3978).
|
|
5000
|
+
* PostgreSQL's `= ANY (ARRAY[...])` rendering is handled separately, by parsePgArrayConstraint.
|
|
5001
|
+
*
|
|
5002
|
+
* A single-value list is captured too: `CHECK (Status='Active')` — which is also how SQL Server renders
|
|
5003
|
+
* `IN ('Active')` — permits exactly one value, which is a one-item list.
|
|
5004
|
+
*
|
|
5005
|
+
* This is a parser, not a policy: it reports what the constraint permits. WHICH fields get a value list
|
|
5006
|
+
* out of that is decided after the fact, by valueListForField.
|
|
5007
|
+
*/
|
|
4830
5008
|
parseCheckConstraintValues(constraintDefinition, fieldName, entityName) {
|
|
4831
|
-
//
|
|
4832
|
-
// SQL Server uses [FieldName]='Value' quoting, PostgreSQL uses "FieldName" or unquoted FieldName
|
|
4833
|
-
// We handle both: [FieldName], "FieldName", or bare FieldName
|
|
5009
|
+
// Normalize N'literal' to 'literal' so one pattern handles both.
|
|
4834
5010
|
// Note: Assuming fieldName does not contain regex special characters; otherwise, it needs to be escaped as well.
|
|
4835
5011
|
const processedConstraint = constraintDefinition.replace(/(^|[=(\s])N'([^']*)'/g, "$1'$2'");
|
|
4836
5012
|
// PostgreSQL ANY (ARRAY[...]) pattern:
|
|
@@ -4843,46 +5019,92 @@ export class ManageMetadataBase {
|
|
|
4843
5019
|
// Build a regex fragment that matches the field name in any quoting style:
|
|
4844
5020
|
// [FieldName] (SQL Server) or "FieldName" (PostgreSQL) or bare FieldName
|
|
4845
5021
|
const quotedField = `(?:\\[${fieldName}\\]|"${fieldName}"|${fieldName})`;
|
|
4846
|
-
//
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
5022
|
+
// One literal, either quoting style. The unquoted numeric forms are the ones sys.check_constraints
|
|
5023
|
+
// actually stores: (3) (-1) (1.00) (2.5) (1.0000000000000000e+030) and (12345678901234567890.) —
|
|
5024
|
+
// an integral literal with a trailing point once the precision is large enough.
|
|
5025
|
+
const literal = `(?:'[^']+'|${ManageMetadataBase.NUMERIC_CHECK_LITERAL})`;
|
|
5026
|
+
const assignment = `${quotedField}=${literal}`;
|
|
5027
|
+
// Nested pattern: (Field IS NULL OR (Field=<literal> OR ...)) — how `X IS NULL OR X IN (...)` renders
|
|
5028
|
+
const nestedNullRegex = new RegExp(`^\\(${quotedField} IS NULL OR \\(${assignment}(?: OR ${assignment})*\\)\\)$`);
|
|
5029
|
+
// Standard pattern, with an optional trailing IS NULL: (Field=<literal> OR ... [OR Field IS NULL])
|
|
5030
|
+
const structureRegex = new RegExp(`^\\(${assignment}(?: OR ${assignment})*(?: OR ${quotedField} IS NULL)?\\)$`);
|
|
5031
|
+
if (!nestedNullRegex.test(processedConstraint) && !structureRegex.test(processedConstraint)) {
|
|
5032
|
+
return null;
|
|
5033
|
+
}
|
|
5034
|
+
// Extract the values: group 1 is a quoted literal, group 2 an unquoted numeric one.
|
|
5035
|
+
const valueRegex = new RegExp(`${quotedField}=(?:'([^']+)'|(${ManageMetadataBase.NUMERIC_CHECK_LITERAL}))`, 'g');
|
|
5036
|
+
const possibleValues = [];
|
|
5037
|
+
let match;
|
|
5038
|
+
while ((match = valueRegex.exec(processedConstraint)) !== null) {
|
|
5039
|
+
// This is necessary to avoid infinite loops with zero-width matches
|
|
5040
|
+
if (match.index === valueRegex.lastIndex) {
|
|
5041
|
+
valueRegex.lastIndex++;
|
|
5042
|
+
}
|
|
5043
|
+
const value = match[1] ?? (match[2] ? ManageMetadataBase.normalizeNumericCheckLiteral(match[2]) : undefined);
|
|
5044
|
+
if (value && value.length > 0) {
|
|
5045
|
+
possibleValues.push(value);
|
|
4860
5046
|
}
|
|
4861
|
-
return possibleValues.length > 0 ? possibleValues : null;
|
|
4862
5047
|
}
|
|
4863
|
-
//
|
|
4864
|
-
|
|
4865
|
-
|
|
5048
|
+
// An empty result is not a value list — returning [] here would set ValueListType='List' on a field
|
|
5049
|
+
// with no EntityFieldValue rows behind it.
|
|
5050
|
+
return possibleValues.length > 0 ? possibleValues : null;
|
|
5051
|
+
}
|
|
5052
|
+
/**
|
|
5053
|
+
* The value list to actually store for a field, or null when a parsed list should NOT become
|
|
5054
|
+
* EntityFieldValue rows (and so should not become a dropdown in Explorer either).
|
|
5055
|
+
*
|
|
5056
|
+
* Runs AFTER parsing rather than gating it, so each exclusion is no broader than its own reason and
|
|
5057
|
+
* neither one can drop a list that was captured before #3978:
|
|
5058
|
+
*
|
|
5059
|
+
* - `bit`: `IN (0,1)` is vacuous — bit already permits exactly 0 and 1 — and `= 1` is a validator,
|
|
5060
|
+
* not a two-item dropdown over what renders as a checkbox. Every bit list is rendered unquoted, so
|
|
5061
|
+
* none of them was reachable before this change and nothing regresses.
|
|
5062
|
+
* - a PRIMARY KEY carrying a SINGLE value: `CHECK (ID=1)` is the single-row-table guard MJ's own
|
|
5063
|
+
* sequence tables use — a structural invariant, not a set of values a user picks from. A
|
|
5064
|
+
* MULTI-value list on a primary key is left alone: `CHECK (Code IN ('US','CA'))` on a natural-key
|
|
5065
|
+
* PK is a real domain list, it was captured before this change, and CodeGen also runs over customer
|
|
5066
|
+
* schemas where natural-key primary keys are ordinary.
|
|
5067
|
+
*
|
|
5068
|
+
* A field whose metadata row was not found keeps whatever parsed, which is the pre-#3978 behaviour.
|
|
5069
|
+
*/
|
|
5070
|
+
static valueListForField(field, parsedValues) {
|
|
5071
|
+
if (!parsedValues || !field) {
|
|
5072
|
+
return parsedValues;
|
|
5073
|
+
}
|
|
5074
|
+
if (field.Type?.trim().toLowerCase() === 'bit') {
|
|
4866
5075
|
return null;
|
|
4867
5076
|
}
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
const valueRegex = new RegExp(`${quotedField}='([^']+)\'`, 'g');
|
|
4871
|
-
let match;
|
|
4872
|
-
const possibleValues = [];
|
|
4873
|
-
// Use regex to find matches and extract the values
|
|
4874
|
-
while ((match = valueRegex.exec(processedConstraint)) !== null) {
|
|
4875
|
-
// This is necessary to avoid infinite loops with zero-width matches
|
|
4876
|
-
if (match.index === valueRegex.lastIndex) {
|
|
4877
|
-
valueRegex.lastIndex++;
|
|
4878
|
-
}
|
|
4879
|
-
// The first captured group contains the value
|
|
4880
|
-
if (match[1]) {
|
|
4881
|
-
possibleValues.push(match[1]);
|
|
4882
|
-
}
|
|
4883
|
-
}
|
|
4884
|
-
return possibleValues;
|
|
5077
|
+
if (field.IsPrimaryKey && parsedValues.length === 1) {
|
|
5078
|
+
return null;
|
|
4885
5079
|
}
|
|
5080
|
+
return parsedValues;
|
|
5081
|
+
}
|
|
5082
|
+
/**
|
|
5083
|
+
* Sorts a parsed value list in place, so the sequence a value gets is stable across databases and runs.
|
|
5084
|
+
* An all-numeric list is compared as numbers (1, 2, 10); anything else keeps the default lexical order.
|
|
5085
|
+
*
|
|
5086
|
+
* Keyed off the VALUES, not the column type — so a string column whose list happens to be all-numeric
|
|
5087
|
+
* (`CHECK (Code IN ('1','2','10'))` on nvarchar) would re-sequence too. Measured over every column CHECK
|
|
5088
|
+
* constraint in a live 6.x database (MJ core + BizApps accounting/orders): of the 431 lists the previous
|
|
5089
|
+
* parser already captured, ZERO re-sequence under this comparison. If one ever turns up and the churn is
|
|
5090
|
+
* unwanted, gate `allNumeric` on the field being numeric as well.
|
|
5091
|
+
*/
|
|
5092
|
+
static sortCheckConstraintValues(values) {
|
|
5093
|
+
const allNumeric = values.length > 0 && values.every(v => /^-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?$/.test(v));
|
|
5094
|
+
return allNumeric ? values.sort((a, b) => Number(a) - Number(b)) : values.sort();
|
|
5095
|
+
}
|
|
5096
|
+
/**
|
|
5097
|
+
* A numeric literal as SQL Server renders it inside a CHECK constraint: parenthesized, unquoted, and
|
|
5098
|
+
* possibly signed, fractional or exponential — `(3)`, `(-1)`, `(1.00)`, `(2.5)`, `(1.0e+030)`.
|
|
5099
|
+
*/
|
|
5100
|
+
static { this.NUMERIC_CHECK_LITERAL = `\\(-?\\d+(?:\\.\\d*)?(?:[eE][+-]?\\d+)?\\)`; }
|
|
5101
|
+
/**
|
|
5102
|
+
* Turns a matched numeric literal into the value to store: strips the wrapping parentheses, and the
|
|
5103
|
+
* trailing point SQL Server adds to a large integral literal (`(12345678901234567890.)`).
|
|
5104
|
+
*/
|
|
5105
|
+
static normalizeNumericCheckLiteral(literal) {
|
|
5106
|
+
const inner = literal.slice(1, -1);
|
|
5107
|
+
return inner.endsWith('.') ? inner.slice(0, -1) : inner;
|
|
4886
5108
|
}
|
|
4887
5109
|
/**
|
|
4888
5110
|
* Parses PostgreSQL ANY (ARRAY[...]) CHECK constraint syntax.
|
|
@@ -4945,7 +5167,7 @@ export class ManageMetadataBase {
|
|
|
4945
5167
|
// it — user-visible AND editable, letting someone mutate the snapshot the refresher overwrites. The
|
|
4946
5168
|
// wrapper view is already excluded (it carries the minted entity, so EntityID IS NOT NULL). The outer
|
|
4947
5169
|
// view is aliased `t` so the correlated MaterializedResult subquery can't resolve columns ambiguously.
|
|
4948
|
-
const coreSchema =
|
|
5170
|
+
const coreSchema = MjCoreSchema();
|
|
4949
5171
|
// Gate the MaterializedResult reference on the table actually existing. Referencing it unconditionally
|
|
4950
5172
|
// would throw — breaking new-entity creation for EVERY entity — on any DB where MaterializedResult
|
|
4951
5173
|
// isn't present yet (e.g. the PostgreSQL parallel world's object-availability lag). If it's absent we
|
|
@@ -5089,7 +5311,7 @@ export class ManageMetadataBase {
|
|
|
5089
5311
|
const result = await ag.generateEntityName(newEntity.TableName, currentUser);
|
|
5090
5312
|
// Checked here as well as inside generateEntityName: a subclass or a stub can return anything, and
|
|
5091
5313
|
// a non-name that reaches the INSERT fails it (see createNewEntity's catch for why that used to be silent).
|
|
5092
|
-
if (result &&
|
|
5314
|
+
if (result && IsPlausibleEntityName(result.entityName)) {
|
|
5093
5315
|
return this.markupEntityName(newEntity.SchemaName, result.entityName);
|
|
5094
5316
|
}
|
|
5095
5317
|
else {
|
|
@@ -5154,7 +5376,7 @@ export class ManageMetadataBase {
|
|
|
5154
5376
|
return configInfo.newEntityDefaults?.NameRulesBySchema?.find(r => {
|
|
5155
5377
|
let schemaNameToUse = r.SchemaName;
|
|
5156
5378
|
if (schemaNameToUse?.trim().toLowerCase() === '${mj_core_schema}') {
|
|
5157
|
-
schemaNameToUse =
|
|
5379
|
+
schemaNameToUse = MjCoreSchema();
|
|
5158
5380
|
}
|
|
5159
5381
|
return schemaNameToUse.trim().toLowerCase() === schemaName.trim().toLowerCase();
|
|
5160
5382
|
});
|
|
@@ -5204,7 +5426,7 @@ export class ManageMetadataBase {
|
|
|
5204
5426
|
// process a single new entity
|
|
5205
5427
|
let newEntityName = await this.createNewEntityName(newEntity, currentUser);
|
|
5206
5428
|
const newEntityDisplayName = this.createNewEntityDisplayName(newEntity, newEntityName);
|
|
5207
|
-
const { name: uniqueName, suffix: uniqueSuffix } = this.
|
|
5429
|
+
const { name: uniqueName, suffix: uniqueSuffix } = this.ResolveUniqueEntityName(newEntityName, newEntity.SchemaName, md.Entities.map(e => e.Name));
|
|
5208
5430
|
if (uniqueName !== newEntityName) {
|
|
5209
5431
|
LogError(` >>>> WARNING: Entity name already exists, so using ${uniqueName} instead. If you did not intend for this, please rename the ${newEntity.SchemaName}.${newEntity.TableName} table in the database.`);
|
|
5210
5432
|
}
|
|
@@ -5213,7 +5435,7 @@ export class ManageMetadataBase {
|
|
|
5213
5435
|
const isNewSchema = await this.isSchemaNew(pool, newEntity.SchemaName);
|
|
5214
5436
|
const newEntityID = this.createNewUUID();
|
|
5215
5437
|
const sSQLInsert = this.createNewEntityInsertSQL(newEntityID, newEntityName, newEntity, suffix, newEntityDisplayName);
|
|
5216
|
-
await this.
|
|
5438
|
+
await this.logSQLAndExecute(pool, sSQLInsert, `SQL generated to create new entity ${newEntityName}`);
|
|
5217
5439
|
// if we get here we created a new entity safely, otherwise we get exception
|
|
5218
5440
|
// add it to the new entity list
|
|
5219
5441
|
ManageMetadataBase.newEntityList.push(newEntityName);
|
|
@@ -5246,10 +5468,10 @@ export class ManageMetadataBase {
|
|
|
5246
5468
|
if (configInfo.newEntityDefaults.AddToApplicationWithSchemaName) {
|
|
5247
5469
|
// only do this if the configuration setting is set to add new entities to applications for schema names
|
|
5248
5470
|
for (const appUUID of apps) {
|
|
5249
|
-
const sSQLInsertApplicationEntity = `INSERT INTO ${this.qs(
|
|
5471
|
+
const sSQLInsertApplicationEntity = `INSERT INTO ${this.qs(MjCoreSchema(), 'ApplicationEntity')}
|
|
5250
5472
|
(${this.qi('ApplicationID')}, ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
|
|
5251
|
-
('${appUUID}', '${newEntityID}', (SELECT COALESCE(MAX(${this.qi('Sequence')}),0)+1 FROM ${this.qs(
|
|
5252
|
-
await this.
|
|
5473
|
+
('${appUUID}', '${newEntityID}', (SELECT COALESCE(MAX(${this.qi('Sequence')}),0)+1 FROM ${this.qs(MjCoreSchema(), 'ApplicationEntity')} WHERE ${this.qi('ApplicationID')} = '${appUUID}'), ${this.utcNow()}, ${this.utcNow()})`;
|
|
5474
|
+
await this.logSQLAndExecute(pool, sSQLInsertApplicationEntity, `SQL generated to add new entity ${newEntityName} to application ID: '${appUUID}'`);
|
|
5253
5475
|
}
|
|
5254
5476
|
}
|
|
5255
5477
|
else {
|
|
@@ -5268,7 +5490,7 @@ export class ManageMetadataBase {
|
|
|
5268
5490
|
const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
|
|
5269
5491
|
if (RoleID) {
|
|
5270
5492
|
const sSQLInsertPermission = this.buildEntityPermissionInsertSQL(newEntityID, RoleID, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete);
|
|
5271
|
-
await this.
|
|
5493
|
+
await this.logSQLAndExecute(pool, sSQLInsertPermission, `SQL generated to add new permission for entity ${newEntityName} for role ${p.RoleName}`);
|
|
5272
5494
|
}
|
|
5273
5495
|
else
|
|
5274
5496
|
LogError(` >>>> ERROR: Unable to find Role ID for role ${p.RoleName} to add permissions for new entity ${newEntityName}`);
|
|
@@ -5302,7 +5524,7 @@ export class ManageMetadataBase {
|
|
|
5302
5524
|
async isSchemaNew(pool, schemaName) {
|
|
5303
5525
|
// check to see if there are any entities in the db with this schema name
|
|
5304
5526
|
// Quote the alias so PostgreSQL preserves PascalCase (unquoted aliases are lowercased in PG)
|
|
5305
|
-
const sSQL = `SELECT COUNT(*) AS ${this.qi('Count')} FROM ${this.qs(
|
|
5527
|
+
const sSQL = `SELECT COUNT(*) AS ${this.qi('Count')} FROM ${this.qs(MjCoreSchema(), 'Entity')} WHERE ${this.qi('SchemaName')} = '${schemaName}'`;
|
|
5306
5528
|
const resultResult = await this.runQuery(pool, sSQL);
|
|
5307
5529
|
const result = resultResult.recordset;
|
|
5308
5530
|
// Use Number() because PG returns COUNT(*) as bigint, which the pg driver delivers as a string
|
|
@@ -5334,7 +5556,7 @@ export class ManageMetadataBase {
|
|
|
5334
5556
|
// Guard the INSERT with an existence check so the emitted (and re-runnable) migration SQL is
|
|
5335
5557
|
// idempotent. drop-schema clears an app's ApplicationEntity/EntityPermission/Entity rows but NOT
|
|
5336
5558
|
// its Application row, so replaying this block would otherwise collide on the Application PK.
|
|
5337
|
-
const appCheckQuery = `SELECT 1 FROM ${this.qs(
|
|
5559
|
+
const appCheckQuery = `SELECT 1 FROM ${this.qs(MjCoreSchema(), 'Application')} WHERE ${this.qi('ID')} = '${appID}'`;
|
|
5338
5560
|
// Schema-named bucket apps are plumbing (entity links, role grants, SchemaAutoAddNewEntities),
|
|
5339
5561
|
// not products — hide them from new users. Application.DefaultForNewUser defaults to 1 in the
|
|
5340
5562
|
// DB, so omitting the column here is what put raw '__mj_*'-named apps in every new user's
|
|
@@ -5343,10 +5565,10 @@ export class ManageMetadataBase {
|
|
|
5343
5565
|
// statement in PG's `DO $$ ... $$` block, and the identifier auto-quoter skips dollar-quoted
|
|
5344
5566
|
// blocks wholesale (they can legally contain arbitrary text), so nothing downstream will quote
|
|
5345
5567
|
// these for us. Bare `ID` reaches PG folded to `id` and the INSERT fails on every run.
|
|
5346
|
-
const appInsert = `INSERT INTO ${this.qs(
|
|
5568
|
+
const appInsert = `INSERT INTO ${this.qs(MjCoreSchema(), 'Application')} (${this.qi('ID')}, ${this.qi('Name')}, ${this.qi('Description')}, ${this.qi('SchemaAutoAddNewEntities')}, ${this.qi('Path')}, ${this.qi('AutoUpdatePath')}, ${this.qi('DefaultForNewUser')})
|
|
5347
5569
|
VALUES ('${appID}', '${appName}', 'Generated for schema', '${schemaName}', '${path}', ${this.dialect.BooleanLiteral(true)}, ${this.dialect.BooleanLiteral(false)})`;
|
|
5348
5570
|
const sSQL = this.conditionalInsert(appCheckQuery, appInsert);
|
|
5349
|
-
await this.
|
|
5571
|
+
await this.logSQLAndExecute(pool, sSQL, `SQL generated to create new application ${appName}`);
|
|
5350
5572
|
LogStatus(`Created new application ${appName} with Path: ${path}`);
|
|
5351
5573
|
// Auto-assign default roles to the new application
|
|
5352
5574
|
await this.addDefaultRolesForApplication(pool, appID, appName);
|
|
@@ -5374,12 +5596,12 @@ export class ManageMetadataBase {
|
|
|
5374
5596
|
// This covers BOTH re-running the block after drop-schema (which leaves ApplicationRole rows
|
|
5375
5597
|
// intact) AND the collision with the Integration role that manageMetadata recreates
|
|
5376
5598
|
// out-of-band — either path would otherwise violate UQ_ApplicationRole_App_Role.
|
|
5377
|
-
const roleCheckQuery = `SELECT 1 FROM ${this.qs(
|
|
5378
|
-
const roleInsert = `INSERT INTO ${this.qs(
|
|
5599
|
+
const roleCheckQuery = `SELECT 1 FROM ${this.qs(MjCoreSchema(), 'ApplicationRole')} WHERE ${this.qi('ApplicationID')} = '${appId}' AND ${this.qi('RoleID')} = '${role.ID}'`;
|
|
5600
|
+
const roleInsert = `INSERT INTO ${this.qs(MjCoreSchema(), 'ApplicationRole')}
|
|
5379
5601
|
(${this.qi('ApplicationID')}, ${this.qi('RoleID')}, ${this.qi('CanAccess')}, ${this.qi('CanAdmin')}) VALUES
|
|
5380
5602
|
('${appId}', '${role.ID}', ${this.boolLit(roleDef.CanAccess)}, ${this.boolLit(roleDef.CanAdmin)})`;
|
|
5381
5603
|
const sSQLInsert = this.conditionalInsert(roleCheckQuery, roleInsert);
|
|
5382
|
-
await this.
|
|
5604
|
+
await this.logSQLAndExecute(pool, sSQLInsert, `Adding role ${roleDef.RoleName} to application ${appName}`);
|
|
5383
5605
|
}
|
|
5384
5606
|
else {
|
|
5385
5607
|
LogError(`Unable to find Role '${roleDef.RoleName}' for application ${appName}`);
|
|
@@ -5387,14 +5609,14 @@ export class ManageMetadataBase {
|
|
|
5387
5609
|
}
|
|
5388
5610
|
}
|
|
5389
5611
|
async applicationExists(pool, applicationName) {
|
|
5390
|
-
const sSQL = `SELECT ID FROM ${this.qs(
|
|
5612
|
+
const sSQL = `SELECT ID FROM ${this.qs(MjCoreSchema(), 'Application')} WHERE Name = '${applicationName}'`;
|
|
5391
5613
|
const resultResult = await this.runQuery(pool, sSQL);
|
|
5392
5614
|
const result = resultResult.recordset;
|
|
5393
5615
|
return result && result.length > 0 ? result[0].ID.length > 0 : false;
|
|
5394
5616
|
}
|
|
5395
5617
|
async getApplicationIDForSchema(pool, schemaName) {
|
|
5396
5618
|
// get all the apps each time from DB as we might be adding, don't use Metadata here for that reason
|
|
5397
|
-
const sSQL = `SELECT ID, Name, SchemaAutoAddNewEntities FROM ${this.qs(
|
|
5619
|
+
const sSQL = `SELECT ID, Name, SchemaAutoAddNewEntities FROM ${this.qs(MjCoreSchema(), 'vwApplications')}`;
|
|
5398
5620
|
const resultResult = await this.runQuery(pool, sSQL);
|
|
5399
5621
|
const result = resultResult.recordset;
|
|
5400
5622
|
if (!result || result.length === 0) {
|
|
@@ -5436,10 +5658,10 @@ export class ManageMetadataBase {
|
|
|
5436
5658
|
if (apps && apps.length > 0) {
|
|
5437
5659
|
if (configInfo.newEntityDefaults.AddToApplicationWithSchemaName) {
|
|
5438
5660
|
for (const appUUID of apps) {
|
|
5439
|
-
const sSQLInsert = `INSERT INTO ${this.qs(
|
|
5661
|
+
const sSQLInsert = `INSERT INTO ${this.qs(MjCoreSchema(), 'ApplicationEntity')}
|
|
5440
5662
|
(${this.qi('ApplicationID')}, ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
|
|
5441
|
-
('${appUUID}', '${entityId}', (SELECT COALESCE(MAX(${this.qi('Sequence')}),0)+1 FROM ${this.qs(
|
|
5442
|
-
await this.
|
|
5663
|
+
('${appUUID}', '${entityId}', (SELECT COALESCE(MAX(${this.qi('Sequence')}),0)+1 FROM ${this.qs(MjCoreSchema(), 'ApplicationEntity')} WHERE ${this.qi('ApplicationID')} = '${appUUID}'), ${this.utcNow()}, ${this.utcNow()})`;
|
|
5664
|
+
await this.logSQLAndExecute(pool, sSQLInsert, `SQL generated to add entity ${entityName} to application ID: '${appUUID}'`);
|
|
5443
5665
|
}
|
|
5444
5666
|
}
|
|
5445
5667
|
}
|
|
@@ -5465,7 +5687,7 @@ export class ManageMetadataBase {
|
|
|
5465
5687
|
* valid on both SQL Server and PostgreSQL, so no provider branch is needed.
|
|
5466
5688
|
*/
|
|
5467
5689
|
buildEntityPermissionInsertSQL(entityId, roleId, canRead, canCreate, canUpdate, canDelete) {
|
|
5468
|
-
const table = this.qs(
|
|
5690
|
+
const table = this.qs(MjCoreSchema(), 'EntityPermission');
|
|
5469
5691
|
const entityLit = this.uuidLit(entityId);
|
|
5470
5692
|
const roleLit = this.uuidLit(roleId);
|
|
5471
5693
|
return `INSERT INTO ${table}
|
|
@@ -5490,7 +5712,7 @@ export class ManageMetadataBase {
|
|
|
5490
5712
|
const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
|
|
5491
5713
|
if (RoleID) {
|
|
5492
5714
|
const sSQLInsert = this.buildEntityPermissionInsertSQL(entityId, RoleID, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete);
|
|
5493
|
-
await this.
|
|
5715
|
+
await this.logSQLAndExecute(pool, sSQLInsert, `SQL generated to add permission for entity ${entityName} for role ${p.RoleName}`);
|
|
5494
5716
|
}
|
|
5495
5717
|
else {
|
|
5496
5718
|
LogError(` >>>> ERROR: Unable to find Role ID for role ${p.RoleName} to add permissions for entity ${entityName}`);
|
|
@@ -5652,7 +5874,7 @@ export class ManageMetadataBase {
|
|
|
5652
5874
|
continue;
|
|
5653
5875
|
}
|
|
5654
5876
|
const sSQLInsert = this.buildEntityPermissionInsertSQL(entityId, roleId, true, false, false, false);
|
|
5655
|
-
await this.
|
|
5877
|
+
await this.logSQLAndExecute(pool, sSQLInsert, `SQL generated to add read permission for materialized entity ${entityName} for role ${p.RoleName}`);
|
|
5656
5878
|
}
|
|
5657
5879
|
}
|
|
5658
5880
|
/**
|
|
@@ -5662,8 +5884,8 @@ export class ManageMetadataBase {
|
|
|
5662
5884
|
* human authors protection. Non-destructive (rows are kept, just flipped) so the grant can be restored.
|
|
5663
5885
|
*/
|
|
5664
5886
|
async revokeMaterializedEntityReadAccess(pool, entityId, entityLabel, reason) {
|
|
5665
|
-
const sql = `UPDATE ${this.qs(
|
|
5666
|
-
await this.
|
|
5887
|
+
const sql = `UPDATE ${this.qs(MjCoreSchema(), 'EntityPermission')} SET ${this.qi('CanRead')}=${this.boolLit(false)} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`;
|
|
5888
|
+
await this.logSQLAndExecute(pool, sql, `Revoke read access on materialized entity "${entityLabel}" (${reason})`);
|
|
5667
5889
|
}
|
|
5668
5890
|
/**
|
|
5669
5891
|
* ROLE-RLS LAYER ONLY — true if any of the entity's role permissions carries a non-empty `ReadRLSFilterID`.
|
|
@@ -5734,7 +5956,7 @@ export class ManageMetadataBase {
|
|
|
5734
5956
|
for (const table of ['APIKeyScope', 'APIApplicationScope']) {
|
|
5735
5957
|
if (!(await this.columnExistsInCoreSchema(pool, table, 'RowFilterID')))
|
|
5736
5958
|
continue;
|
|
5737
|
-
const res = await this.runQuery(pool, `SELECT DISTINCT ${this.qi('ResourcePattern')} AS ResourcePattern FROM ${this.qs(
|
|
5959
|
+
const res = await this.runQuery(pool, `SELECT DISTINCT ${this.qi('ResourcePattern')} AS ResourcePattern FROM ${this.qs(MjCoreSchema(), table)} WHERE ${this.qi('RowFilterID')} IS NOT NULL`);
|
|
5738
5960
|
for (const row of res.recordset ?? []) {
|
|
5739
5961
|
const r = row;
|
|
5740
5962
|
const pattern = (r.ResourcePattern ?? r.resourcepattern) ?? '';
|
|
@@ -5767,7 +5989,7 @@ export class ManageMetadataBase {
|
|
|
5767
5989
|
if (cached !== undefined)
|
|
5768
5990
|
return cached;
|
|
5769
5991
|
const sql = `SELECT COUNT(*) AS ColExists FROM INFORMATION_SCHEMA.COLUMNS ` +
|
|
5770
|
-
`WHERE TABLE_SCHEMA = '${
|
|
5992
|
+
`WHERE TABLE_SCHEMA = '${MjCoreSchema()}' AND TABLE_NAME = '${table}' AND COLUMN_NAME = '${column}'`;
|
|
5771
5993
|
const result = await this.runQuery(pool, sql);
|
|
5772
5994
|
const row = (result.recordset?.[0] ?? {});
|
|
5773
5995
|
const cnt = row.ColExists ?? row.colexists ?? 0;
|
|
@@ -5787,14 +6009,14 @@ export class ManageMetadataBase {
|
|
|
5787
6009
|
if (sourceEntities.length === 0)
|
|
5788
6010
|
return 0; // no sources to intersect against — the RLS/empty gate handles this
|
|
5789
6011
|
const roleCanReadAllSources = (roleId) => sourceEntities.every((e) => e.Permissions.some((p) => UUIDsEqual(p.RoleID, roleId) && p.CanRead));
|
|
5790
|
-
const grants = await this.runQuery(pool, `SELECT ${this.qi('RoleID')} FROM ${this.qs(
|
|
6012
|
+
const grants = await this.runQuery(pool, `SELECT ${this.qi('RoleID')} FROM ${this.qs(MjCoreSchema(), 'EntityPermission')} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`);
|
|
5791
6013
|
let revoked = 0;
|
|
5792
6014
|
for (const g of grants.recordset) {
|
|
5793
6015
|
const roleId = g.RoleID;
|
|
5794
6016
|
if (!roleId)
|
|
5795
6017
|
continue;
|
|
5796
6018
|
if (!roleCanReadAllSources(roleId)) {
|
|
5797
|
-
await this.
|
|
6019
|
+
await this.logSQLAndExecute(pool, `UPDATE ${this.qs(MjCoreSchema(), 'EntityPermission')} SET ${this.qi('CanRead')}=${this.boolLit(false)} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('RoleID')}='${roleId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`, `Narrow read grant on materialized entity "${entityLabel}": role ${roleId} can no longer read every source (C2 intersection re-narrowed)`);
|
|
5798
6020
|
revoked++;
|
|
5799
6021
|
}
|
|
5800
6022
|
}
|
|
@@ -5824,7 +6046,7 @@ export class ManageMetadataBase {
|
|
|
5824
6046
|
* earlier in this run (which are not in metadata yet)
|
|
5825
6047
|
* @returns the free name and the suffix used to reach it ('' when the name was already free)
|
|
5826
6048
|
*/
|
|
5827
|
-
|
|
6049
|
+
ResolveUniqueEntityName(desiredName, schemaName, takenNames) {
|
|
5828
6050
|
const taken = new Set([...takenNames, ...ManageMetadataBase.newEntityList].map(n => n.toLowerCase()));
|
|
5829
6051
|
const isTaken = (candidate) => taken.has(candidate.toLowerCase());
|
|
5830
6052
|
if (!isTaken(desiredName)) {
|
|
@@ -5843,13 +6065,17 @@ export class ManageMetadataBase {
|
|
|
5843
6065
|
}
|
|
5844
6066
|
return { name, suffix };
|
|
5845
6067
|
}
|
|
6068
|
+
/** @deprecated Use {@link ResolveUniqueEntityName}. */
|
|
6069
|
+
resolveUniqueEntityName(desiredName, schemaName, takenNames) {
|
|
6070
|
+
return this.ResolveUniqueEntityName(desiredName, schemaName, takenNames);
|
|
6071
|
+
}
|
|
5846
6072
|
createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
|
|
5847
6073
|
const newEntityDefaults = configInfo.newEntityDefaults;
|
|
5848
6074
|
const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;
|
|
5849
6075
|
const allowCaching = this.resolveAllowCachingForSchema(newEntity.SchemaName);
|
|
5850
6076
|
const q = (name) => this.qi(name);
|
|
5851
6077
|
const sSQLInsert = `
|
|
5852
|
-
INSERT INTO ${this.qs(
|
|
6078
|
+
INSERT INTO ${this.qs(MjCoreSchema(), 'Entity')} (
|
|
5853
6079
|
${q('ID')},
|
|
5854
6080
|
${q('Name')},
|
|
5855
6081
|
${q('DisplayName')},
|
|
@@ -5910,7 +6136,7 @@ export class ManageMetadataBase {
|
|
|
5910
6136
|
const match = overrides.find(entry => {
|
|
5911
6137
|
let candidate = entry.SchemaName;
|
|
5912
6138
|
if (candidate?.trim().toLowerCase() === '${mj_core_schema}') {
|
|
5913
|
-
candidate =
|
|
6139
|
+
candidate = MjCoreSchema();
|
|
5914
6140
|
}
|
|
5915
6141
|
return candidate.trim().toLowerCase() === schemaName.trim().toLowerCase();
|
|
5916
6142
|
});
|
|
@@ -5971,7 +6197,7 @@ export class ManageMetadataBase {
|
|
|
5971
6197
|
e.FullTextSearchEnabled,
|
|
5972
6198
|
e.AutoUpdateFullTextSearch
|
|
5973
6199
|
FROM
|
|
5974
|
-
${this.qs(
|
|
6200
|
+
${this.qs(MjCoreSchema(), 'vwEntities')} e
|
|
5975
6201
|
WHERE
|
|
5976
6202
|
${whereClause}
|
|
5977
6203
|
ORDER BY
|
|
@@ -6020,7 +6246,7 @@ export class ManageMetadataBase {
|
|
|
6020
6246
|
ef.AutoUpdateFullTextSearch,
|
|
6021
6247
|
ef.${this.qi('Length')} as MaxLength
|
|
6022
6248
|
FROM
|
|
6023
|
-
${this.qs(
|
|
6249
|
+
${this.qs(MjCoreSchema(), 'vwEntityFields')} ef
|
|
6024
6250
|
WHERE
|
|
6025
6251
|
ef.EntityID IN (${entityIds})
|
|
6026
6252
|
ORDER BY
|
|
@@ -6038,7 +6264,7 @@ export class ManageMetadataBase {
|
|
|
6038
6264
|
es.Name,
|
|
6039
6265
|
es.Value
|
|
6040
6266
|
FROM
|
|
6041
|
-
${this.qs(
|
|
6267
|
+
${this.qs(MjCoreSchema(), 'EntitySetting')} es
|
|
6042
6268
|
WHERE
|
|
6043
6269
|
es.EntityID IN (${entityIds})
|
|
6044
6270
|
AND es.Name = 'FieldCategoryInfo'
|
|
@@ -6113,13 +6339,13 @@ export class ManageMetadataBase {
|
|
|
6113
6339
|
}
|
|
6114
6340
|
}
|
|
6115
6341
|
const pct = Math.round((processedCount / total) * 100);
|
|
6116
|
-
|
|
6342
|
+
UpdateSpinner(`Advanced generation: ${processedCount}/${total} entities (${pct}%)${errorCount > 0 ? ` — ${errorCount} error(s)` : ''}`);
|
|
6117
6343
|
// Credential circuit tripped (e.g. keyless / mis-credentialed env) — stop issuing further
|
|
6118
6344
|
// AI calls; the remaining entities would only produce doomed round-trips. One clear message
|
|
6119
6345
|
// was already logged when the circuit opened.
|
|
6120
6346
|
if (ag.AICircuitOpen) {
|
|
6121
6347
|
const skipped = total - processedCount;
|
|
6122
|
-
|
|
6348
|
+
UpdateSpinner(`Advanced generation: AI credential circuit open — skipping remaining ${skipped} entit${skipped === 1 ? 'y' : 'ies'} (check AI credentials).`);
|
|
6123
6349
|
break;
|
|
6124
6350
|
}
|
|
6125
6351
|
}
|
|
@@ -6290,7 +6516,7 @@ export class ManageMetadataBase {
|
|
|
6290
6516
|
// Execute all updates in one batch
|
|
6291
6517
|
if (sqlStatements.length > 0) {
|
|
6292
6518
|
try {
|
|
6293
|
-
await this.
|
|
6519
|
+
await this.logSQLBatchAndExecute(pool, sqlStatements, `Set field properties for entity`, false);
|
|
6294
6520
|
}
|
|
6295
6521
|
catch (ex) {
|
|
6296
6522
|
logError('Error executing combined smart field SQL: ', ex);
|
|
@@ -6318,7 +6544,7 @@ export class ManageMetadataBase {
|
|
|
6318
6544
|
const proposedSearchableCount = (result.searchableFields ?? []).length;
|
|
6319
6545
|
const proposedAllowUserSearch = result.allowUserSearch === true;
|
|
6320
6546
|
// 1. Shape cleanup (defensive — identifyFields() also runs this).
|
|
6321
|
-
const cleaned =
|
|
6547
|
+
const cleaned = NormalizeSmartFieldResultShape(result);
|
|
6322
6548
|
result.searchableFields = cleaned.searchableFields ?? [];
|
|
6323
6549
|
result.searchPredicates = cleaned.searchPredicates ?? [];
|
|
6324
6550
|
result.allowUserSearch = cleaned.allowUserSearch;
|
|
@@ -6339,14 +6565,14 @@ export class ManageMetadataBase {
|
|
|
6339
6565
|
droppedIneligibleCount += 1;
|
|
6340
6566
|
continue;
|
|
6341
6567
|
}
|
|
6342
|
-
if (
|
|
6568
|
+
if (IsNarrativeFieldName(name) && !ftxEnabled) {
|
|
6343
6569
|
droppedNarrativeCount += 1;
|
|
6344
6570
|
continue;
|
|
6345
6571
|
}
|
|
6346
6572
|
eligible.push(name);
|
|
6347
6573
|
}
|
|
6348
6574
|
// 3. Per-entity cap.
|
|
6349
|
-
const { accepted, dropped: droppedByCap } =
|
|
6575
|
+
const { accepted, dropped: droppedByCap } = ApplySearchableFieldsCap(eligible);
|
|
6350
6576
|
result.searchableFields = accepted;
|
|
6351
6577
|
// 4. Predicate normalization. Build a fresh searchPredicates list keyed
|
|
6352
6578
|
// on the accepted fields. The LLM's proposed predicates are the input
|
|
@@ -6361,7 +6587,7 @@ export class ManageMetadataBase {
|
|
|
6361
6587
|
const normalizedPredicates = [];
|
|
6362
6588
|
let predicatesRewrittenCount = 0;
|
|
6363
6589
|
for (const fieldName of accepted) {
|
|
6364
|
-
const { predicate, rewritten } =
|
|
6590
|
+
const { predicate, rewritten } = NormalizePredicate({
|
|
6365
6591
|
fieldName,
|
|
6366
6592
|
proposed: proposedByName.get(fieldName),
|
|
6367
6593
|
isInFullTextSearchFields: ftsFieldSet.has(fieldName),
|
|
@@ -6436,7 +6662,7 @@ export class ManageMetadataBase {
|
|
|
6436
6662
|
// Set the winner when it isn't flagged yet (and auto-update is allowed).
|
|
6437
6663
|
if (winner && winner.AutoUpdateIsNameField && winner.ID && !winner.IsNameField) {
|
|
6438
6664
|
sqlStatements.push(`
|
|
6439
|
-
UPDATE ${this.qs(
|
|
6665
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6440
6666
|
SET IsNameField = ${this.boolLit(true)}
|
|
6441
6667
|
WHERE ID = '${winner.ID}'
|
|
6442
6668
|
AND AutoUpdateIsNameField = ${this.boolLit(true)}
|
|
@@ -6448,7 +6674,7 @@ export class ManageMetadataBase {
|
|
|
6448
6674
|
continue;
|
|
6449
6675
|
}
|
|
6450
6676
|
sqlStatements.push(`
|
|
6451
|
-
UPDATE ${this.qs(
|
|
6677
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6452
6678
|
SET IsNameField = ${this.boolLit(false)}
|
|
6453
6679
|
WHERE ID = '${f.ID}'
|
|
6454
6680
|
AND AutoUpdateIsNameField = ${this.boolLit(true)}
|
|
@@ -6597,7 +6823,7 @@ export class ManageMetadataBase {
|
|
|
6597
6823
|
for (const field of defaultInViewFields) {
|
|
6598
6824
|
if (!field.DefaultInView) {
|
|
6599
6825
|
sqlStatements.push(`
|
|
6600
|
-
UPDATE ${this.qs(
|
|
6826
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6601
6827
|
SET DefaultInView = ${this.boolLit(true)}
|
|
6602
6828
|
WHERE ID = '${field.ID}'
|
|
6603
6829
|
AND AutoUpdateDefaultInView = ${this.boolLit(true)}
|
|
@@ -6635,7 +6861,7 @@ export class ManageMetadataBase {
|
|
|
6635
6861
|
}
|
|
6636
6862
|
if (!field.IncludeInUserSearchAPI) {
|
|
6637
6863
|
sqlStatements.push(`
|
|
6638
|
-
UPDATE ${this.qs(
|
|
6864
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6639
6865
|
SET IncludeInUserSearchAPI = ${this.boolLit(true)}
|
|
6640
6866
|
WHERE ID = '${field.ID}'
|
|
6641
6867
|
AND AutoUpdateIncludeInUserSearchAPI = ${this.boolLit(true)}
|
|
@@ -6643,6 +6869,279 @@ export class ManageMetadataBase {
|
|
|
6643
6869
|
}
|
|
6644
6870
|
}
|
|
6645
6871
|
}
|
|
6872
|
+
/**
|
|
6873
|
+
* Build the two deterministic search-flag hygiene statements.
|
|
6874
|
+
*
|
|
6875
|
+
* These exist because the LLM-driven smart-field pipeline cannot be relied on to run at all:
|
|
6876
|
+
* `AdvancedGeneration.enabled` reads `enableAdvancedGeneration ?? false`, so on a default
|
|
6877
|
+
* configuration none of `applySearchableFieldUpdates` / `applyEntitySearchConfig` ever
|
|
6878
|
+
* executes — while every new entity is still INSERTed with `AllowUserSearchAPI = 1`
|
|
6879
|
+
* (see `createNewEntityInsertSQL`). The result is an entity flagged searchable with nothing
|
|
6880
|
+
* searchable on it, which is not a harmless default: `UserSearchString` against such an entity
|
|
6881
|
+
* is a documented no-op (MJ#4581/#4582), so the data provider ignores the term and returns the
|
|
6882
|
+
* UNFILTERED table. Global search then fans out to it on every keystroke and discards every row.
|
|
6883
|
+
*
|
|
6884
|
+
* Both statements are deterministic, need no model, and are safe to run on every pass:
|
|
6885
|
+
*
|
|
6886
|
+
* 1. **Seed** — an entity with NO searchable field gets its name-like columns flagged
|
|
6887
|
+
* (`NAME_LIKE_FIELD_NAMES`: Name, Title, FirstName, LastName, …). Those are what a person
|
|
6888
|
+
* types into a search box, so they are the one defensible default. Only fires when the
|
|
6889
|
+
* entity has nothing flagged at all, so it fills a gap rather than overriding anyone —
|
|
6890
|
+
* including the LLM, which has already run by this point when it is enabled.
|
|
6891
|
+
*
|
|
6892
|
+
* Note this keys on the field's NAME, not on `IsNameField`. That flag looks like the
|
|
6893
|
+
* obvious source and is the wrong one: measured against a real database, seeding from it
|
|
6894
|
+
* would flag 54 fields of which 41 are VIRTUAL — the denormalized FK display columns
|
|
6895
|
+
* CodeGen puts on views (`Action`, `Agent`, `Artifact`). Those are computed by JOIN, so a
|
|
6896
|
+
* LIKE against them cannot seek any index, and flagging them would push 49 junction
|
|
6897
|
+
* entities into the global-search fan-out with unindexable predicates — the exact cost
|
|
6898
|
+
* `search-guardrails.ts` exists to prevent. It also picked up identifiers (`RecordID`,
|
|
6899
|
+
* `Token`, `ExternalSystemRecordID`) and a `Description`, which `isNarrativeFieldName`
|
|
6900
|
+
* rejects on the LLM path. And it would still not have fixed `MJ: Employees`, whose only
|
|
6901
|
+
* `IsNameField` is the virtual `FirstLast`; keying on the name reaches its real
|
|
6902
|
+
* `FirstName` / `LastName` columns, which is what the reported bug needed.
|
|
6903
|
+
* 2. **Clear** — an entity STILL left with no searchable field has `AllowUserSearchAPI` turned
|
|
6904
|
+
* off, so it drops out of the search fan-out instead of contributing noise.
|
|
6905
|
+
*
|
|
6906
|
+
* Both honor the `AutoUpdate*` opt-outs, which is how an operator pins a hand-made decision —
|
|
6907
|
+
* and is exactly how the curated entries in `metadata/entities/.entity-search-exclusions.json`
|
|
6908
|
+
* protect themselves (they set `AutoUpdateAllowUserSearchAPI` to false alongside the flag).
|
|
6909
|
+
*
|
|
6910
|
+
* Full-text-search entities are exempt from both. An FTS entity is searchable through its
|
|
6911
|
+
* INDEX: `createViewUserSearchSQL` takes the full-text branch before it ever reads
|
|
6912
|
+
* `IncludeInUserSearchAPI`, so those flags are dead metadata there and the entity is a
|
|
6913
|
+
* perfectly valid search target without them.
|
|
6914
|
+
*
|
|
6915
|
+
* Written as `UPDATE ... WHERE <key> IN (subquery)` rather than `UPDATE ... FROM ... JOIN`,
|
|
6916
|
+
* which is T-SQL-only. The subquery form is ANSI and runs unchanged on both platforms.
|
|
6917
|
+
*/
|
|
6918
|
+
buildSearchFlagHygieneSQL(excludeSchemas) {
|
|
6919
|
+
const coreSchema = MjCoreSchema();
|
|
6920
|
+
const entity = this.qs(coreSchema, 'Entity');
|
|
6921
|
+
const entityField = this.qs(coreSchema, 'EntityField');
|
|
6922
|
+
const yes = this.boolLit(true);
|
|
6923
|
+
const no = this.boolLit(false);
|
|
6924
|
+
// Same set the LLM path's isNameLikeFieldName() tests, lowered for a case-insensitive
|
|
6925
|
+
// comparison that does not depend on the database collation.
|
|
6926
|
+
const nameLikeList = NAME_LIKE_FIELD_NAMES.map(n => `'${n.toLowerCase()}'`).join(',');
|
|
6927
|
+
// Every name in NAME_LIKE_FIELD_NAMES resolves to the same predicate, so one literal covers
|
|
6928
|
+
// the whole seed. This MUST be set explicitly: `EntityField.UserSearchPredicateAPI` defaults
|
|
6929
|
+
// to 'Contains' in the database, which is `LIKE '%term%'` — the unindexable scan the
|
|
6930
|
+
// guardrails exist to prevent. Seeding the flag without the predicate would have made every
|
|
6931
|
+
// seeded entity a full scan on every keystroke.
|
|
6932
|
+
const seedPredicate = DefaultPredicateFor(NAME_LIKE_FIELD_NAMES[0]);
|
|
6933
|
+
// The entity-shape guardrails the LLM path applies (`entityLevelEnableBlockedReason`).
|
|
6934
|
+
// A log / audit / run-history table grows without bound and a detail / line-item child is
|
|
6935
|
+
// reached through its parent; neither is a global-search target, whichever columns it has.
|
|
6936
|
+
// Expressed as SQL rather than reusing the regex helpers because this runs in the database.
|
|
6937
|
+
const shapeSuffixes = [
|
|
6938
|
+
'Logs', 'Log', 'Runs', 'Run', 'Run History', 'Run Steps', 'Run Messages', 'Execution Logs',
|
|
6939
|
+
'Details', 'Detail', 'Lines', 'Line', 'Items', 'Item', 'Steps', 'Step',
|
|
6940
|
+
'Params', 'Param', 'Mappings', 'Mapping',
|
|
6941
|
+
];
|
|
6942
|
+
// WORD boundaries, not raw suffixes. `LIKE '%Lines'` is a case-insensitive endsWith under
|
|
6943
|
+
// the default collation, so it matched `Pipelines`, `Guidelines`, `Timelines`, `Airlines`,
|
|
6944
|
+
// `Deadlines` and `Baselines`; `LIKE '%Logs'` matched `Catalogs`, `Dialogs` and `Blogs`;
|
|
6945
|
+
// `LIKE '%Audit%'` matched `Auditors`. MJ's own metadata was not exempt — `MJ: ML Training
|
|
6946
|
+
// Pipelines` was caught by `'%Lines'`. Those entities were then excluded from the seed and
|
|
6947
|
+
// swept up by the clear (which carries no shape filter, deliberately — see below), so a
|
|
6948
|
+
// guardrail meaning "do not bother seeding these" silently turned search OFF on them.
|
|
6949
|
+
//
|
|
6950
|
+
// Matching the final WORD keeps every intended shape — `Order Details`, `Audit Logs`,
|
|
6951
|
+
// `MJ: AI Agent Runs` — while `Catalogs`, `Pipelines` and `Auditors` fall through to the
|
|
6952
|
+
// seed as ordinary entities.
|
|
6953
|
+
const nameEndsWithWord = (word) => `(e.${this.qi('Name')} = '${word}' OR e.${this.qi('Name')} LIKE '% ${word}')`;
|
|
6954
|
+
const nameContainsWord = (word) => `(e.${this.qi('Name')} = '${word}'`
|
|
6955
|
+
+ ` OR e.${this.qi('Name')} LIKE '${word} %'`
|
|
6956
|
+
+ ` OR e.${this.qi('Name')} LIKE '% ${word}'`
|
|
6957
|
+
+ ` OR e.${this.qi('Name')} LIKE '% ${word} %')`;
|
|
6958
|
+
const shapeClauses = shapeSuffixes
|
|
6959
|
+
.map(sfx => nameEndsWithWord(sfx))
|
|
6960
|
+
.concat([nameContainsWord('Audit'), nameContainsWord('Record Change')])
|
|
6961
|
+
.join(' OR ');
|
|
6962
|
+
const entityShapeFilter = `AND NOT (${shapeClauses})`;
|
|
6963
|
+
// Schema names are configuration, not literals we control: double any apostrophe rather
|
|
6964
|
+
// than interpolating it straight into the statement.
|
|
6965
|
+
const schemaFilter = excludeSchemas.length > 0
|
|
6966
|
+
? `AND e.${this.qi('SchemaName')} NOT IN (${excludeSchemas.map(sc => `'${sc.replace(/'/g, "''")}'`).join(',')})`
|
|
6967
|
+
: '';
|
|
6968
|
+
// "This entity has nothing a user search can match." Mirrors
|
|
6969
|
+
// `EntityInfo.HasSearchFields` and the runtime screen in EntitySearchProvider.
|
|
6970
|
+
const noSearchableField = `NOT EXISTS (
|
|
6971
|
+
SELECT 1 FROM ${entityField} f2
|
|
6972
|
+
WHERE f2.${this.qi('EntityID')} = e.${this.qi('ID')}
|
|
6973
|
+
AND f2.${this.qi('IncludeInUserSearchAPI')} = ${yes}
|
|
6974
|
+
)`;
|
|
6975
|
+
// An FTS entity is searchable through its index, with no per-field flags involved.
|
|
6976
|
+
const notFullText = `AND ${this.coalesce(`e.${this.qi('FullTextSearchEnabled')}`, no)} = ${no}`;
|
|
6977
|
+
// The eligibility predicate here mirrors `isFieldEligibleForUserSearch` (and the runtime
|
|
6978
|
+
// `isTextSearchableType` it was written against): not the primary key, a bounded text
|
|
6979
|
+
// column. A name field that is neither is left alone rather than flagged uselessly.
|
|
6980
|
+
// The candidate SELECTs are built once and used twice: once as the UPDATE's subquery, once
|
|
6981
|
+
// as the probe `applySearchFlagHygiene` runs to decide whether to emit the UPDATE at all.
|
|
6982
|
+
// Sharing the text is the point — a probe whose predicates could drift from the statement
|
|
6983
|
+
// it guards would either suppress a needed write or emit a no-op one.
|
|
6984
|
+
const seedCandidateSQL = `
|
|
6985
|
+
SELECT ranked.${this.qi('ID')} FROM (
|
|
6986
|
+
SELECT f.${this.qi('ID')},
|
|
6987
|
+
ROW_NUMBER() OVER (
|
|
6988
|
+
PARTITION BY f.${this.qi('EntityID')}
|
|
6989
|
+
ORDER BY f.${this.qi('Sequence')}, f.${this.qi('Name')}
|
|
6990
|
+
) AS rn
|
|
6991
|
+
FROM ${entityField} f
|
|
6992
|
+
INNER JOIN ${entity} e ON e.${this.qi('ID')} = f.${this.qi('EntityID')}
|
|
6993
|
+
WHERE LOWER(f.${this.qi('Name')}) IN (${nameLikeList})
|
|
6994
|
+
AND f.${this.qi('AutoUpdateIncludeInUserSearchAPI')} = ${yes}
|
|
6995
|
+
AND f.${this.qi('IncludeInUserSearchAPI')} = ${no}
|
|
6996
|
+
AND ${this.coalesce(`f.${this.qi('IsPrimaryKey')}`, no)} = ${no}
|
|
6997
|
+
AND ${this.coalesce(`f.${this.qi('IsVirtual')}`, no)} = ${no}
|
|
6998
|
+
AND LOWER(f.${this.qi('Type')}) IN ('nvarchar','varchar','char','nchar')
|
|
6999
|
+
AND ${this.coalesce(`f.${this.qi('Length')}`, '0')} <> -1
|
|
7000
|
+
AND e.${this.qi('VirtualEntity')} = ${no}
|
|
7001
|
+
AND e.${this.qi('AllowUserSearchAPI')} = ${yes}
|
|
7002
|
+
${notFullText}
|
|
7003
|
+
${entityShapeFilter}
|
|
7004
|
+
${schemaFilter}
|
|
7005
|
+
AND ${noSearchableField}
|
|
7006
|
+
) ranked
|
|
7007
|
+
WHERE ranked.rn <= ${MAX_SEARCHABLE_FIELDS_PER_ENTITY}`;
|
|
7008
|
+
// The clear carries NO entity-shape filter, and that is deliberate rather than an
|
|
7009
|
+
// oversight: a log / run / detail table is exactly what should drop out of the search
|
|
7010
|
+
// fan-out, so the shapes the seed refuses to touch are meant to fall through to here.
|
|
7011
|
+
//
|
|
7012
|
+
// That composition is load-bearing and worth stating, because it means the shape list
|
|
7013
|
+
// above does not merely withhold help — it DECIDES which entities get search turned off.
|
|
7014
|
+
// A name wrongly matched there is not "left alone", it is disabled. Which is why the
|
|
7015
|
+
// matching is word-boundary (see nameEndsWithWord) and why widening that list is a
|
|
7016
|
+
// destructive change, not a conservative one.
|
|
7017
|
+
const clearWhere = `e.${this.qi('AllowUserSearchAPI')} = ${yes}
|
|
7018
|
+
AND e.${this.qi('AutoUpdateAllowUserSearchAPI')} = ${yes}
|
|
7019
|
+
AND e.${this.qi('VirtualEntity')} = ${no}
|
|
7020
|
+
${notFullText}
|
|
7021
|
+
${schemaFilter}
|
|
7022
|
+
AND ${noSearchableField}`;
|
|
7023
|
+
const clearCandidateSQL = `
|
|
7024
|
+
SELECT e.${this.qi('ID')}
|
|
7025
|
+
FROM ${entity} e
|
|
7026
|
+
WHERE ${clearWhere}`;
|
|
7027
|
+
const seedSQL = `
|
|
7028
|
+
UPDATE ${entityField}
|
|
7029
|
+
SET ${this.qi('IncludeInUserSearchAPI')} = ${yes},
|
|
7030
|
+
${this.qi('UserSearchPredicateAPI')} = '${seedPredicate}'
|
|
7031
|
+
WHERE ${this.qi('ID')} IN (${seedCandidateSQL}
|
|
7032
|
+
)`;
|
|
7033
|
+
const clearSQL = `
|
|
7034
|
+
UPDATE ${entity}
|
|
7035
|
+
SET ${this.qi('AllowUserSearchAPI')} = ${no}
|
|
7036
|
+
WHERE ${this.qi('ID')} IN (${clearCandidateSQL}
|
|
7037
|
+
)`;
|
|
7038
|
+
// Derived-table alias is required by T-SQL and accepted by PostgreSQL, so one form serves
|
|
7039
|
+
// both. COUNT(*) rather than EXISTS because the count also feeds the status line.
|
|
7040
|
+
const seedProbeSQL = `SELECT COUNT(*) AS ${this.qi('Cnt')} FROM (${seedCandidateSQL}
|
|
7041
|
+
) probe`;
|
|
7042
|
+
// The clear probe returns NAMES, not a count. Disabling search is the destructive half of
|
|
7043
|
+
// this pass and it is effectively one-way: getting it back means flagging a field by hand
|
|
7044
|
+
// AND pinning AutoUpdateAllowUserSearchAPI = 0, or the next run undoes the repair. An
|
|
7045
|
+
// operator told "cleared 14 entities" has no way to learn which 14 without going to the
|
|
7046
|
+
// database; naming them in the run output is the difference between an auditable change
|
|
7047
|
+
// and a silent one. Shares `clearWhere` with the UPDATE so the two cannot disagree.
|
|
7048
|
+
const clearProbeSQL = `
|
|
7049
|
+
SELECT e.${this.qi('Name')} AS ${this.qi('Name')}
|
|
7050
|
+
FROM ${entity} e
|
|
7051
|
+
WHERE ${clearWhere}
|
|
7052
|
+
ORDER BY e.${this.qi('Name')}`;
|
|
7053
|
+
return { seedSQL, clearSQL, seedProbeSQL, clearProbeSQL };
|
|
7054
|
+
}
|
|
7055
|
+
/**
|
|
7056
|
+
* Run the deterministic search-flag hygiene pass (see {@link buildSearchFlagHygieneSQL}).
|
|
7057
|
+
*
|
|
7058
|
+
* Runs OUTSIDE `applyAdvancedGeneration` and therefore regardless of whether advanced
|
|
7059
|
+
* generation is enabled — which is the whole point, since it is off by default and an entity
|
|
7060
|
+
* created under that default is exactly the one that needs this. Ordered after it so the
|
|
7061
|
+
* model's choices, when it did run, are already in place and the seed only fills a real gap.
|
|
7062
|
+
*
|
|
7063
|
+
* COMPARE FIRST, then write — the T20 contract every-run config writers are held to (see
|
|
7064
|
+
* `__tests__/idempotency/config-writers-compare-first.test.ts`). The two UPDATEs converge on
|
|
7065
|
+
* their own, because the first pass makes `noSearchableField` false for every row it touches,
|
|
7066
|
+
* so re-running them changes nothing. That is not sufficient: `LogSQLBatchAndExecute` writes
|
|
7067
|
+
* whatever it is handed into the run's `CodeGen_Run_*.sql` capture whether or not a row moves,
|
|
7068
|
+
* and the drift gate's warm-twice stage fails on ANY capture surviving a second run. Emitting
|
|
7069
|
+
* unconditionally therefore reddened the gate on every PR while the data was perfectly
|
|
7070
|
+
* idempotent — the data converged, the log did not. So each statement is emitted only when its
|
|
7071
|
+
* own candidate probe finds work.
|
|
7072
|
+
*/
|
|
7073
|
+
async applySearchFlagHygiene(pool, excludeSchemas) {
|
|
7074
|
+
try {
|
|
7075
|
+
const { seedSQL, clearSQL, seedProbeSQL, clearProbeSQL } = this.buildSearchFlagHygieneSQL(excludeSchemas);
|
|
7076
|
+
// Order matters: seeding first means an entity whose name field was just flagged is no
|
|
7077
|
+
// longer a candidate for having its AllowUserSearchAPI cleared. Reversed, the pass would
|
|
7078
|
+
// disable search on an entity it was about to make searchable.
|
|
7079
|
+
//
|
|
7080
|
+
// 4th arg is `isRecurringScript`, NOT a throw flag — passing `false` here is the default
|
|
7081
|
+
// and is spelled out only to make the intent explicit. Errors are caught below.
|
|
7082
|
+
const seedCount = await this.searchFlagHygieneCandidateCount(pool, seedProbeSQL);
|
|
7083
|
+
if (seedCount > 0) {
|
|
7084
|
+
logStatus(` Search-flag hygiene: seeding ${seedCount} name field(s)`);
|
|
7085
|
+
await this.logSQLBatchAndExecute(pool, [seedSQL], 'Deterministic search-flag hygiene — seed name fields', false);
|
|
7086
|
+
}
|
|
7087
|
+
// Probed AFTER the seed has run, not alongside it. Seeding changes which entities still
|
|
7088
|
+
// have nothing searchable, so a clear probe taken before it would count entities the seed
|
|
7089
|
+
// was about to repair and emit a statement that then matched nothing — reintroducing the
|
|
7090
|
+
// stray capture file this is here to avoid.
|
|
7091
|
+
const clearNames = await this.searchFlagHygieneClearCandidates(pool, clearProbeSQL);
|
|
7092
|
+
if (clearNames.length > 0) {
|
|
7093
|
+
// NAME them. This is the destructive half and it is effectively one-way for an
|
|
7094
|
+
// operator who does not know it happened.
|
|
7095
|
+
const shown = clearNames.slice(0, 25).join(', ');
|
|
7096
|
+
const more = clearNames.length > 25 ? `, ... and ${clearNames.length - 25} more` : '';
|
|
7097
|
+
logStatus(` Search-flag hygiene: turning AllowUserSearchAPI OFF on ${clearNames.length} entity(ies): ${shown}${more}`);
|
|
7098
|
+
await this.logSQLBatchAndExecute(pool, [clearSQL], 'Deterministic search-flag hygiene — clear AllowUserSearchAPI', false);
|
|
7099
|
+
}
|
|
7100
|
+
return true;
|
|
7101
|
+
}
|
|
7102
|
+
catch (ex) {
|
|
7103
|
+
// A probe that cannot run is NOT the same as "nothing to do", and must not be allowed to
|
|
7104
|
+
// look like it. Before this pass compared first, a malformed statement was appended to
|
|
7105
|
+
// the CodeGen_Run capture by SQLLogging BEFORE it was executed, so a broken pass left a
|
|
7106
|
+
// surviving artifact and reddened the drift gate. Probing first removes that signal: the
|
|
7107
|
+
// throw now happens before anything is written, so without a loud failure here the pass
|
|
7108
|
+
// would silently do nothing while every gate stayed green — the exact silent no-op this
|
|
7109
|
+
// whole change exists to eliminate, one level up in the fixer. The caller fails the run.
|
|
7110
|
+
logError('Search-flag hygiene FAILED — search flags were NOT reconciled on this run', ex);
|
|
7111
|
+
return false;
|
|
7112
|
+
}
|
|
7113
|
+
}
|
|
7114
|
+
/**
|
|
7115
|
+
* Row count for one of the search-flag hygiene probes (see {@link applySearchFlagHygiene}).
|
|
7116
|
+
*
|
|
7117
|
+
* Reads the first column of the first row positionally rather than by name, because the alias
|
|
7118
|
+
* comes back cased differently across drivers. A probe that returns nothing is treated as no
|
|
7119
|
+
* work, which is the safe direction: the statement is skipped rather than emitted blind.
|
|
7120
|
+
*/
|
|
7121
|
+
async searchFlagHygieneCandidateCount(pool, probeSQL) {
|
|
7122
|
+
const result = await this.runQuery(pool, probeSQL);
|
|
7123
|
+
const row = result?.recordset?.[0];
|
|
7124
|
+
if (!row) {
|
|
7125
|
+
return 0;
|
|
7126
|
+
}
|
|
7127
|
+
const count = Number(Object.values(row)[0]);
|
|
7128
|
+
return Number.isFinite(count) ? count : 0;
|
|
7129
|
+
}
|
|
7130
|
+
/**
|
|
7131
|
+
* Names of the entities the clear would disable (see {@link applySearchFlagHygiene}).
|
|
7132
|
+
*
|
|
7133
|
+
* Valid only AFTER the seed has run, since seeding changes which entities still have nothing
|
|
7134
|
+
* searchable. Names rather than a count because this is the destructive half: the run output
|
|
7135
|
+
* is the only place an operator can see which entities lost search, and getting it back is
|
|
7136
|
+
* manual.
|
|
7137
|
+
*/
|
|
7138
|
+
async searchFlagHygieneClearCandidates(pool, probeSQL) {
|
|
7139
|
+
const result = await this.runQuery(pool, probeSQL);
|
|
7140
|
+
const rows = result?.recordset ?? [];
|
|
7141
|
+
return rows
|
|
7142
|
+
.map(r => String(Object.values(r)[0] ?? '').trim())
|
|
7143
|
+
.filter(n => n.length > 0);
|
|
7144
|
+
}
|
|
6646
7145
|
/**
|
|
6647
7146
|
* Returns true if the field is a sensible target for LIKE-based user search.
|
|
6648
7147
|
* Mirrors the runtime guard in GenericDatabaseProvider.isTextSearchableType /
|
|
@@ -6684,7 +7183,7 @@ export class ManageMetadataBase {
|
|
|
6684
7183
|
// Only update if the current value differs from the recommended predicate
|
|
6685
7184
|
if (field.UserSearchPredicateAPI !== sp.predicate) {
|
|
6686
7185
|
sqlStatements.push(`
|
|
6687
|
-
UPDATE ${this.qs(
|
|
7186
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6688
7187
|
SET UserSearchPredicateAPI = '${sp.predicate}'
|
|
6689
7188
|
WHERE ID = '${field.ID}'
|
|
6690
7189
|
AND AutoUpdateUserSearchPredicate = ${this.boolLit(true)}
|
|
@@ -6715,7 +7214,7 @@ export class ManageMetadataBase {
|
|
|
6715
7214
|
// the field-level guardrails, and refuses log/audit and detail/line-
|
|
6716
7215
|
// item-shaped entities. Anything blocked here gets a one-line note in
|
|
6717
7216
|
// the CodeGen run report so the team can audit proposals.
|
|
6718
|
-
const blocked =
|
|
7217
|
+
const blocked = EntityLevelEnableBlockedReason({
|
|
6719
7218
|
entityName: entity.Name,
|
|
6720
7219
|
confidence: result.confidence,
|
|
6721
7220
|
acceptedSearchableFieldsCount: (result.searchableFields ?? []).length,
|
|
@@ -6731,7 +7230,7 @@ export class ManageMetadataBase {
|
|
|
6731
7230
|
}
|
|
6732
7231
|
if (newValue !== currentValue) {
|
|
6733
7232
|
sqlStatements.push(`
|
|
6734
|
-
UPDATE ${this.qs(
|
|
7233
|
+
UPDATE ${this.qs(MjCoreSchema(), 'Entity')}
|
|
6735
7234
|
SET AllowUserSearchAPI = ${this.boolLit(newValue)}
|
|
6736
7235
|
WHERE ID = '${entity.ID}'
|
|
6737
7236
|
AND AutoUpdateAllowUserSearchAPI = ${this.boolLit(true)}
|
|
@@ -6774,7 +7273,7 @@ export class ManageMetadataBase {
|
|
|
6774
7273
|
const currentValue = !!entity.FullTextSearchEnabled;
|
|
6775
7274
|
if (newValue !== currentValue) {
|
|
6776
7275
|
sqlStatements.push(`
|
|
6777
|
-
UPDATE ${this.qs(
|
|
7276
|
+
UPDATE ${this.qs(MjCoreSchema(), 'Entity')}
|
|
6778
7277
|
SET FullTextSearchEnabled = ${this.boolLit(newValue)}
|
|
6779
7278
|
WHERE ID = '${entity.ID}'
|
|
6780
7279
|
AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
|
|
@@ -6796,7 +7295,7 @@ export class ManageMetadataBase {
|
|
|
6796
7295
|
}
|
|
6797
7296
|
if (!field.FullTextSearchEnabled) {
|
|
6798
7297
|
sqlStatements.push(`
|
|
6799
|
-
UPDATE ${this.qs(
|
|
7298
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6800
7299
|
SET FullTextSearchEnabled = ${this.boolLit(true)}
|
|
6801
7300
|
WHERE ID = '${field.ID}'
|
|
6802
7301
|
AND AutoUpdateFullTextSearch = ${this.boolLit(true)}
|
|
@@ -6841,7 +7340,7 @@ export class ManageMetadataBase {
|
|
|
6841
7340
|
* Respects the AutoUpdateSupportsGeoCoding flag — if 0, the value is locked.
|
|
6842
7341
|
*/
|
|
6843
7342
|
async detectAndSetGeoCodingSupport(pool, entity) {
|
|
6844
|
-
const schema =
|
|
7343
|
+
const schema = MjCoreSchema();
|
|
6845
7344
|
// Check if the entity's AutoUpdateSupportsGeoCoding flag allows us to modify it
|
|
6846
7345
|
const autoUpdateResult = await pool.query(`
|
|
6847
7346
|
SELECT ${this.qi('AutoUpdateSupportsGeoCoding')}, ${this.qi('SupportsGeoCoding')}
|
|
@@ -6864,7 +7363,7 @@ export class ManageMetadataBase {
|
|
|
6864
7363
|
const shouldSupportGeo = existingGeoCount > 0;
|
|
6865
7364
|
const currentValue = row.SupportsGeoCoding ? true : false;
|
|
6866
7365
|
if (shouldSupportGeo !== currentValue) {
|
|
6867
|
-
await this.
|
|
7366
|
+
await this.logSQLAndExecute(pool, `
|
|
6868
7367
|
UPDATE ${this.qs(schema, 'Entity')}
|
|
6869
7368
|
SET ${this.qi('SupportsGeoCoding')} = ${this.boolLit(shouldSupportGeo)}
|
|
6870
7369
|
WHERE ${this.qi('ID')} = '${entity.ID}' AND ${this.qi('AutoUpdateSupportsGeoCoding')} = ${this.boolLit(true)}
|
|
@@ -6938,14 +7437,14 @@ export class ManageMetadataBase {
|
|
|
6938
7437
|
continue;
|
|
6939
7438
|
}
|
|
6940
7439
|
const lockCtx = {
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
existingCategories,
|
|
7440
|
+
IsNewEntity: ctx.isNewEntity,
|
|
7441
|
+
IsNewField: ManageMetadataBase.isFieldNew(entity.ID, field.Name),
|
|
7442
|
+
DescriptionReopened: ManageMetadataBase.isDisplayNameReopened(entity.ID, field.Name),
|
|
7443
|
+
TypeReopened: ManageMetadataBase.isTypeReopened(entity.ID, field.Name),
|
|
7444
|
+
ExistingCategories: existingCategories,
|
|
6946
7445
|
};
|
|
6947
|
-
const update =
|
|
6948
|
-
for (const skip of update.
|
|
7446
|
+
const update = ComputeFieldMetadataUpdate(field, fieldCategory, lockCtx, this.validateExtendedType.bind(this), (val, fn) => this.sanitizeCodeType(val, fn ?? field.Name, entity.Name));
|
|
7447
|
+
for (const skip of update.Skipped) {
|
|
6949
7448
|
if (skip.reason === 'locked') {
|
|
6950
7449
|
reporter.counter('ai.fieldsLocked');
|
|
6951
7450
|
}
|
|
@@ -6972,7 +7471,7 @@ export class ManageMetadataBase {
|
|
|
6972
7471
|
setClauses.push(`CodeType = ${codeVal}`);
|
|
6973
7472
|
}
|
|
6974
7473
|
if (setClauses.length > 0) {
|
|
6975
|
-
sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(
|
|
7474
|
+
sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(MjCoreSchema(), 'EntityField')}
|
|
6976
7475
|
SET
|
|
6977
7476
|
${setClauses.join(',\n ')}
|
|
6978
7477
|
WHERE
|
|
@@ -6981,7 +7480,7 @@ WHERE
|
|
|
6981
7480
|
}
|
|
6982
7481
|
if (sqlStatements.length > 0) {
|
|
6983
7482
|
try {
|
|
6984
|
-
await this.
|
|
7483
|
+
await this.logSQLBatchAndExecute(pool, sqlStatements, `Set categories for ${sqlStatements.length} fields`, false);
|
|
6985
7484
|
}
|
|
6986
7485
|
catch (ex) {
|
|
6987
7486
|
logError('Error Applying Field Categories', ex);
|
|
@@ -6994,19 +7493,19 @@ WHERE
|
|
|
6994
7493
|
async applyEntityIcon(pool, entityId, entityIcon, entityName) {
|
|
6995
7494
|
if (!entityIcon || entityIcon.trim().length === 0)
|
|
6996
7495
|
return;
|
|
6997
|
-
const checkSQL = `SELECT Icon FROM ${this.qs(
|
|
7496
|
+
const checkSQL = `SELECT Icon FROM ${this.qs(MjCoreSchema(), 'Entity')} WHERE ID = '${entityId}'`;
|
|
6998
7497
|
const entityCheck = await this.runQuery(pool, checkSQL);
|
|
6999
7498
|
if (entityCheck.recordset.length > 0) {
|
|
7000
7499
|
const currentIcon = entityCheck.recordset[0].Icon;
|
|
7001
7500
|
if (!currentIcon || currentIcon.trim().length === 0) {
|
|
7002
7501
|
const escapedIcon = entityIcon.replace(/'/g, "''");
|
|
7003
7502
|
const updateSQL = `
|
|
7004
|
-
UPDATE ${this.qs(
|
|
7503
|
+
UPDATE ${this.qs(MjCoreSchema(), 'Entity')}
|
|
7005
7504
|
SET ${this.qi('Icon')} = '${escapedIcon}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7006
7505
|
WHERE ${this.qi('ID')} = '${entityId}'
|
|
7007
7506
|
`;
|
|
7008
7507
|
try {
|
|
7009
|
-
await this.
|
|
7508
|
+
await this.logSQLAndExecute(pool, updateSQL, `Set entity icon to ${entityIcon}`, false);
|
|
7010
7509
|
logStatus(` Set entity icon: ${entityIcon}`);
|
|
7011
7510
|
}
|
|
7012
7511
|
catch (ex) {
|
|
@@ -7021,26 +7520,26 @@ WHERE
|
|
|
7021
7520
|
async applyCategoryInfoSettings(pool, entityId, categoryInfo, _entityName) {
|
|
7022
7521
|
if (!categoryInfo || Object.keys(categoryInfo).length === 0)
|
|
7023
7522
|
return;
|
|
7024
|
-
const canonicalInfo =
|
|
7523
|
+
const canonicalInfo = CanonicalJSONStringify(categoryInfo, 2);
|
|
7025
7524
|
const infoJSON = canonicalInfo.replace(/'/g, "''");
|
|
7026
7525
|
const ENTITY_SETTING_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
7027
7526
|
// Upsert FieldCategoryInfo (new format)
|
|
7028
|
-
const checkNewSQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(
|
|
7527
|
+
const checkNewSQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(MjCoreSchema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
7029
7528
|
const existingNew = await this.runQuery(pool, checkNewSQL);
|
|
7030
7529
|
if (existingNew.recordset.length > 0) {
|
|
7031
7530
|
let isMatch = false;
|
|
7032
7531
|
try {
|
|
7033
7532
|
const rawVal = existingNew.recordset[0].Value;
|
|
7034
7533
|
const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
|
|
7035
|
-
isMatch =
|
|
7534
|
+
isMatch = DeepEqualJSON(parsed, categoryInfo);
|
|
7036
7535
|
}
|
|
7037
7536
|
catch {
|
|
7038
7537
|
isMatch = false;
|
|
7039
7538
|
}
|
|
7040
7539
|
if (!isMatch) {
|
|
7041
7540
|
try {
|
|
7042
|
-
await this.
|
|
7043
|
-
UPDATE ${this.qs(
|
|
7541
|
+
await this.logSQLAndExecute(pool, `
|
|
7542
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntitySetting')}
|
|
7044
7543
|
SET ${this.qi('Value')} = '${infoJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7045
7544
|
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'
|
|
7046
7545
|
`, `Update FieldCategoryInfo setting for entity`, false);
|
|
@@ -7053,11 +7552,11 @@ WHERE
|
|
|
7053
7552
|
else {
|
|
7054
7553
|
const newId = uuidv5(`${entityId}|FieldCategoryInfo`, ENTITY_SETTING_NAMESPACE);
|
|
7055
7554
|
try {
|
|
7056
|
-
const checkQuery = `SELECT 1 FROM ${this.qs(
|
|
7057
|
-
const insertSQL = `INSERT INTO ${this.qs(
|
|
7555
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(MjCoreSchema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryInfo'`;
|
|
7556
|
+
const insertSQL = `INSERT INTO ${this.qs(MjCoreSchema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
|
|
7058
7557
|
VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
7059
7558
|
const sSQL = this.conditionalInsert(checkQuery, insertSQL);
|
|
7060
|
-
await this.
|
|
7559
|
+
await this.logSQLAndExecute(pool, sSQL, `Insert FieldCategoryInfo setting for entity`, false);
|
|
7061
7560
|
}
|
|
7062
7561
|
catch (ex) {
|
|
7063
7562
|
logError('Error Applying Category Info Settings: Part 2', ex);
|
|
@@ -7070,24 +7569,24 @@ WHERE
|
|
|
7070
7569
|
iconsOnly[category] = info.icon;
|
|
7071
7570
|
}
|
|
7072
7571
|
}
|
|
7073
|
-
const canonicalIcons =
|
|
7572
|
+
const canonicalIcons = CanonicalJSONStringify(iconsOnly, 2);
|
|
7074
7573
|
const iconsJSON = canonicalIcons.replace(/'/g, "''");
|
|
7075
|
-
const checkLegacySQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(
|
|
7574
|
+
const checkLegacySQL = `SELECT ${this.qi('ID')}, ${this.qi('Value')} FROM ${this.qs(MjCoreSchema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
|
|
7076
7575
|
const existingLegacy = await this.runQuery(pool, checkLegacySQL);
|
|
7077
7576
|
if (existingLegacy.recordset.length > 0) {
|
|
7078
7577
|
let isMatch = false;
|
|
7079
7578
|
try {
|
|
7080
7579
|
const rawVal = existingLegacy.recordset[0].Value;
|
|
7081
7580
|
const parsed = typeof rawVal === 'string' ? JSON.parse(rawVal) : rawVal;
|
|
7082
|
-
isMatch =
|
|
7581
|
+
isMatch = DeepEqualJSON(parsed, iconsOnly);
|
|
7083
7582
|
}
|
|
7084
7583
|
catch {
|
|
7085
7584
|
isMatch = false;
|
|
7086
7585
|
}
|
|
7087
7586
|
if (!isMatch) {
|
|
7088
7587
|
try {
|
|
7089
|
-
await this.
|
|
7090
|
-
UPDATE ${this.qs(
|
|
7588
|
+
await this.logSQLAndExecute(pool, `
|
|
7589
|
+
UPDATE ${this.qs(MjCoreSchema(), 'EntitySetting')}
|
|
7091
7590
|
SET ${this.qi('Value')} = '${iconsJSON}', ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7092
7591
|
WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'
|
|
7093
7592
|
`, `Update FieldCategoryIcons setting (legacy)`, false);
|
|
@@ -7100,11 +7599,11 @@ WHERE
|
|
|
7100
7599
|
else {
|
|
7101
7600
|
const newId = uuidv5(`${entityId}|FieldCategoryIcons`, ENTITY_SETTING_NAMESPACE);
|
|
7102
7601
|
try {
|
|
7103
|
-
const checkQuery = `SELECT 1 FROM ${this.qs(
|
|
7104
|
-
const insertSQL = `INSERT INTO ${this.qs(
|
|
7602
|
+
const checkQuery = `SELECT 1 FROM ${this.qs(MjCoreSchema(), 'EntitySetting')} WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = 'FieldCategoryIcons'`;
|
|
7603
|
+
const insertSQL = `INSERT INTO ${this.qs(MjCoreSchema(), 'EntitySetting')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('Name')}, ${this.qi('Value')}, ${this.qi(EntityInfo.CreatedAtFieldName)}, ${this.qi(EntityInfo.UpdatedAtFieldName)})
|
|
7105
7604
|
VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})`;
|
|
7106
7605
|
const sSQL = this.conditionalInsert(checkQuery, insertSQL);
|
|
7107
|
-
await this.
|
|
7606
|
+
await this.logSQLAndExecute(pool, sSQL, `Insert FieldCategoryIcons setting (legacy)`, false);
|
|
7108
7607
|
}
|
|
7109
7608
|
catch (ex) {
|
|
7110
7609
|
logError('Error Applying Category Info Settings: Part 4', ex);
|
|
@@ -7117,12 +7616,12 @@ WHERE
|
|
|
7117
7616
|
*/
|
|
7118
7617
|
async applyEntityImportance(pool, entityId, importance, entityName) {
|
|
7119
7618
|
const updateSQL = `
|
|
7120
|
-
UPDATE ${this.qs(
|
|
7619
|
+
UPDATE ${this.qs(MjCoreSchema(), 'ApplicationEntity')}
|
|
7121
7620
|
SET ${this.qi('DefaultForNewUser')} = ${this.boolLit(importance.defaultForNewUser)}, ${this.qi(EntityInfo.UpdatedAtFieldName)} = ${this.utcNow()}
|
|
7122
7621
|
WHERE ${this.qi('EntityID')} = '${entityId}'
|
|
7123
7622
|
`;
|
|
7124
7623
|
try {
|
|
7125
|
-
await this.
|
|
7624
|
+
await this.logSQLAndExecute(pool, updateSQL, `Set DefaultForNewUser=${importance.defaultForNewUser} for NEW entity (category: ${importance.entityCategory}, confidence: ${importance.confidence})`, false);
|
|
7126
7625
|
logStatus(` Entity importance (NEW Entity): ${importance.entityCategory} (defaultForNewUser: ${importance.defaultForNewUser}, confidence: ${importance.confidence})`);
|
|
7127
7626
|
logStatus(` Reasoning: ${importance.reasoning}`);
|
|
7128
7627
|
}
|
|
@@ -7140,8 +7639,8 @@ WHERE
|
|
|
7140
7639
|
* @param isRecurringScript - if set to true tells the logger that the provided SQL represents a recurring script meaning it is something that is executed, generally, for all CodeGen runs. In these cases, the Config settings can result in omitting these recurring scripts from being logged because the configuration environment may have those recurring scripts already set to run after all run-specific migrations get run.
|
|
7141
7640
|
* @returns - The result of the query execution.
|
|
7142
7641
|
*/
|
|
7143
|
-
async
|
|
7144
|
-
return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator);
|
|
7642
|
+
async logSQLAndExecute(pool, query, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO', requiresOwnBatch = false) {
|
|
7643
|
+
return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript, includeBatchSeparator, batchSeparator, requiresOwnBatch);
|
|
7145
7644
|
}
|
|
7146
7645
|
/**
|
|
7147
7646
|
* Logs and executes a sequence of SQL statements as a single batch.
|
|
@@ -7159,17 +7658,17 @@ WHERE
|
|
|
7159
7658
|
* Empty / whitespace-only statements are filtered out. Returns
|
|
7160
7659
|
* `undefined` (no execution) when nothing remains.
|
|
7161
7660
|
*/
|
|
7162
|
-
async
|
|
7661
|
+
async logSQLBatchAndExecute(pool, statements, description, isRecurringScript = false, includeBatchSeparator = false, batchSeparator = 'GO') {
|
|
7163
7662
|
const terminated = [];
|
|
7164
7663
|
for (const s of statements) {
|
|
7165
|
-
const trimmed = (s ?? '')
|
|
7664
|
+
const trimmed = TrimTrailingStatementTerminators(s ?? '');
|
|
7166
7665
|
if (trimmed.length === 0)
|
|
7167
7666
|
continue;
|
|
7168
7667
|
terminated.push(`${trimmed};`);
|
|
7169
7668
|
}
|
|
7170
7669
|
if (terminated.length === 0)
|
|
7171
7670
|
return undefined;
|
|
7172
|
-
return await this.
|
|
7671
|
+
return await this.logSQLAndExecute(pool, terminated.join('\n'), description, isRecurringScript, includeBatchSeparator, batchSeparator);
|
|
7173
7672
|
}
|
|
7174
7673
|
}
|
|
7175
7674
|
//# sourceMappingURL=manage-metadata.js.map
|