@memberjunction/record-graph 0.0.0 → 6.2.0-edge.2
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/LICENSE +183 -0
- package/README.md +86 -43
- package/dist/DependencyGraphWalker.d.ts +129 -0
- package/dist/DependencyGraphWalker.d.ts.map +1 -0
- package/dist/DependencyGraphWalker.js +847 -0
- package/dist/DependencyGraphWalker.js.map +1 -0
- package/dist/collection-resolver.d.ts +25 -0
- package/dist/collection-resolver.d.ts.map +1 -0
- package/dist/collection-resolver.js +120 -0
- package/dist/collection-resolver.js.map +1 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +17 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +23 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +35 -0
- package/dist/keys.js.map +1 -0
- package/dist/links.d.ts +13 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +14 -0
- package/dist/links.js.map +1 -0
- package/dist/sort.d.ts +23 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +73 -0
- package/dist/sort.js.map +1 -0
- package/dist/sql.d.ts +24 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +43 -0
- package/dist/sql.js.map +1 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +28 -8
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { CompositeKey, Metadata, RunView, LogError, LogStatus, } from '@memberjunction/core';
|
|
2
|
+
import { EscapeSQLString, NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
|
|
3
|
+
import { BuildCompositeKeyFromRecord } from './keys.js';
|
|
4
|
+
import { SortNodesTopologically } from './sort.js';
|
|
5
|
+
import { SYSTEM_FK_SKIP_PATTERNS } from './constants.js';
|
|
6
|
+
/**
|
|
7
|
+
* Walks a record's relationship graph to discover all dependent and referenced records.
|
|
8
|
+
*
|
|
9
|
+
* Traversal rules:
|
|
10
|
+
* - Reverse walk: follows curated One-To-Many relationships (where child points to parent).
|
|
11
|
+
* Recurses with 'full' mode (both reverse + forward).
|
|
12
|
+
* - Forward walk: follows foreign keys on the parent (where parent points to another entity).
|
|
13
|
+
* Recurses with 'forward-only' mode — targets do NOT discover their own reverse children,
|
|
14
|
+
* preventing graph explosion from shared/hub entities.
|
|
15
|
+
* - Subtypes: follows IS-A inheritance if IncludeSubtypes is true.
|
|
16
|
+
* - Hierarchies: follows self-referencing hierarchy FKs if FollowHierarchies is true.
|
|
17
|
+
* - Soft links: follows inbound polymorphic EntityID/RecordID links if IncludeSoftLinks is true.
|
|
18
|
+
* - Non-curated inbound FKs: discovers non-curated inbound FKs if ListNonCuratedInbound is true.
|
|
19
|
+
* - EdgePolicy: callers can supply an EdgePolicy callback to dictate Deep / Reference / Skip per edge.
|
|
20
|
+
* - Cycle prevention: uses ancestorStack (entity types) to prevent backtracking and a global visited set (records).
|
|
21
|
+
*/
|
|
22
|
+
export class DependencyGraphWalker {
|
|
23
|
+
constructor(provider) {
|
|
24
|
+
/** Cache: entityID → reverse relationships from EntityRelationship metadata */
|
|
25
|
+
this.reverseRelCache = new Map();
|
|
26
|
+
/** Cache: entityID → forward FK references from field metadata */
|
|
27
|
+
this.forwardRefCache = new Map();
|
|
28
|
+
this._provider = provider;
|
|
29
|
+
}
|
|
30
|
+
/** Returns the active provider — explicit override if set, otherwise the global default. */
|
|
31
|
+
get ProviderToUse() {
|
|
32
|
+
return (this._provider ?? Metadata.Provider);
|
|
33
|
+
}
|
|
34
|
+
// =========================================================================
|
|
35
|
+
// Public API
|
|
36
|
+
// =========================================================================
|
|
37
|
+
/**
|
|
38
|
+
* Walk from a root record through its relationships, building a tree of
|
|
39
|
+
* all records that should be included in a version label or clone plan.
|
|
40
|
+
*
|
|
41
|
+
* @param entityName - The starting entity name
|
|
42
|
+
* @param recordKey - The starting record's primary key
|
|
43
|
+
* @param options - Controls depth, filtering, policies, etc.
|
|
44
|
+
* @param contextUser - Server-side user context
|
|
45
|
+
* @returns The root DependencyNode with all descendants populated
|
|
46
|
+
*/
|
|
47
|
+
async WalkDependents(entityName, recordKey, options, contextUser) {
|
|
48
|
+
const md = this.ProviderToUse;
|
|
49
|
+
const entityInfo = md.EntityByName(entityName);
|
|
50
|
+
if (!entityInfo) {
|
|
51
|
+
throw new Error(`Entity '${entityName}' not found in metadata`);
|
|
52
|
+
}
|
|
53
|
+
const resolvedOptions = this.resolveDefaults(options);
|
|
54
|
+
const visited = new Set();
|
|
55
|
+
const ancestorStack = new Set();
|
|
56
|
+
const stats = this.createEmptyStats();
|
|
57
|
+
this.reverseRelCache.clear();
|
|
58
|
+
this.forwardRefCache.clear();
|
|
59
|
+
const rootData = await this.loadRecordData(entityInfo, recordKey, contextUser);
|
|
60
|
+
const rootNode = {
|
|
61
|
+
EntityName: entityName,
|
|
62
|
+
EntityInfo: entityInfo,
|
|
63
|
+
RecordKey: recordKey,
|
|
64
|
+
RecordID: recordKey.ToConcatenatedString(),
|
|
65
|
+
RecordData: rootData,
|
|
66
|
+
Relationship: null,
|
|
67
|
+
Children: [],
|
|
68
|
+
Depth: 0,
|
|
69
|
+
DiscoveringEdge: null,
|
|
70
|
+
};
|
|
71
|
+
// Mark root as visited and push its entity type onto the ancestor stack
|
|
72
|
+
visited.add(this.visitKey(entityInfo.Name, rootNode.RecordID));
|
|
73
|
+
ancestorStack.add(entityInfo.Name);
|
|
74
|
+
this.incrementEntityCount(stats, entityInfo.Name);
|
|
75
|
+
// Root always gets full discovery mode (both reverse + forward); walkChildren also walks
|
|
76
|
+
// the root's IS-A subtypes when IncludeSubtypes is on.
|
|
77
|
+
await this.walkChildren(rootNode, 'full', resolvedOptions, visited, ancestorStack, stats, contextUser);
|
|
78
|
+
// Pop root (cleanup)
|
|
79
|
+
ancestorStack.delete(entityInfo.Name);
|
|
80
|
+
this.logWalkSummary(entityName, resolvedOptions, stats);
|
|
81
|
+
return rootNode;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Flatten a dependency tree into a topologically sorted list.
|
|
85
|
+
* Parents appear before their children, ensuring safe restore or clone execution ordering.
|
|
86
|
+
*/
|
|
87
|
+
FlattenTopological(root) {
|
|
88
|
+
return SortNodesTopologically(root);
|
|
89
|
+
}
|
|
90
|
+
// =========================================================================
|
|
91
|
+
// Core Walk Logic
|
|
92
|
+
// =========================================================================
|
|
93
|
+
/**
|
|
94
|
+
* Recursively discover and attach child nodes for a given parent node.
|
|
95
|
+
*/
|
|
96
|
+
async walkChildren(parentNode, discoveryMode, options, visited, ancestorStack, stats, contextUser) {
|
|
97
|
+
if (parentNode.Depth >= options.MaxDepth) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Reverse walk: only when in full mode (root or reverse-discovered nodes)
|
|
101
|
+
if (discoveryMode === 'full') {
|
|
102
|
+
await this.walkReverseRelationships(parentNode, options, visited, ancestorStack, stats, contextUser);
|
|
103
|
+
if (options.ListNonCuratedInbound) {
|
|
104
|
+
await this.walkNonCuratedInbound(parentNode, options, visited, ancestorStack, stats, contextUser);
|
|
105
|
+
}
|
|
106
|
+
if (options.IncludeSoftLinks) {
|
|
107
|
+
await this.walkSoftLinks(parentNode, options, visited, ancestorStack, stats, contextUser);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
stats.ForwardOnlySuppressions++;
|
|
112
|
+
}
|
|
113
|
+
// Forward walk: always performed regardless of discovery mode
|
|
114
|
+
await this.walkForwardReferences(parentNode, options, visited, ancestorStack, stats, contextUser);
|
|
115
|
+
// Subtypes walk: if enabled, discover IS-A rows
|
|
116
|
+
if (options.IncludeSubtypes) {
|
|
117
|
+
await this.walkSubtypes(parentNode, options, visited, ancestorStack, stats, contextUser);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// =========================================================================
|
|
121
|
+
// Reverse Walking (EntityRelationship-driven)
|
|
122
|
+
// =========================================================================
|
|
123
|
+
/**
|
|
124
|
+
* Walk reverse relationships: for each EntityRelationship on the parent
|
|
125
|
+
* entity, load matching child records and recurse into them.
|
|
126
|
+
*/
|
|
127
|
+
async walkReverseRelationships(parentNode, options, visited, ancestorStack, stats, contextUser) {
|
|
128
|
+
const relationships = this.discoverReverseRelationships(parentNode.EntityInfo, options);
|
|
129
|
+
for (const rel of relationships) {
|
|
130
|
+
const isHierarchyRel = UUIDsEqual(rel.ChildEntityInfo.ID, parentNode.EntityInfo.ID);
|
|
131
|
+
if (isHierarchyRel && !options.FollowHierarchies) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Ancestor check: skip if the child entity type is on our current path (unless allowed hierarchy recursion)
|
|
135
|
+
if (ancestorStack.has(rel.ChildEntityInfo.Name) && !(isHierarchyRel && options.FollowHierarchies)) {
|
|
136
|
+
stats.AncestorSkips++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (this.shouldSkipEntity(rel.ChildEntityInfo.Name, options)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const edgeKind = isHierarchyRel ? 'Hierarchy' : (rel.IsCollection ? 'Collection' : 'Relationship');
|
|
143
|
+
// Check edge policy before loading if policy is configured
|
|
144
|
+
const edgeCandidate = {
|
|
145
|
+
Kind: edgeKind,
|
|
146
|
+
SourceEntityName: parentNode.EntityName,
|
|
147
|
+
SourceKey: parentNode.RecordKey,
|
|
148
|
+
SourceRecordData: parentNode.RecordData,
|
|
149
|
+
TargetEntityName: rel.ChildEntityInfo.Name,
|
|
150
|
+
JoinField: rel.ChildJoinField,
|
|
151
|
+
Relationship: rel.RelationshipInfo,
|
|
152
|
+
CollectionName: rel.CollectionName,
|
|
153
|
+
IsSoftLink: false,
|
|
154
|
+
Depth: parentNode.Depth + 1,
|
|
155
|
+
HierarchyDirection: isHierarchyRel ? 'Down' : undefined,
|
|
156
|
+
};
|
|
157
|
+
const edgeDecision = options.EdgePolicy ? options.EdgePolicy(edgeCandidate) : 'Deep';
|
|
158
|
+
if (edgeDecision === 'Skip') {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const childRecords = await this.loadChildRecords(parentNode, rel, options, contextUser, (message) => options.OnLoadFailure?.({ Edge: edgeCandidate, Decision: edgeDecision, Message: message }));
|
|
162
|
+
for (const childData of childRecords) {
|
|
163
|
+
const childKey = BuildCompositeKeyFromRecord(rel.ChildEntityInfo, childData);
|
|
164
|
+
const discoveringEdge = {
|
|
165
|
+
Kind: edgeKind,
|
|
166
|
+
FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
|
|
167
|
+
ToKey: this.visitKey(rel.ChildEntityInfo.Name, childKey.ToConcatenatedString()),
|
|
168
|
+
SourceEntityName: parentNode.EntityName,
|
|
169
|
+
TargetEntityName: rel.ChildEntityInfo.Name,
|
|
170
|
+
JoinField: rel.ChildJoinField,
|
|
171
|
+
Relationship: rel.RelationshipInfo,
|
|
172
|
+
CollectionName: rel.CollectionName,
|
|
173
|
+
IsSoftLink: false,
|
|
174
|
+
};
|
|
175
|
+
// Re-evaluate EdgePolicy per-record if candidate target key is known
|
|
176
|
+
let decision = 'Deep';
|
|
177
|
+
if (options.EdgePolicy) {
|
|
178
|
+
const candidate = {
|
|
179
|
+
Kind: edgeKind,
|
|
180
|
+
SourceEntityName: parentNode.EntityName,
|
|
181
|
+
SourceKey: parentNode.RecordKey,
|
|
182
|
+
SourceRecordData: parentNode.RecordData,
|
|
183
|
+
TargetEntityName: rel.ChildEntityInfo.Name,
|
|
184
|
+
TargetKey: childKey,
|
|
185
|
+
JoinField: rel.ChildJoinField,
|
|
186
|
+
Relationship: rel.RelationshipInfo,
|
|
187
|
+
CollectionName: rel.CollectionName,
|
|
188
|
+
IsSoftLink: false,
|
|
189
|
+
Depth: parentNode.Depth + 1,
|
|
190
|
+
HierarchyDirection: isHierarchyRel ? 'Down' : undefined,
|
|
191
|
+
};
|
|
192
|
+
decision = options.EdgePolicy(candidate);
|
|
193
|
+
if (decision === 'Skip')
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const childNode = this.registerNode(rel.ChildEntityInfo, childData, rel.RelationshipInfo, parentNode, visited, stats, discoveringEdge);
|
|
197
|
+
if (!childNode)
|
|
198
|
+
continue;
|
|
199
|
+
if (decision === 'Deep') {
|
|
200
|
+
// Push child entity type, recurse with FULL mode, then pop
|
|
201
|
+
const alreadyInStack = ancestorStack.has(rel.ChildEntityInfo.Name);
|
|
202
|
+
if (!alreadyInStack) {
|
|
203
|
+
ancestorStack.add(rel.ChildEntityInfo.Name);
|
|
204
|
+
}
|
|
205
|
+
await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
|
|
206
|
+
if (!alreadyInStack) {
|
|
207
|
+
ancestorStack.delete(rel.ChildEntityInfo.Name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// =========================================================================
|
|
214
|
+
// Inbound Non-Curated FK Walking
|
|
215
|
+
// =========================================================================
|
|
216
|
+
/**
|
|
217
|
+
* Discovers inbound foreign keys that are not registered as curated EntityRelationships.
|
|
218
|
+
* Evaluated when ListNonCuratedInbound is true.
|
|
219
|
+
*/
|
|
220
|
+
async walkNonCuratedInbound(parentNode, options, visited, ancestorStack, stats, contextUser) {
|
|
221
|
+
if (!options.EdgePolicy)
|
|
222
|
+
return; // Non-curated inbound edges require an explicit policy
|
|
223
|
+
const md = this.ProviderToUse;
|
|
224
|
+
const parentEntity = parentNode.EntityInfo;
|
|
225
|
+
const parentKeyField = parentEntity.FirstPrimaryKey.Name; // first-pk-ok: foreign keys target single-column primary keys
|
|
226
|
+
const parentKeyValue = parentNode.RecordData[parentKeyField];
|
|
227
|
+
if (parentKeyValue == null)
|
|
228
|
+
return;
|
|
229
|
+
// Curated child entity IDs to skip
|
|
230
|
+
const curatedChildEntityIds = new Set(parentEntity.RelatedEntities.map(r => NormalizeUUID(r.RelatedEntityID)));
|
|
231
|
+
for (const entity of md.Entities) {
|
|
232
|
+
if (options.RequireTrackRecordChanges && !entity.TrackRecordChanges)
|
|
233
|
+
continue;
|
|
234
|
+
if (this.shouldSkipEntity(entity.Name, options))
|
|
235
|
+
continue;
|
|
236
|
+
if (curatedChildEntityIds.has(NormalizeUUID(entity.ID)))
|
|
237
|
+
continue;
|
|
238
|
+
for (const field of entity.Fields) {
|
|
239
|
+
if (!field.RelatedEntityID || !UUIDsEqual(field.RelatedEntityID, parentEntity.ID))
|
|
240
|
+
continue;
|
|
241
|
+
if (this.isSystemFKField(field.Name))
|
|
242
|
+
continue;
|
|
243
|
+
const candidate = {
|
|
244
|
+
Kind: 'InboundFK',
|
|
245
|
+
SourceEntityName: parentNode.EntityName,
|
|
246
|
+
SourceKey: parentNode.RecordKey,
|
|
247
|
+
SourceRecordData: parentNode.RecordData,
|
|
248
|
+
TargetEntityName: entity.Name,
|
|
249
|
+
JoinField: field.Name,
|
|
250
|
+
Relationship: null,
|
|
251
|
+
IsSoftLink: false,
|
|
252
|
+
Depth: parentNode.Depth + 1,
|
|
253
|
+
};
|
|
254
|
+
const decision = options.EdgePolicy(candidate);
|
|
255
|
+
if (decision === 'Skip')
|
|
256
|
+
continue;
|
|
257
|
+
// Load records matching inbound FK
|
|
258
|
+
const filter = `[${field.Name}] = '${EscapeSQLString(String(parentKeyValue))}'` +
|
|
259
|
+
(!options.IncludeDeleted && this.entityHasSoftDelete(entity) ? ' AND __mj_DeletedAt IS NULL' : '');
|
|
260
|
+
try {
|
|
261
|
+
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
262
|
+
const result = await rv.RunView({
|
|
263
|
+
EntityName: entity.Name,
|
|
264
|
+
ExtraFilter: filter,
|
|
265
|
+
ResultType: 'simple',
|
|
266
|
+
}, contextUser);
|
|
267
|
+
if (!result.Success) {
|
|
268
|
+
options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: result.ErrorMessage || 'the query failed' });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const row of result.Results) {
|
|
272
|
+
const rowKey = BuildCompositeKeyFromRecord(entity, row);
|
|
273
|
+
const discoveringEdge = {
|
|
274
|
+
Kind: 'InboundFK',
|
|
275
|
+
FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
|
|
276
|
+
ToKey: this.visitKey(entity.Name, rowKey.ToConcatenatedString()),
|
|
277
|
+
SourceEntityName: parentNode.EntityName,
|
|
278
|
+
TargetEntityName: entity.Name,
|
|
279
|
+
JoinField: field.Name,
|
|
280
|
+
Relationship: null,
|
|
281
|
+
IsSoftLink: false,
|
|
282
|
+
};
|
|
283
|
+
const childNode = this.registerNode(entity, row, null, parentNode, visited, stats, discoveringEdge);
|
|
284
|
+
if (!childNode)
|
|
285
|
+
continue;
|
|
286
|
+
if (decision === 'Deep') {
|
|
287
|
+
ancestorStack.add(entity.Name);
|
|
288
|
+
await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
|
|
289
|
+
ancestorStack.delete(entity.Name);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
295
|
+
LogError(`DependencyGraphWalker: Error loading inbound FK records for ${entity.Name}: ${msg}`);
|
|
296
|
+
options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: msg });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// =========================================================================
|
|
302
|
+
// Soft Link Walking
|
|
303
|
+
// =========================================================================
|
|
304
|
+
/**
|
|
305
|
+
* Discovers records pointing to the parent via polymorphic EntityID/RecordID soft links.
|
|
306
|
+
*/
|
|
307
|
+
async walkSoftLinks(parentNode, options, visited, ancestorStack, stats, contextUser) {
|
|
308
|
+
const md = this.ProviderToUse;
|
|
309
|
+
const parentEntity = parentNode.EntityInfo;
|
|
310
|
+
for (const entity of md.Entities) {
|
|
311
|
+
if (options.RequireTrackRecordChanges && !entity.TrackRecordChanges)
|
|
312
|
+
continue;
|
|
313
|
+
if (this.shouldSkipEntity(entity.Name, options))
|
|
314
|
+
continue;
|
|
315
|
+
// Look for fields declared with EntityIDFieldName
|
|
316
|
+
for (const field of entity.Fields) {
|
|
317
|
+
if (!field.EntityIDFieldName)
|
|
318
|
+
continue;
|
|
319
|
+
const candidate = {
|
|
320
|
+
Kind: 'SoftLink',
|
|
321
|
+
SourceEntityName: parentNode.EntityName,
|
|
322
|
+
SourceKey: parentNode.RecordKey,
|
|
323
|
+
SourceRecordData: parentNode.RecordData,
|
|
324
|
+
TargetEntityName: entity.Name,
|
|
325
|
+
JoinField: field.Name,
|
|
326
|
+
Relationship: null,
|
|
327
|
+
IsSoftLink: true,
|
|
328
|
+
EntityIDFieldName: field.EntityIDFieldName,
|
|
329
|
+
Depth: parentNode.Depth + 1,
|
|
330
|
+
};
|
|
331
|
+
const decision = options.EdgePolicy ? options.EdgePolicy(candidate) : 'Skip';
|
|
332
|
+
if (decision === 'Skip')
|
|
333
|
+
continue;
|
|
334
|
+
// Build query: EntityID discriminator match and RecordID match
|
|
335
|
+
// Soft links store either the full record-id ("ID|abc") or, commonly for a single
|
|
336
|
+
// key column, the bare value ("abc"); match both.
|
|
337
|
+
const recordIDValue = parentNode.RecordKey.ToRecordID();
|
|
338
|
+
const bareIDValue = parentNode.RecordKey.ToCompactURLSegment();
|
|
339
|
+
const filter = `([${field.EntityIDFieldName}] = '${EscapeSQLString(parentEntity.ID)}' OR [${field.EntityIDFieldName}] = '${EscapeSQLString(parentEntity.Name)}') ` +
|
|
340
|
+
`AND ([${field.Name}] = '${EscapeSQLString(recordIDValue)}' OR [${field.Name}] = '${EscapeSQLString(bareIDValue)}')` +
|
|
341
|
+
(!options.IncludeDeleted && this.entityHasSoftDelete(entity) ? ' AND __mj_DeletedAt IS NULL' : '');
|
|
342
|
+
try {
|
|
343
|
+
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
344
|
+
const result = await rv.RunView({
|
|
345
|
+
EntityName: entity.Name,
|
|
346
|
+
ExtraFilter: filter,
|
|
347
|
+
ResultType: 'simple',
|
|
348
|
+
}, contextUser);
|
|
349
|
+
if (!result.Success) {
|
|
350
|
+
options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: result.ErrorMessage || 'the query failed' });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
for (const row of result.Results) {
|
|
354
|
+
const rowKey = BuildCompositeKeyFromRecord(entity, row);
|
|
355
|
+
const discoveringEdge = {
|
|
356
|
+
Kind: 'SoftLink',
|
|
357
|
+
FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
|
|
358
|
+
ToKey: this.visitKey(entity.Name, rowKey.ToConcatenatedString()),
|
|
359
|
+
SourceEntityName: parentNode.EntityName,
|
|
360
|
+
TargetEntityName: entity.Name,
|
|
361
|
+
JoinField: field.Name,
|
|
362
|
+
Relationship: null,
|
|
363
|
+
IsSoftLink: true,
|
|
364
|
+
EntityIDFieldName: field.EntityIDFieldName,
|
|
365
|
+
};
|
|
366
|
+
const childNode = this.registerNode(entity, row, null, parentNode, visited, stats, discoveringEdge);
|
|
367
|
+
if (!childNode)
|
|
368
|
+
continue;
|
|
369
|
+
if (decision === 'Deep') {
|
|
370
|
+
ancestorStack.add(entity.Name);
|
|
371
|
+
await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
|
|
372
|
+
ancestorStack.delete(entity.Name);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
378
|
+
LogError(`DependencyGraphWalker: Error loading soft link records for ${entity.Name}: ${msg}`);
|
|
379
|
+
options.OnLoadFailure?.({ Edge: candidate, Decision: decision, Message: msg });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// =========================================================================
|
|
385
|
+
// Subtypes Walking (IS-A)
|
|
386
|
+
// =========================================================================
|
|
387
|
+
/**
|
|
388
|
+
* Discover and attach IS-A subtype rows for a given parent record.
|
|
389
|
+
*/
|
|
390
|
+
async walkSubtypes(parentNode, options, visited, ancestorStack, stats, contextUser) {
|
|
391
|
+
const md = this.ProviderToUse;
|
|
392
|
+
if (!md.FindISAChildEntities)
|
|
393
|
+
return;
|
|
394
|
+
try {
|
|
395
|
+
// IS-A tables share one single-column key, and the provider matches on its bare value
|
|
396
|
+
// (not the "ID|<guid>" record-id string, which errors on a UUID column).
|
|
397
|
+
const pkValue = parentNode.RecordKey.KeyValuePairs[0]?.Value;
|
|
398
|
+
if (pkValue === null || pkValue === undefined)
|
|
399
|
+
return;
|
|
400
|
+
const subtypes = await md.FindISAChildEntities(parentNode.EntityInfo, String(pkValue), contextUser);
|
|
401
|
+
for (const subtype of subtypes) {
|
|
402
|
+
const childEntityInfo = md.EntityByName(subtype.ChildEntityName);
|
|
403
|
+
if (!childEntityInfo)
|
|
404
|
+
continue;
|
|
405
|
+
if (options.RequireTrackRecordChanges && !childEntityInfo.TrackRecordChanges)
|
|
406
|
+
continue;
|
|
407
|
+
if (this.shouldSkipEntity(childEntityInfo.Name, options))
|
|
408
|
+
continue;
|
|
409
|
+
const candidate = {
|
|
410
|
+
Kind: 'IsASubtype',
|
|
411
|
+
SourceEntityName: parentNode.EntityName,
|
|
412
|
+
SourceKey: parentNode.RecordKey,
|
|
413
|
+
SourceRecordData: parentNode.RecordData,
|
|
414
|
+
TargetEntityName: childEntityInfo.Name,
|
|
415
|
+
TargetKey: parentNode.RecordKey,
|
|
416
|
+
JoinField: childEntityInfo.FirstPrimaryKey.Name, // first-pk-ok: IS-A subtype shares the parent's single-column key
|
|
417
|
+
Relationship: null,
|
|
418
|
+
IsSoftLink: false,
|
|
419
|
+
Depth: parentNode.Depth + 1,
|
|
420
|
+
};
|
|
421
|
+
const decision = options.EdgePolicy ? options.EdgePolicy(candidate) : 'Deep';
|
|
422
|
+
if (decision === 'Skip')
|
|
423
|
+
continue;
|
|
424
|
+
// Load subtype record using the same primary key value
|
|
425
|
+
const childData = await this.loadRecordData(childEntityInfo, parentNode.RecordKey, contextUser);
|
|
426
|
+
if (!childData || Object.keys(childData).length === 0)
|
|
427
|
+
continue;
|
|
428
|
+
const discoveringEdge = {
|
|
429
|
+
Kind: 'IsASubtype',
|
|
430
|
+
FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
|
|
431
|
+
ToKey: this.visitKey(childEntityInfo.Name, parentNode.RecordID),
|
|
432
|
+
SourceEntityName: parentNode.EntityName,
|
|
433
|
+
TargetEntityName: childEntityInfo.Name,
|
|
434
|
+
JoinField: childEntityInfo.FirstPrimaryKey.Name, // first-pk-ok: IS-A subtype shares the parent's single-column key
|
|
435
|
+
Relationship: null,
|
|
436
|
+
IsSoftLink: false,
|
|
437
|
+
};
|
|
438
|
+
const childNode = this.registerNode(childEntityInfo, childData, null, parentNode, visited, stats, discoveringEdge, true);
|
|
439
|
+
if (!childNode)
|
|
440
|
+
continue;
|
|
441
|
+
if (decision === 'Deep') {
|
|
442
|
+
ancestorStack.add(childEntityInfo.Name);
|
|
443
|
+
await this.walkChildren(childNode, 'full', options, visited, ancestorStack, stats, contextUser);
|
|
444
|
+
ancestorStack.delete(childEntityInfo.Name);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
450
|
+
LogError(`DependencyGraphWalker: Error finding IS-A child entities for ${parentNode.EntityName}: ${msg}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// =========================================================================
|
|
454
|
+
// Forward Walking (FK field-driven)
|
|
455
|
+
// =========================================================================
|
|
456
|
+
/**
|
|
457
|
+
* Walk forward FK references: for each FK field on the parent entity that
|
|
458
|
+
* points to another entity, load the referenced record and recurse.
|
|
459
|
+
*/
|
|
460
|
+
async walkForwardReferences(parentNode, options, visited, ancestorStack, stats, contextUser) {
|
|
461
|
+
const forwardRefs = this.discoverForwardReferences(parentNode.EntityInfo, options);
|
|
462
|
+
for (const ref of forwardRefs) {
|
|
463
|
+
// Self-pointer check
|
|
464
|
+
if (ref.IsSelf) {
|
|
465
|
+
if (ref.IsHierarchy && options.FollowHierarchies) {
|
|
466
|
+
// Follow hierarchy edge
|
|
467
|
+
const fkValue = parentNode.RecordData[ref.FKFieldName];
|
|
468
|
+
if (fkValue == null)
|
|
469
|
+
continue;
|
|
470
|
+
const targetKey = new CompositeKey([{
|
|
471
|
+
FieldName: ref.TargetKeyField,
|
|
472
|
+
Value: fkValue,
|
|
473
|
+
}]);
|
|
474
|
+
const candidate = {
|
|
475
|
+
Kind: 'Hierarchy',
|
|
476
|
+
SourceEntityName: parentNode.EntityName,
|
|
477
|
+
SourceKey: parentNode.RecordKey,
|
|
478
|
+
SourceRecordData: parentNode.RecordData,
|
|
479
|
+
TargetEntityName: ref.TargetEntityInfo.Name,
|
|
480
|
+
TargetKey: targetKey,
|
|
481
|
+
JoinField: ref.FKFieldName,
|
|
482
|
+
Relationship: null,
|
|
483
|
+
IsSoftLink: false,
|
|
484
|
+
Depth: parentNode.Depth + 1,
|
|
485
|
+
HierarchyDirection: 'Up',
|
|
486
|
+
};
|
|
487
|
+
const decision = options.EdgePolicy ? options.EdgePolicy(candidate) : 'Deep';
|
|
488
|
+
if (decision === 'Skip')
|
|
489
|
+
continue;
|
|
490
|
+
const vKey = this.visitKey(ref.TargetEntityInfo.Name, targetKey.ToConcatenatedString());
|
|
491
|
+
if (visited.has(vKey)) {
|
|
492
|
+
stats.VisitedSkips++;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const targetData = await this.loadRecordData(ref.TargetEntityInfo, targetKey, contextUser);
|
|
496
|
+
if (!targetData || Object.keys(targetData).length === 0)
|
|
497
|
+
continue;
|
|
498
|
+
const discoveringEdge = {
|
|
499
|
+
Kind: 'Hierarchy',
|
|
500
|
+
FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
|
|
501
|
+
ToKey: vKey,
|
|
502
|
+
SourceEntityName: parentNode.EntityName,
|
|
503
|
+
TargetEntityName: ref.TargetEntityInfo.Name,
|
|
504
|
+
JoinField: ref.FKFieldName,
|
|
505
|
+
Relationship: null,
|
|
506
|
+
IsSoftLink: false,
|
|
507
|
+
};
|
|
508
|
+
const targetNode = this.registerNode(ref.TargetEntityInfo, targetData, null, parentNode, visited, stats, discoveringEdge);
|
|
509
|
+
if (!targetNode)
|
|
510
|
+
continue;
|
|
511
|
+
if (decision === 'Deep') {
|
|
512
|
+
await this.walkChildren(targetNode, 'full', options, visited, ancestorStack, stats, contextUser);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Plain self pointers are never followed automatically
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
// Ancestor check: skip if target entity type is on our current path
|
|
519
|
+
if (ancestorStack.has(ref.TargetEntityInfo.Name)) {
|
|
520
|
+
stats.AncestorSkips++;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (this.shouldSkipEntity(ref.TargetEntityInfo.Name, options)) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
// Read the FK value from the record data
|
|
527
|
+
const fkValue = parentNode.RecordData[ref.FKFieldName];
|
|
528
|
+
if (fkValue == null)
|
|
529
|
+
continue;
|
|
530
|
+
const targetKey = new CompositeKey([{
|
|
531
|
+
FieldName: ref.TargetKeyField,
|
|
532
|
+
Value: fkValue,
|
|
533
|
+
}]);
|
|
534
|
+
const edgeKind = ref.IsEmbedded ? 'Embedded' : 'ForwardFK';
|
|
535
|
+
// Check edge policy before loading
|
|
536
|
+
let decision = ref.IsEmbedded ? 'Deep' : 'Reference';
|
|
537
|
+
if (options.EdgePolicy) {
|
|
538
|
+
const candidate = {
|
|
539
|
+
Kind: edgeKind,
|
|
540
|
+
SourceEntityName: parentNode.EntityName,
|
|
541
|
+
SourceKey: parentNode.RecordKey,
|
|
542
|
+
SourceRecordData: parentNode.RecordData,
|
|
543
|
+
TargetEntityName: ref.TargetEntityInfo.Name,
|
|
544
|
+
TargetKey: targetKey,
|
|
545
|
+
JoinField: ref.FKFieldName,
|
|
546
|
+
Relationship: null,
|
|
547
|
+
IsSoftLink: false,
|
|
548
|
+
Depth: parentNode.Depth + 1,
|
|
549
|
+
};
|
|
550
|
+
decision = options.EdgePolicy(candidate);
|
|
551
|
+
if (decision === 'Skip')
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
// Skip if we've already visited this specific record
|
|
555
|
+
const vKey = this.visitKey(ref.TargetEntityInfo.Name, targetKey.ToConcatenatedString());
|
|
556
|
+
if (visited.has(vKey)) {
|
|
557
|
+
stats.VisitedSkips++;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const targetData = await this.loadRecordData(ref.TargetEntityInfo, targetKey, contextUser);
|
|
561
|
+
if (!targetData || Object.keys(targetData).length === 0)
|
|
562
|
+
continue;
|
|
563
|
+
const discoveringEdge = {
|
|
564
|
+
Kind: edgeKind,
|
|
565
|
+
FromKey: this.visitKey(parentNode.EntityName, parentNode.RecordID),
|
|
566
|
+
ToKey: vKey,
|
|
567
|
+
SourceEntityName: parentNode.EntityName,
|
|
568
|
+
TargetEntityName: ref.TargetEntityInfo.Name,
|
|
569
|
+
JoinField: ref.FKFieldName,
|
|
570
|
+
Relationship: null,
|
|
571
|
+
IsSoftLink: false,
|
|
572
|
+
};
|
|
573
|
+
const targetNode = this.registerNode(ref.TargetEntityInfo, targetData, null, parentNode, visited, stats, discoveringEdge);
|
|
574
|
+
if (!targetNode)
|
|
575
|
+
continue;
|
|
576
|
+
// Recurse based on policy decision:
|
|
577
|
+
// 'Deep' recurses with full mode; 'Reference' recurses with forward-only mode
|
|
578
|
+
const nextMode = decision === 'Deep' ? 'full' : 'forward-only';
|
|
579
|
+
ancestorStack.add(ref.TargetEntityInfo.Name);
|
|
580
|
+
await this.walkChildren(targetNode, nextMode, options, visited, ancestorStack, stats, contextUser);
|
|
581
|
+
ancestorStack.delete(ref.TargetEntityInfo.Name);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// =========================================================================
|
|
585
|
+
// Relationship Discovery
|
|
586
|
+
// =========================================================================
|
|
587
|
+
/**
|
|
588
|
+
* Discover reverse relationships for an entity using EntityRelationship metadata.
|
|
589
|
+
*/
|
|
590
|
+
discoverReverseRelationships(parentEntity, options) {
|
|
591
|
+
const cached = this.reverseRelCache.get(parentEntity.ID);
|
|
592
|
+
if (cached)
|
|
593
|
+
return cached;
|
|
594
|
+
const md = this.ProviderToUse;
|
|
595
|
+
const relationships = [];
|
|
596
|
+
for (const rel of parentEntity.RelatedEntities) {
|
|
597
|
+
// Only walk One-To-Many (parent has many children)
|
|
598
|
+
if (rel.Type.trim() !== 'One To Many')
|
|
599
|
+
continue;
|
|
600
|
+
const childEntity = md.EntityByID(rel.RelatedEntityID);
|
|
601
|
+
if (!childEntity)
|
|
602
|
+
continue;
|
|
603
|
+
if (options.RequireTrackRecordChanges && !childEntity.TrackRecordChanges)
|
|
604
|
+
continue;
|
|
605
|
+
const parentKeyField = this.resolveParentKeyFieldFromRelationship(rel, parentEntity);
|
|
606
|
+
let collectionName;
|
|
607
|
+
let isCollection = false;
|
|
608
|
+
if (rel.RelatedRecordCollection) {
|
|
609
|
+
isCollection = true;
|
|
610
|
+
try {
|
|
611
|
+
const parsed = typeof rel.RelatedRecordCollection === 'string'
|
|
612
|
+
? JSON.parse(rel.RelatedRecordCollection)
|
|
613
|
+
: rel.RelatedRecordCollection;
|
|
614
|
+
if (parsed && typeof parsed['Name'] === 'string') {
|
|
615
|
+
collectionName = parsed['Name'];
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// Ignore parse error; keep isCollection = true
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
relationships.push({
|
|
623
|
+
ChildEntityInfo: childEntity,
|
|
624
|
+
ChildJoinField: rel.RelatedEntityJoinField,
|
|
625
|
+
ParentKeyField: parentKeyField,
|
|
626
|
+
RelationshipInfo: rel,
|
|
627
|
+
CollectionName: collectionName,
|
|
628
|
+
IsCollection: isCollection,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
this.reverseRelCache.set(parentEntity.ID, relationships);
|
|
632
|
+
return relationships;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Discover forward FK references on an entity.
|
|
636
|
+
*/
|
|
637
|
+
discoverForwardReferences(entity, options) {
|
|
638
|
+
const cached = this.forwardRefCache.get(entity.ID);
|
|
639
|
+
if (cached)
|
|
640
|
+
return cached;
|
|
641
|
+
const md = this.ProviderToUse;
|
|
642
|
+
const refs = [];
|
|
643
|
+
for (const field of entity.Fields) {
|
|
644
|
+
if (!field.RelatedEntityID)
|
|
645
|
+
continue;
|
|
646
|
+
if (this.isSystemFKField(field.Name))
|
|
647
|
+
continue;
|
|
648
|
+
const isSelf = UUIDsEqual(field.RelatedEntityID, entity.ID);
|
|
649
|
+
const targetEntity = isSelf ? entity : md.EntityByID(field.RelatedEntityID);
|
|
650
|
+
if (!targetEntity)
|
|
651
|
+
continue;
|
|
652
|
+
if (options.RequireTrackRecordChanges && !targetEntity.TrackRecordChanges)
|
|
653
|
+
continue;
|
|
654
|
+
const isHierarchy = field.IsHierarchy;
|
|
655
|
+
refs.push({
|
|
656
|
+
TargetEntityInfo: targetEntity,
|
|
657
|
+
FKFieldName: field.Name,
|
|
658
|
+
TargetKeyField: this.resolveParentKeyFieldFromFK(field, targetEntity),
|
|
659
|
+
IsSelf: isSelf,
|
|
660
|
+
IsHierarchy: isHierarchy,
|
|
661
|
+
IsEmbedded: false, // Embedded records are declared in subclasses or metadata
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
this.forwardRefCache.set(entity.ID, refs);
|
|
665
|
+
return refs;
|
|
666
|
+
}
|
|
667
|
+
// =========================================================================
|
|
668
|
+
// Data Loading
|
|
669
|
+
// =========================================================================
|
|
670
|
+
/**
|
|
671
|
+
* Load child records for a reverse relationship.
|
|
672
|
+
*/
|
|
673
|
+
async loadChildRecords(parentNode, rel, options, contextUser, onFailure) {
|
|
674
|
+
const parentKeyValue = parentNode.RecordData[rel.ParentKeyField] ?? null;
|
|
675
|
+
if (parentKeyValue == null)
|
|
676
|
+
return [];
|
|
677
|
+
let extraFilter = `[${rel.ChildJoinField}] = '${EscapeSQLString(String(parentKeyValue))}'`;
|
|
678
|
+
if (!options.IncludeDeleted && this.entityHasSoftDelete(rel.ChildEntityInfo)) {
|
|
679
|
+
extraFilter += ` AND __mj_DeletedAt IS NULL`;
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
683
|
+
const result = await rv.RunView({
|
|
684
|
+
EntityName: rel.ChildEntityInfo.Name,
|
|
685
|
+
ExtraFilter: extraFilter,
|
|
686
|
+
ResultType: 'simple',
|
|
687
|
+
}, contextUser);
|
|
688
|
+
if (!result.Success) {
|
|
689
|
+
LogError(`DependencyGraphWalker: Failed to load children of ` +
|
|
690
|
+
`${parentNode.EntityName} via ${rel.ChildEntityInfo.Name}.${rel.ChildJoinField}: ` +
|
|
691
|
+
`${result.ErrorMessage}`);
|
|
692
|
+
onFailure(result.ErrorMessage || 'the query failed');
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
return result.Results;
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
699
|
+
LogError(`DependencyGraphWalker: Error loading child records: ${msg}`);
|
|
700
|
+
onFailure(msg);
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Load a single record's data by primary key.
|
|
706
|
+
*/
|
|
707
|
+
async loadRecordData(entityInfo, key, contextUser) {
|
|
708
|
+
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
709
|
+
const result = await rv.RunView({
|
|
710
|
+
EntityName: entityInfo.Name,
|
|
711
|
+
ExtraFilter: key.ToWhereClause(),
|
|
712
|
+
ResultType: 'simple',
|
|
713
|
+
}, contextUser);
|
|
714
|
+
if (!result.Success || result.Results.length === 0) {
|
|
715
|
+
LogError(`DependencyGraphWalker: loadRecordData returned empty for entity ` +
|
|
716
|
+
`'${entityInfo.Name}' with key ${key.ToConcatenatedString()}`);
|
|
717
|
+
return {};
|
|
718
|
+
}
|
|
719
|
+
return result.Results[0];
|
|
720
|
+
}
|
|
721
|
+
// =========================================================================
|
|
722
|
+
// Node Registration
|
|
723
|
+
// =========================================================================
|
|
724
|
+
/**
|
|
725
|
+
* Build a DependencyNode from record data, register it in the visited set,
|
|
726
|
+
* and attach it as a child of the parent. Returns null if already visited.
|
|
727
|
+
*/
|
|
728
|
+
registerNode(entityInfo, recordData, relationship, parentNode, visited, stats, discoveringEdge, isSubtypeRow) {
|
|
729
|
+
const key = BuildCompositeKeyFromRecord(entityInfo, recordData);
|
|
730
|
+
const vKey = this.visitKey(entityInfo.Name, key.ToConcatenatedString());
|
|
731
|
+
if (visited.has(vKey)) {
|
|
732
|
+
stats.VisitedSkips++;
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
visited.add(vKey);
|
|
736
|
+
const node = {
|
|
737
|
+
EntityName: entityInfo.Name,
|
|
738
|
+
EntityInfo: entityInfo,
|
|
739
|
+
RecordKey: key,
|
|
740
|
+
RecordID: key.ToConcatenatedString(),
|
|
741
|
+
RecordData: recordData,
|
|
742
|
+
Relationship: relationship,
|
|
743
|
+
Children: [],
|
|
744
|
+
Depth: parentNode.Depth + 1,
|
|
745
|
+
DiscoveringEdge: discoveringEdge ?? null,
|
|
746
|
+
IsSubtypeRow: isSubtypeRow ?? false,
|
|
747
|
+
};
|
|
748
|
+
parentNode.Children.push(node);
|
|
749
|
+
this.incrementEntityCount(stats, entityInfo.Name);
|
|
750
|
+
return node;
|
|
751
|
+
}
|
|
752
|
+
// =========================================================================
|
|
753
|
+
// Logging
|
|
754
|
+
// =========================================================================
|
|
755
|
+
/** Create an empty stats accumulator. */
|
|
756
|
+
createEmptyStats() {
|
|
757
|
+
return {
|
|
758
|
+
EntityCounts: new Map(),
|
|
759
|
+
TotalRecords: 0,
|
|
760
|
+
AncestorSkips: 0,
|
|
761
|
+
VisitedSkips: 0,
|
|
762
|
+
ForwardOnlySuppressions: 0,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/** Increment the per-entity record counter. */
|
|
766
|
+
incrementEntityCount(stats, entityName) {
|
|
767
|
+
stats.TotalRecords++;
|
|
768
|
+
stats.EntityCounts.set(entityName, (stats.EntityCounts.get(entityName) ?? 0) + 1);
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Log a detailed summary of the walk.
|
|
772
|
+
*/
|
|
773
|
+
logWalkSummary(rootEntityName, options, stats) {
|
|
774
|
+
const entityBreakdown = Array.from(stats.EntityCounts.entries())
|
|
775
|
+
.sort((a, b) => b[1] - a[1])
|
|
776
|
+
.map(([name, count]) => ` ${name}: ${count}`)
|
|
777
|
+
.join('\n');
|
|
778
|
+
LogStatus(`DependencyGraphWalker: Walk complete for '${rootEntityName}'\n` +
|
|
779
|
+
` Total records: ${stats.TotalRecords}\n` +
|
|
780
|
+
` Unique entities: ${stats.EntityCounts.size}\n` +
|
|
781
|
+
` Ancestor skips: ${stats.AncestorSkips}\n` +
|
|
782
|
+
` Visited skips: ${stats.VisitedSkips}\n` +
|
|
783
|
+
` Forward-only suppressions: ${stats.ForwardOnlySuppressions}\n` +
|
|
784
|
+
` Max depth: ${options.MaxDepth}\n` +
|
|
785
|
+
` Per-entity breakdown:\n${entityBreakdown}`);
|
|
786
|
+
}
|
|
787
|
+
// =========================================================================
|
|
788
|
+
// Helpers
|
|
789
|
+
// =========================================================================
|
|
790
|
+
/** Build a unique key for the visited set: "EntityName::recordID" */
|
|
791
|
+
visitKey(entityName, recordId) {
|
|
792
|
+
return `${entityName}::${recordId}`;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Determine the parent key field from an EntityRelationship record.
|
|
796
|
+
*/
|
|
797
|
+
resolveParentKeyFieldFromRelationship(rel, parentEntity) {
|
|
798
|
+
if (rel.EntityKeyField && rel.EntityKeyField.trim().length > 0) {
|
|
799
|
+
return rel.EntityKeyField;
|
|
800
|
+
}
|
|
801
|
+
return parentEntity.FirstPrimaryKey.Name; // first-pk-ok: FK target — relationship without explicit EntityKeyField joins on parent single key column
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Determine the target key field from a FK field definition.
|
|
805
|
+
*/
|
|
806
|
+
resolveParentKeyFieldFromFK(fkField, targetEntity) {
|
|
807
|
+
if (fkField.RelatedEntityFieldName && fkField.RelatedEntityFieldName.trim().length > 0) {
|
|
808
|
+
return fkField.RelatedEntityFieldName;
|
|
809
|
+
}
|
|
810
|
+
return targetEntity.FirstPrimaryKey.Name; // first-pk-ok: FK target — column without RelatedEntityFieldName references target single key column
|
|
811
|
+
}
|
|
812
|
+
/** Check if a field name matches a system/infrastructure FK pattern. */
|
|
813
|
+
isSystemFKField(fieldName) {
|
|
814
|
+
return SYSTEM_FK_SKIP_PATTERNS.some(pattern => pattern.test(fieldName));
|
|
815
|
+
}
|
|
816
|
+
/** Check if an entity should be skipped based on filter options. */
|
|
817
|
+
shouldSkipEntity(entityName, options) {
|
|
818
|
+
if (options.ExcludeEntities.length > 0 && options.ExcludeEntities.includes(entityName)) {
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
if (options.EntityFilter.length > 0 && !options.EntityFilter.includes(entityName)) {
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
/** Check if an entity has a soft delete field. */
|
|
827
|
+
entityHasSoftDelete(entityInfo) {
|
|
828
|
+
return entityInfo.Fields.some(f => f.Name === '__mj_DeletedAt');
|
|
829
|
+
}
|
|
830
|
+
/** Apply defaults to walk options. */
|
|
831
|
+
resolveDefaults(options) {
|
|
832
|
+
return {
|
|
833
|
+
MaxDepth: options.MaxDepth ?? 10,
|
|
834
|
+
EntityFilter: options.EntityFilter ?? [],
|
|
835
|
+
ExcludeEntities: options.ExcludeEntities ?? [],
|
|
836
|
+
IncludeDeleted: options.IncludeDeleted ?? false,
|
|
837
|
+
RequireTrackRecordChanges: options.RequireTrackRecordChanges ?? true,
|
|
838
|
+
IncludeSoftLinks: options.IncludeSoftLinks ?? false,
|
|
839
|
+
IncludeSubtypes: options.IncludeSubtypes ?? false,
|
|
840
|
+
FollowHierarchies: options.FollowHierarchies ?? false,
|
|
841
|
+
ListNonCuratedInbound: options.ListNonCuratedInbound ?? false,
|
|
842
|
+
EdgePolicy: options.EdgePolicy,
|
|
843
|
+
OnLoadFailure: options.OnLoadFailure,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
//# sourceMappingURL=DependencyGraphWalker.js.map
|