@memberjunction/codegen-lib 5.43.0 → 5.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Database/manage-metadata.d.ts +87 -2
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +361 -9
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/providers/postgresql/metadataSupportObjects.js +33 -0
- package/dist/Database/providers/postgresql/metadataSupportObjects.js.map +1 -1
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +16 -0
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +31 -4
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/externalTypeMapping.d.ts +22 -0
- package/dist/Misc/externalTypeMapping.d.ts.map +1 -0
- package/dist/Misc/externalTypeMapping.js +74 -0
- package/dist/Misc/externalTypeMapping.js.map +1 -0
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +53 -5
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/package.json +27 -20
|
@@ -5,12 +5,13 @@ import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
|
|
|
5
5
|
// and the SS code path silently fails.
|
|
6
6
|
import './providers/sqlserver/SQLServerCodeGenProvider.js';
|
|
7
7
|
import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
|
|
8
|
-
import { EntityInfo, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
|
|
8
|
+
import { EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
|
|
9
9
|
import { MJEntityFieldSchema } from "@memberjunction/core-entities";
|
|
10
10
|
import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
|
|
11
11
|
import { SQLUtilityBase } from "./sql.js";
|
|
12
12
|
import { AdvancedGeneration } from "../Misc/advanced_generation.js";
|
|
13
13
|
import { CodeGenReporter } from "../Misc/codegen-reporter.js";
|
|
14
|
+
import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
|
|
14
15
|
import { SQLParser } from "@memberjunction/sql-parser";
|
|
15
16
|
import { createDisplayName, generatePluralName, MJGlobal, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
|
|
16
17
|
import { v4 as uuidv4 } from 'uuid';
|
|
@@ -44,6 +45,23 @@ export class ManageMetadataBase {
|
|
|
44
45
|
this._dbProvider = null;
|
|
45
46
|
// ─── End Dialect Infrastructure ───────────────────────────────────
|
|
46
47
|
this._sqlUtilityObject = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
|
|
48
|
+
/**
|
|
49
|
+
* External-data-source analogue of {@link manageVirtualEntities}. For each entity backed by an
|
|
50
|
+
* external data source, introspect the REMOTE schema (via the EDS router resolved through the
|
|
51
|
+
* ClassFactory) and sync its `EntityField` rows to match — the remote equivalent of reading the
|
|
52
|
+
* local INFORMATION_SCHEMA for a virtual/view entity. No-op when there are no external entities.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Whether the current database's `Entity` table has the `ExternalDataSourceID` column. External
|
|
56
|
+
* Data Sources ship as a SQL Server migration only, so on any database/schema that predates it
|
|
57
|
+
* (PostgreSQL today) the column is absent — and ANY raw SQL referencing it throws
|
|
58
|
+
* "column does not exist" and aborts the entire CodeGen run, even for a pure MJ-DB schema with no
|
|
59
|
+
* external entities. Callers gate EDS-specific queries on this so CodeGen stays green everywhere
|
|
60
|
+
* and auto-activates once the column lands on other platforms (mirrors the PG stored-proc guard in
|
|
61
|
+
* metadataSupportObjects.ts). Cross-platform via INFORMATION_SCHEMA; cached for the run (the schema
|
|
62
|
+
* cannot change mid-run).
|
|
63
|
+
*/
|
|
64
|
+
this._entityHasExternalDataSourceColumn = null;
|
|
47
65
|
}
|
|
48
66
|
/**
|
|
49
67
|
* Returns the CodeGenDatabaseProvider for the current database platform.
|
|
@@ -684,8 +702,18 @@ export class ManageMetadataBase {
|
|
|
684
702
|
logStatus(` > IS-A: "${childName}" already has ParentID set to "${parentName}", skipping`);
|
|
685
703
|
}
|
|
686
704
|
else {
|
|
687
|
-
// Set ParentID on the child entity
|
|
688
|
-
|
|
705
|
+
// Set ParentID on the child entity. Build an inlined (non-parameterized) UPDATE and
|
|
706
|
+
// run it via LogSQLAndExecute so the assignment is BOTH executed live AND serialized
|
|
707
|
+
// into the CodeGen_Run migration — mirroring applySoftPKFKConfig(). A parameterized
|
|
708
|
+
// runQueryWithParams() executes live but is never written to the migration file, so on
|
|
709
|
+
// any migration-only/clean deploy Entity.ParentID stays NULL, IsChildType is false, and
|
|
710
|
+
// creating a child record fails on the child→parent FK (the IS-A base view + mirrored
|
|
711
|
+
// virtual fields are already serialized, but this one flag was not).
|
|
712
|
+
const updateSQL = `UPDATE ${this.qs(schema, 'Entity')}
|
|
713
|
+
SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()},
|
|
714
|
+
${this.qi('ParentID')} = '${parentId}'
|
|
715
|
+
WHERE ${this.qi('ID')} = '${childId}'`;
|
|
716
|
+
await this.LogSQLAndExecute(pool, updateSQL, `Set IS-A ParentID for "${childName}" → "${parentName}"`);
|
|
689
717
|
if (existingParentId) {
|
|
690
718
|
logStatus(` > IS-A: Updated "${childName}" ParentID from previous value to "${parentName}"`);
|
|
691
719
|
}
|
|
@@ -952,6 +980,23 @@ export class ManageMetadataBase {
|
|
|
952
980
|
logError(' Error managing virtual entities');
|
|
953
981
|
bSuccess = false;
|
|
954
982
|
}
|
|
983
|
+
// External-data-source entities: introspect the REMOTE schema and sync their EntityField rows
|
|
984
|
+
// (the remote analogue of reading the local view's columns for a virtual entity).
|
|
985
|
+
const eeResult = await this.manageExternalEntities(pool, currentUser);
|
|
986
|
+
if (!eeResult.success) {
|
|
987
|
+
logError(' Error managing external data source entities');
|
|
988
|
+
bSuccess = false;
|
|
989
|
+
}
|
|
990
|
+
// External entities are processed AFTER the main manageEntityRelationships pass above, so any
|
|
991
|
+
// foreign keys they introspected (now set as soft FKs) need a second relationship pass to be
|
|
992
|
+
// materialized into EntityRelationship records in this same run.
|
|
993
|
+
if (eeResult.relationshipsUpdated) {
|
|
994
|
+
logStatus(' Managing external-entity relationships...');
|
|
995
|
+
if (!await this.manageEntityRelationships(pool, excludeSchemas, md)) {
|
|
996
|
+
logError(' Error managing external entity relationships');
|
|
997
|
+
bSuccess = false;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
955
1000
|
// LLM-assisted virtual entity field decoration — identify PKs, FKs, and descriptions
|
|
956
1001
|
await this.decorateVirtualEntitiesWithLLM(pool, currentUser);
|
|
957
1002
|
// Config-driven IS-A relationship setup — set ParentID on child entities
|
|
@@ -1069,6 +1114,257 @@ export class ManageMetadataBase {
|
|
|
1069
1114
|
}
|
|
1070
1115
|
return { success: bSuccess, anyUpdates: anyUpdates };
|
|
1071
1116
|
}
|
|
1117
|
+
async entityHasExternalDataSourceColumn(pool) {
|
|
1118
|
+
if (this._entityHasExternalDataSourceColumn === null) {
|
|
1119
|
+
// Check the VIEW the gated queries actually read (vwEntities), not the base Entity table — a
|
|
1120
|
+
// schema where the table has the column but the view wasn't refreshed to expose it would still
|
|
1121
|
+
// throw. (Matches the PG guard in metadataSupportObjects.ts, which also checks the view.)
|
|
1122
|
+
const sql = `SELECT COUNT(*) AS ColExists FROM INFORMATION_SCHEMA.COLUMNS ` +
|
|
1123
|
+
`WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = 'vwEntities' AND COLUMN_NAME = 'ExternalDataSourceID'`;
|
|
1124
|
+
const result = await this.runQuery(pool, sql);
|
|
1125
|
+
const row = (result.recordset?.[0] ?? {});
|
|
1126
|
+
const cnt = row.ColExists ?? row.colexists ?? 0;
|
|
1127
|
+
this._entityHasExternalDataSourceColumn = Number(cnt) > 0;
|
|
1128
|
+
}
|
|
1129
|
+
return this._entityHasExternalDataSourceColumn;
|
|
1130
|
+
}
|
|
1131
|
+
async manageExternalEntities(pool, currentUser) {
|
|
1132
|
+
let bSuccess = true;
|
|
1133
|
+
let anyUpdates = false;
|
|
1134
|
+
let relationshipsUpdated = false;
|
|
1135
|
+
// EDS is provisioned only where its (SQL Server) migration ran. On a database whose Entity table
|
|
1136
|
+
// lacks ExternalDataSourceID (e.g. PostgreSQL), there can be no external entities — and the query
|
|
1137
|
+
// below would throw and abort the whole CodeGen run — so skip cleanly.
|
|
1138
|
+
if (!(await this.entityHasExternalDataSourceColumn(pool))) {
|
|
1139
|
+
return { success: true, anyUpdates: false, relationshipsUpdated: false };
|
|
1140
|
+
}
|
|
1141
|
+
const sql = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE ExternalDataSourceID IS NOT NULL`;
|
|
1142
|
+
const result = await this.runQuery(pool, sql);
|
|
1143
|
+
const externalEntities = result.recordset;
|
|
1144
|
+
if (!externalEntities || externalEntities.length === 0) {
|
|
1145
|
+
return { success: true, anyUpdates: false, relationshipsUpdated: false };
|
|
1146
|
+
}
|
|
1147
|
+
// Lazily load the External Data Sources engine + drivers — ONLY now that we know external
|
|
1148
|
+
// entities exist. The dynamic import is deliberate (CLAUDE.md rule #8, category 3: build-tool
|
|
1149
|
+
// startup deferral): the common codegen run has no external entities and must not pay the cost
|
|
1150
|
+
// of loading pg/mongodb/the engine. The packages are declared deps; importing each triggers its
|
|
1151
|
+
// @RegisterClass side effects so the ClassFactory can resolve the router + drivers below.
|
|
1152
|
+
// snowflake-sdk is an optional peer the Snowflake driver loads only on connect.
|
|
1153
|
+
await import('@memberjunction/external-data-sources');
|
|
1154
|
+
await import('@memberjunction/external-data-source-postgres');
|
|
1155
|
+
await import('@memberjunction/external-data-source-snowflake');
|
|
1156
|
+
await import('@memberjunction/external-data-source-mongodb');
|
|
1157
|
+
await import('@memberjunction/external-data-source-sqlserver');
|
|
1158
|
+
await import('@memberjunction/external-data-source-mysql');
|
|
1159
|
+
await import('@memberjunction/external-data-source-oracle');
|
|
1160
|
+
const router = MJGlobal.Instance.ClassFactory.CreateInstance(ExternalDataSourceReadRouter);
|
|
1161
|
+
if (!router) {
|
|
1162
|
+
logError(' Cannot sync external entity fields: no ExternalDataSourceReadRouter is registered. Ensure @memberjunction/external-data-sources (and the relevant driver) are loaded in the CodeGen process.');
|
|
1163
|
+
return { success: false, anyUpdates: false, relationshipsUpdated: false };
|
|
1164
|
+
}
|
|
1165
|
+
// Refresh the in-memory metadata cache so the external entities are present for EntityByName()
|
|
1166
|
+
// inside manageSingleExternalEntity / manageSingleVirtualEntityField. External entities are
|
|
1167
|
+
// often created/seeded after the CodeGen process loaded metadata at startup; without this
|
|
1168
|
+
// refresh, EntityByName() returns null and field sync is SILENTLY skipped — the same gotcha
|
|
1169
|
+
// already handled for config-created virtual entities above.
|
|
1170
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1171
|
+
await md.Refresh();
|
|
1172
|
+
for (const ee of externalEntities) {
|
|
1173
|
+
const { success, updatedEntity, relationshipsUpdated: relUpdated } = await this.manageSingleExternalEntity(pool, ee, router, currentUser);
|
|
1174
|
+
anyUpdates = anyUpdates || updatedEntity;
|
|
1175
|
+
relationshipsUpdated = relationshipsUpdated || relUpdated;
|
|
1176
|
+
if (!success) {
|
|
1177
|
+
logError(` Error managing external entity ${ee.Name}`);
|
|
1178
|
+
bSuccess = false;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return { success: bSuccess, anyUpdates, relationshipsUpdated };
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Whether an introspected remote object has columns we can safely sync. When false (object
|
|
1185
|
+
* missing or zero columns), {@link manageSingleExternalEntity} skips the field sync rather than
|
|
1186
|
+
* treating it as "the entity now has no fields" (which would DELETE every EntityField). Pure/testable.
|
|
1187
|
+
*/
|
|
1188
|
+
externalObjectIsSyncable(obj) {
|
|
1189
|
+
return !!(obj && Array.isArray(obj.Columns) && obj.Columns.length > 0);
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Builds the DELETE for EntityFields no longer present in the freshly-introspected remote object,
|
|
1193
|
+
* or '' when nothing should be removed. Field-name matching is case-insensitive. Pure/testable
|
|
1194
|
+
* (no DB access) — the data-loss-sensitive computation guarded by {@link externalObjectIsSyncable}.
|
|
1195
|
+
*/
|
|
1196
|
+
buildExternalFieldRemoveSQL(schema, existingFields, introspectedFieldNames) {
|
|
1197
|
+
const present = new Set(introspectedFieldNames.map(n => n.trim().toLowerCase()));
|
|
1198
|
+
const removeIds = existingFields.filter(f => !present.has(f.Name.trim().toLowerCase())).map(f => f.ID);
|
|
1199
|
+
if (removeIds.length === 0) {
|
|
1200
|
+
return '';
|
|
1201
|
+
}
|
|
1202
|
+
const idList = removeIds.map(id => `'${id}'`).join(',');
|
|
1203
|
+
// Clear FK dependents (value-list entries) before the EntityField delete to avoid an FK violation,
|
|
1204
|
+
// mirroring spDeleteUnneededEntityFields. Emitted as two statements separated by the platform terminator.
|
|
1205
|
+
return `DELETE FROM ${this.qs(schema, 'EntityFieldValue')} WHERE EntityFieldID IN (${idList});\n` +
|
|
1206
|
+
`DELETE FROM ${this.qs(schema, 'EntityField')} WHERE ID IN (${idList})`;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Introspects the remote schema for a single external entity and syncs its `EntityField` rows.
|
|
1210
|
+
* Mirrors {@link manageSingleVirtualEntity} but sources the field list from the driver's
|
|
1211
|
+
* `IntrospectSchema` (mapped to MJ types via {@link mapExternalNativeTypeToMJ}) instead of the
|
|
1212
|
+
* local view's columns, and reuses {@link manageSingleVirtualEntityField} for create/update.
|
|
1213
|
+
*/
|
|
1214
|
+
async manageSingleExternalEntity(pool, externalEntity, router, currentUser) {
|
|
1215
|
+
let bSuccess = true;
|
|
1216
|
+
let bUpdated = false;
|
|
1217
|
+
let bRelationshipsUpdated = false;
|
|
1218
|
+
// Introspection reaches a live remote system. A connection/auth/network failure there must NOT
|
|
1219
|
+
// fail the whole CodeGen run (which also regenerates every MJ-DB entity) — treat it as a
|
|
1220
|
+
// recoverable warning-skip, leaving this entity's existing fields untouched. Genuine errors in
|
|
1221
|
+
// the field-sync logic below still fail (caught by the outer try/catch as success:false).
|
|
1222
|
+
let descriptor;
|
|
1223
|
+
try {
|
|
1224
|
+
descriptor = await router.IntrospectExternalSchema(externalEntity.ExternalDataSourceID, externalEntity.SchemaName || undefined, currentUser);
|
|
1225
|
+
}
|
|
1226
|
+
catch (e) {
|
|
1227
|
+
// Surface via logError (not logStatus) so a genuine driver bug isn't invisible in a green run —
|
|
1228
|
+
// but still recover (success:true) so one unreachable remote source can't fail the whole run.
|
|
1229
|
+
logError(` ⚠️ External entity ${externalEntity.Name}: remote schema introspection failed (${e instanceof Error ? e.message : String(e)}) — skipping this entity (left untouched); CodeGen run continues.`);
|
|
1230
|
+
return { success: true, updatedEntity: false, relationshipsUpdated: false };
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
// Find the remote object backing this entity (prefer ExternalObjectName; fall back to
|
|
1234
|
+
// BaseTable/Name). Match on bare or schema-qualified name, case-insensitively.
|
|
1235
|
+
const target = (externalEntity.ExternalObjectName || externalEntity.BaseTable || externalEntity.Name || '').trim().toLowerCase();
|
|
1236
|
+
const obj = descriptor.Objects.find(o => o.Name.trim().toLowerCase() === target ||
|
|
1237
|
+
`${o.Schema ?? ''}.${o.Name}`.trim().toLowerCase() === target);
|
|
1238
|
+
if (!obj) {
|
|
1239
|
+
logStatus(` ⚠️ External entity ${externalEntity.Name}: remote object '${externalEntity.ExternalObjectName ?? externalEntity.Name}' not found in introspected schema — skipping this entity (left untouched); CodeGen run continues.`);
|
|
1240
|
+
// Recoverable skip (misconfigured/unreachable object): leave the entity untouched and
|
|
1241
|
+
// continue the run rather than failing the whole metadata pass for one external entity.
|
|
1242
|
+
return { success: true, updatedEntity: false, relationshipsUpdated: false };
|
|
1243
|
+
}
|
|
1244
|
+
// Guard the degenerate "found but zero columns" case the same way as "not found": a
|
|
1245
|
+
// permission-limited / transient / partial introspection that returns an object shell with
|
|
1246
|
+
// no columns must NOT be treated as "the entity now has no fields" — that would make the
|
|
1247
|
+
// remove-list below every existing EntityField and DELETE them all (silent metadata loss).
|
|
1248
|
+
// Bail with a warning instead and leave the existing fields untouched.
|
|
1249
|
+
if (!this.externalObjectIsSyncable(obj)) {
|
|
1250
|
+
logStatus(` ⚠️ External entity ${externalEntity.Name}: remote object '${obj.Name}' introspected with zero columns — skipping field sync to avoid destroying existing field metadata; CodeGen run continues.`);
|
|
1251
|
+
// Recoverable skip (permission-limited / transient / partial introspection): leave the
|
|
1252
|
+
// existing fields untouched and continue, rather than failing the whole metadata pass.
|
|
1253
|
+
return { success: true, updatedEntity: false, relationshipsUpdated: false };
|
|
1254
|
+
}
|
|
1255
|
+
// Map each introspected column into the veField shape that manageSingleVirtualEntityField consumes.
|
|
1256
|
+
const eeFields = obj.Columns.map(c => {
|
|
1257
|
+
const t = mapExternalNativeTypeToMJ(c.NativeType);
|
|
1258
|
+
return { FieldName: c.Name, Type: t.Type, Length: t.Length, Precision: t.Precision, Scale: t.Scale, AllowsNull: c.Nullable, IsPrimaryKey: c.IsPrimaryKey };
|
|
1259
|
+
});
|
|
1260
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1261
|
+
const entity = md.EntityByName(externalEntity.Name);
|
|
1262
|
+
if (entity) {
|
|
1263
|
+
// remove EntityFields no longer present in the remote object
|
|
1264
|
+
const sqlRemove = this.buildExternalFieldRemoveSQL(mj_core_schema(), entity.Fields, eeFields.map(ef => ef.FieldName));
|
|
1265
|
+
if (sqlRemove) {
|
|
1266
|
+
await this.LogSQLAndExecute(pool, sqlRemove, `SQL text to remove fields from external entity ${externalEntity.Name}`);
|
|
1267
|
+
bUpdated = true;
|
|
1268
|
+
}
|
|
1269
|
+
// create/update each remote column. Use the real PK info from introspection; fall back
|
|
1270
|
+
// to "first column is the PK" when the remote reported none (e.g. a view).
|
|
1271
|
+
const anyPk = eeFields.some(f => f.IsPrimaryKey);
|
|
1272
|
+
// How many columns make up the PK? Introspected PK count when present, else 1 (first-column
|
|
1273
|
+
// fallback). A composite PK (>1) means no single column is unique on its own (M4).
|
|
1274
|
+
const primaryKeyColumnCount = anyPk ? eeFields.filter(f => f.IsPrimaryKey).length : 1;
|
|
1275
|
+
const singleColumnPrimaryKey = primaryKeyColumnCount === 1;
|
|
1276
|
+
for (let i = 0; i < eeFields.length; i++) {
|
|
1277
|
+
const ef = eeFields[i];
|
|
1278
|
+
const makePrimaryKey = ef.IsPrimaryKey || (!anyPk && i === 0);
|
|
1279
|
+
// reconcilePrimaryKey=true: introspection is authoritative, so sync PK/Unique flags in BOTH
|
|
1280
|
+
// directions (set AND clear) — this clears a stale PK column when the remote PK moves (H5).
|
|
1281
|
+
const { success, updatedField } = await this.manageSingleVirtualEntityField(pool, externalEntity, ef, i + 1, makePrimaryKey, singleColumnPrimaryKey, true);
|
|
1282
|
+
bUpdated = bUpdated || updatedField;
|
|
1283
|
+
if (!success) {
|
|
1284
|
+
logError(`Error managing external entity field ${ef.FieldName} for external entity ${externalEntity.Name}`);
|
|
1285
|
+
bSuccess = false;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// Baseline FK consumption: turn introspected single-column foreign keys into soft FKs
|
|
1289
|
+
// (RelatedEntityID + IsSoftForeignKey) so the standard manageEntityRelationships pass
|
|
1290
|
+
// materializes them into EntityRelationship records.
|
|
1291
|
+
if (await this.manageExternalEntityRelationships(pool, externalEntity, obj, md)) {
|
|
1292
|
+
bUpdated = true;
|
|
1293
|
+
bRelationshipsUpdated = true;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (bUpdated) {
|
|
1297
|
+
const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${externalEntity.ID}'`;
|
|
1298
|
+
await this.LogSQLAndExecute(pool, sqlUpdate, `SQL text to update external entity updated date for ${externalEntity.Name}`);
|
|
1299
|
+
}
|
|
1300
|
+
return { success: bSuccess, updatedEntity: bUpdated, relationshipsUpdated: bRelationshipsUpdated };
|
|
1301
|
+
}
|
|
1302
|
+
catch (e) {
|
|
1303
|
+
logError(e instanceof Error ? e.message : String(e));
|
|
1304
|
+
return { success: false, updatedEntity: bUpdated, relationshipsUpdated: bRelationshipsUpdated };
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Baseline foreign-key consumption for an external entity: for each introspected single-column
|
|
1309
|
+
* relationship whose referenced remote object is ALSO an imported external entity in the same
|
|
1310
|
+
* data source, set the FK field's RelatedEntityID + RelatedEntityFieldName + IsSoftForeignKey=1.
|
|
1311
|
+
* The standard `manageEntityRelationships` pass then materializes these into EntityRelationship
|
|
1312
|
+
* records. Composite FKs and references to objects that aren't imported are skipped (logged) —
|
|
1313
|
+
* those are the follow-up hardening. Returns true if any FK field was updated.
|
|
1314
|
+
*/
|
|
1315
|
+
async manageExternalEntityRelationships(pool, externalEntity, obj, md) {
|
|
1316
|
+
const relationships = obj.Relationships ?? [];
|
|
1317
|
+
// No introspected relationships → nothing to set. We deliberately do NOT try to "clear dropped FKs"
|
|
1318
|
+
// here: introspection returning zero relationships is AMBIGUOUS — it happens for drivers that don't
|
|
1319
|
+
// report FK metadata at all (MongoDB), and for low-privilege logins where the catalog FK views are
|
|
1320
|
+
// permission-filtered to zero rows (SQL Server sys.foreign_keys). Clearing on that signal would wipe
|
|
1321
|
+
// user-authored soft-FKs (which are indistinguishable from CodeGen-set ones — there is no marker),
|
|
1322
|
+
// so we leave existing FK metadata untouched. (See the change-detection below for the no-churn set path.)
|
|
1323
|
+
if (relationships.length === 0) {
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
const dsID = externalEntity.ExternalDataSourceID;
|
|
1327
|
+
const entity = md.EntityByName(externalEntity.Name); // read current field FK metadata (prior-run state)
|
|
1328
|
+
const sqlStatements = [];
|
|
1329
|
+
for (const rel of relationships) {
|
|
1330
|
+
if (rel.Columns.length !== 1) {
|
|
1331
|
+
logStatus(` ⚠ external FK ${externalEntity.Name}.${rel.Name ?? '(unnamed)'}: composite key (${rel.Columns.length} columns) — skipped (baseline supports single-column FKs)`);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const { Column: fkColumn, ReferencedColumn: refColumn } = rel.Columns[0];
|
|
1335
|
+
// Resolve the referenced object to an imported external entity in the SAME data source
|
|
1336
|
+
// (match ExternalObjectName, falling back to BaseTable/Name — mirrors how this entity's
|
|
1337
|
+
// own object is resolved above).
|
|
1338
|
+
const target = rel.ReferencedObject.trim().toLowerCase();
|
|
1339
|
+
const relatedEntity = md.Entities.find(e => e.ExternalDataSourceID && dsID && UUIDsEqual(e.ExternalDataSourceID, dsID) &&
|
|
1340
|
+
((e.ExternalObjectName || e.BaseTable || e.Name) || '').trim().toLowerCase() === target);
|
|
1341
|
+
if (!relatedEntity) {
|
|
1342
|
+
logStatus(` ⚠ external FK ${externalEntity.Name}.${fkColumn} → '${rel.ReferencedObject}': referenced entity not imported — skipped`);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
// Change detection (M5): skip the UPDATE when the field already carries the exact desired FK
|
|
1346
|
+
// metadata — otherwise every CodeGen run re-UPDATEs the row, bumping __mj_UpdatedAt and logging
|
|
1347
|
+
// a spurious change even though nothing changed.
|
|
1348
|
+
const field = entity?.Fields.find(f => f.Name.trim().toLowerCase() === fkColumn.trim().toLowerCase());
|
|
1349
|
+
const alreadySet = !!field && field.IsSoftForeignKey === true &&
|
|
1350
|
+
!!field.RelatedEntityID && UUIDsEqual(field.RelatedEntityID, relatedEntity.ID) &&
|
|
1351
|
+
(field.RelatedEntityFieldName ?? '').trim() === refColumn.trim();
|
|
1352
|
+
if (alreadySet) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
sqlStatements.push(`UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
|
|
1356
|
+
SET RelatedEntityID='${relatedEntity.ID}',
|
|
1357
|
+
RelatedEntityFieldName='${refColumn.replace(/'/g, "''")}',
|
|
1358
|
+
IsSoftForeignKey=1
|
|
1359
|
+
WHERE EntityID='${externalEntity.ID}' AND Name='${fkColumn.replace(/'/g, "''")}'`);
|
|
1360
|
+
logStatus(` ✓ external FK ${externalEntity.Name}.${fkColumn} → ${relatedEntity.Name}.${refColumn}`);
|
|
1361
|
+
}
|
|
1362
|
+
if (sqlStatements.length === 0) {
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
await this.LogSQLBatchAndExecute(pool, sqlStatements, `Set external foreign keys for ${externalEntity.Name}`);
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1072
1368
|
async manageSingleVirtualEntity(pool, virtualEntity) {
|
|
1073
1369
|
let bSuccess = true;
|
|
1074
1370
|
let bUpdated = false;
|
|
@@ -1122,9 +1418,40 @@ export class ManageMetadataBase {
|
|
|
1122
1418
|
return { success: false, updatedEntity: bUpdated };
|
|
1123
1419
|
}
|
|
1124
1420
|
}
|
|
1125
|
-
|
|
1421
|
+
/**
|
|
1422
|
+
* Desired PK/Unique flags for a virtual/external entity field. IsPrimaryKey mirrors makePrimaryKey;
|
|
1423
|
+
* IsUnique is true ONLY for a SINGLE-column PK — a column of a COMPOSITE key is not unique on its own,
|
|
1424
|
+
* so composite-key columns get IsUnique=false (M4). Pure function so the rule is unit-tested.
|
|
1425
|
+
*/
|
|
1426
|
+
resolvePrimaryKeyFlags(makePrimaryKey, singleColumnPrimaryKey) {
|
|
1427
|
+
return { wantPrimaryKey: makePrimaryKey, wantUnique: makePrimaryKey && singleColumnPrimaryKey };
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Whether a field's PK/Unique flags need to change. In RECONCILE mode (external entities, whose
|
|
1431
|
+
* introspected PK set is authoritative across ALL fields) we sync in BOTH directions — setting the
|
|
1432
|
+
* flags when a column becomes a PK AND clearing them when it stops being one (H5: otherwise a stale PK
|
|
1433
|
+
* column keeps IsPrimaryKey=1 when the remote PK moves, yielding duplicate PKs). In NON-reconcile mode
|
|
1434
|
+
* (virtual entities, where makePrimaryKey is a one-time first-column bootstrap, not authoritative) we
|
|
1435
|
+
* only SET on acquisition and never clear — avoiding wiping a legitimately-configured PK, and avoiding
|
|
1436
|
+
* a spurious UPDATE (+ __mj_UpdatedAt bump) every run when nothing changed. Pure function; unit-tested.
|
|
1437
|
+
*/
|
|
1438
|
+
primaryKeyFlagsChanged(current, want, makePrimaryKey, reconcile) {
|
|
1439
|
+
if (!reconcile) {
|
|
1440
|
+
return makePrimaryKey && !current.isPrimaryKey;
|
|
1441
|
+
}
|
|
1442
|
+
// Reconcile mode: sync PK status in both directions. IsUnique is managed ONLY in tandem with the
|
|
1443
|
+
// primary key (when the field is, or is becoming, a PK) — we must NOT force IsUnique=0 on an
|
|
1444
|
+
// ordinary non-PK column, which would silently wipe a legitimately-unique field (e.g. a user- or
|
|
1445
|
+
// introspection-marked unique `email`) that has nothing to do with the primary key.
|
|
1446
|
+
const pkStatusChanged = current.isPrimaryKey !== want.wantPrimaryKey;
|
|
1447
|
+
const uniqueIsPkRelated = want.wantPrimaryKey || current.isPrimaryKey;
|
|
1448
|
+
const uniqueChanged = uniqueIsPkRelated && current.isUnique !== want.wantUnique;
|
|
1449
|
+
return pkStatusChanged || uniqueChanged;
|
|
1450
|
+
}
|
|
1451
|
+
async manageSingleVirtualEntityField(pool, virtualEntity, veField, fieldSequence, makePrimaryKey, singleColumnPrimaryKey = true, reconcilePrimaryKey = false) {
|
|
1126
1452
|
// this protected checks to see if the field exists in the entity definition, and if not, adds it
|
|
1127
1453
|
// if it exist it updates the entity field to match the view's data type and nullability attributes
|
|
1454
|
+
const { wantPrimaryKey, wantUnique } = this.resolvePrimaryKeyFlags(makePrimaryKey, singleColumnPrimaryKey);
|
|
1128
1455
|
// first, get the entity definition
|
|
1129
1456
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1130
1457
|
const entity = md.EntityByName(virtualEntity.Name);
|
|
@@ -1133,8 +1460,15 @@ export class ManageMetadataBase {
|
|
|
1133
1460
|
if (entity) {
|
|
1134
1461
|
const field = entity.Fields.find(f => f.Name.trim().toLowerCase() === veField.FieldName.trim().toLowerCase());
|
|
1135
1462
|
if (field) {
|
|
1136
|
-
//
|
|
1137
|
-
|
|
1463
|
+
// Decide whether the PK/Unique flags need to change. In RECONCILE mode (external entities,
|
|
1464
|
+
// whose introspected PK set is authoritative across ALL fields) we sync in BOTH directions —
|
|
1465
|
+
// setting the flags when a column becomes a PK AND clearing them when it stops being one (H5:
|
|
1466
|
+
// otherwise a stale PK column keeps IsPrimaryKey=1 when the remote PK moves, yielding duplicate
|
|
1467
|
+
// PKs). In NON-reconcile mode (virtual entities, where makePrimaryKey is a one-time first-column
|
|
1468
|
+
// bootstrap, not authoritative) we only SET on acquisition and never clear, to avoid wiping a
|
|
1469
|
+
// legitimately-configured PK — and we avoid re-UPDATING an unchanged row every run.
|
|
1470
|
+
const pkFlagsChanged = this.primaryKeyFlagsChanged({ isPrimaryKey: field.IsPrimaryKey, isUnique: field.IsUnique }, { wantPrimaryKey, wantUnique }, makePrimaryKey, reconcilePrimaryKey);
|
|
1471
|
+
if (pkFlagsChanged ||
|
|
1138
1472
|
field.Type.trim().toLowerCase() !== veField.Type.trim().toLowerCase() ||
|
|
1139
1473
|
field.Length !== veField.Length ||
|
|
1140
1474
|
field.AllowsNull !== veField.AllowsNull ||
|
|
@@ -1148,7 +1482,7 @@ export class ManageMetadataBase {
|
|
|
1148
1482
|
Sequence=${fieldSequence},
|
|
1149
1483
|
Type='${veField.Type}',
|
|
1150
1484
|
AllowsNull=${this.boolLit(veField.AllowsNull)},
|
|
1151
|
-
${
|
|
1485
|
+
${pkFlagsChanged ? `IsPrimaryKey=${this.boolLit(wantPrimaryKey)},IsUnique=${this.boolLit(wantUnique)},` : ''}
|
|
1152
1486
|
Length=${veField.Length},
|
|
1153
1487
|
Precision=${veField.Precision},
|
|
1154
1488
|
Scale=${veField.Scale}
|
|
@@ -1172,9 +1506,9 @@ export class ManageMetadataBase {
|
|
|
1172
1506
|
${q('Length')}, ${q('Precision')}, ${q('Scale')},
|
|
1173
1507
|
${q('Sequence')}, ${q('IsPrimaryKey')}, ${q('IsUnique')},
|
|
1174
1508
|
${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
|
|
1175
|
-
VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${veField.FieldName}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
|
|
1509
|
+
VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${String(veField.FieldName).replace(/'/g, "''")}', '${veField.Type}', ${this.boolLit(veField.AllowsNull)},
|
|
1176
1510
|
${veField.Length}, ${veField.Precision}, ${veField.Scale},
|
|
1177
|
-
${safeSequence}, ${this.boolLit(
|
|
1511
|
+
${safeSequence}, ${this.boolLit(wantPrimaryKey)}, ${this.boolLit(wantUnique)},
|
|
1178
1512
|
${this.utcNow()}, ${this.utcNow()}
|
|
1179
1513
|
)`;
|
|
1180
1514
|
await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
|
|
@@ -1949,6 +2283,17 @@ export class ManageMetadataBase {
|
|
|
1949
2283
|
const entities = entitiesResult.recordset;
|
|
1950
2284
|
if (entities && entities.length > 0) {
|
|
1951
2285
|
for (const e of entities) {
|
|
2286
|
+
// External-data-source entities have no physical MJ table by design, so they always
|
|
2287
|
+
// surface in vwEntitiesWithMissingBaseTables — but they must NOT be pruned (their data
|
|
2288
|
+
// lives on a remote system). Skip them here. This is the analogue of the VirtualEntity
|
|
2289
|
+
// exclusion, but kept as a single dialect-agnostic code guard rather than baked into the
|
|
2290
|
+
// per-dialect filter (getEntitiesWithMissingBaseTablesFilter) or the view, so it applies
|
|
2291
|
+
// uniformly across SQL Server and PostgreSQL. (The External Data Sources migration only
|
|
2292
|
+
// recreates the view to re-expose the ExternalDataSourceID column to this guard; it does
|
|
2293
|
+
// not exclude external entities at the SQL level.)
|
|
2294
|
+
if (e.ExternalDataSourceID) {
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
1952
2297
|
// for the given entity, wipe out the entity metadata and its core deps.
|
|
1953
2298
|
// the below could fail if there are non-core dependencies on the entity, but that's ok, we will flag that in the console
|
|
1954
2299
|
// for the admin to handle manually
|
|
@@ -2432,12 +2777,19 @@ export class ManageMetadataBase {
|
|
|
2432
2777
|
*/
|
|
2433
2778
|
async ensureCreatedAtUpdatedAtFieldsExist(pool, excludeSchemas) {
|
|
2434
2779
|
try {
|
|
2780
|
+
// Only exclude external entities where the ExternalDataSourceID column exists (SQL Server today).
|
|
2781
|
+
// On PostgreSQL the column is absent, so referencing it here would throw and fail CodeGen; external
|
|
2782
|
+
// entities cannot exist there anyway, so the predicate is simply omitted.
|
|
2783
|
+
const externalEntityClause = (await this.entityHasExternalDataSourceColumn(pool))
|
|
2784
|
+
? 'ExternalDataSourceID IS NULL AND -- external entities have no physical base table to add __mj_ system columns to (data is remote)'
|
|
2785
|
+
: '';
|
|
2435
2786
|
const sqlEntities = `SELECT
|
|
2436
2787
|
*
|
|
2437
2788
|
FROM
|
|
2438
2789
|
${this.qs(mj_core_schema(), 'vwEntities')}
|
|
2439
2790
|
WHERE
|
|
2440
2791
|
VirtualEntity = ${this.boolLit(false)} AND
|
|
2792
|
+
${externalEntityClause}
|
|
2441
2793
|
TrackRecordChanges = ${this.boolLit(true)} AND
|
|
2442
2794
|
SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})`;
|
|
2443
2795
|
const entitiesResult = await this.runQuery(pool, sqlEntities);
|