d365fo-mcp 1.17.3 → 1.18.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/.github/copilot-instructions.md +1 -0
- package/bridge/D365MetadataBridge/Models/Models.cs +13 -0
- package/bridge/D365MetadataBridge/Services/CrossReferenceService.cs +331 -69
- package/bridge/D365MetadataBridge/Services/MetadataWriteService.cs +13 -11
- package/dist/bridge/bridgeAdapter.d.ts +25 -0
- package/dist/bridge/bridgeAdapter.js +112 -19
- package/dist/bridge/bridgeTypes.d.ts +21 -0
- package/dist/cli/commands/doctor.js +10 -8
- package/dist/cli/ui.d.ts +9 -2
- package/dist/cli/ui.js +8 -1
- package/dist/config/settings.js +18 -0
- package/dist/index.js +21 -1
- package/dist/metadata/buildIndexWorker.d.ts +1 -1
- package/dist/metadata/buildIndexWorker.js +1 -1
- package/dist/metadata/modelDescriptor.d.ts +139 -2
- package/dist/metadata/modelDescriptor.js +249 -3
- package/dist/metadata/startupIndexWorker.d.ts +11 -1
- package/dist/metadata/startupIndexWorker.js +10 -2
- package/dist/metadata/startupIndexing.d.ts +3 -0
- package/dist/metadata/startupIndexing.js +7 -1
- package/dist/metadata/symbolIndex.d.ts +120 -74
- package/dist/metadata/symbolIndex.js +179 -113
- package/dist/scripts/build-database.js +138 -120
- package/dist/scripts/build-fts.js +138 -120
- package/dist/scripts/extract-metadata.js +44 -10
- package/dist/scripts/startupIndexWorker.js +96 -110
- package/dist/server/toolSchemas/d365foFile.js +2 -1
- package/dist/server/toolSchemas/findReferences.js +1 -1
- package/dist/tools/analysis/findReferences.js +167 -27
- package/dist/tools/analysis/validateObjectNaming.js +15 -4
- package/dist/tools/d365foFile.js +27 -8
- package/dist/tools/prepare/prepareCreate.js +8 -5
- package/dist/tools/readers/classInfo.js +58 -6
- package/dist/tools/readers/getMethodSource.d.ts +12 -1
- package/dist/tools/readers/getMethodSource.js +75 -20
- package/dist/tools/readers/getWorkspaceInfo.js +53 -6
- package/dist/tools/sdlc/buildProject.js +90 -0
- package/dist/tools/sdlc/compilerMetadataPrune.d.ts +50 -0
- package/dist/tools/sdlc/compilerMetadataPrune.js +129 -0
- package/dist/tools/sdlc/updateSymbolIndex.d.ts +1 -0
- package/dist/tools/sdlc/updateSymbolIndex.js +26 -0
- package/dist/tools/smart/codeGen.js +47 -50
- package/dist/tools/specs/d365foFileOpSpecs.js +37 -0
- package/dist/tools/toolHandler.js +5 -4
- package/dist/tools/write/directXmlWriters.d.ts +31 -0
- package/dist/tools/write/directXmlWriters.js +129 -0
- package/dist/tools/write/formDataSourceNote.d.ts +22 -0
- package/dist/tools/write/formDataSourceNote.js +64 -0
- package/dist/tools/write/inlineIndexUpsert.js +6 -0
- package/dist/tools/write/inlineWriteVerification.d.ts +1 -1
- package/dist/tools/write/inlineWriteVerification.js +10 -1
- package/dist/tools/write/modifyD365File.d.ts +11 -0
- package/dist/tools/write/modifyD365File.js +150 -15
- package/dist/tools/write/preserveMetadataElements.js +1 -0
- package/dist/utils/configManager.js +7 -1
- package/dist/utils/indexedMethodSource.d.ts +76 -0
- package/dist/utils/indexedMethodSource.js +108 -0
- package/dist/utils/methodBodyHint.d.ts +12 -0
- package/dist/utils/methodBodyHint.js +12 -0
- package/dist/utils/modelClassifier.d.ts +26 -0
- package/dist/utils/modelClassifier.js +52 -3
- package/dist/utils/objectFileLookup.d.ts +41 -0
- package/dist/utils/objectFileLookup.js +84 -0
- package/dist/utils/objectNaming.js +12 -5
- package/dist/utils/objectNamingRules.d.ts +2 -0
- package/dist/utils/objectNamingRules.js +73 -5
- package/dist/utils/packagesRoot.d.ts +41 -1
- package/dist/utils/packagesRoot.js +152 -16
- package/dist/utils/pathContainment.d.ts +7 -1
- package/dist/utils/pathContainment.js +39 -1
- package/dist/utils/startupProgress.d.ts +57 -0
- package/dist/utils/startupProgress.js +102 -0
- package/dist/utils/toolProgressMessage.js +4 -0
- package/dist/utils/xmlScan.js +7 -1
- package/dist/workspace/projectMembership.d.ts +15 -0
- package/dist/workspace/projectMembership.js +17 -0
- package/package.json +1 -1
|
@@ -74,6 +74,7 @@ PowerShell / any terminal command **WILL HANG** in VS 2022 / VS 2026 MCP integra
|
|
|
74
74
|
- `model-name` → class `{Target}_{ModelName}_Extension`, element `{Target}.{ModelName}`
|
|
75
75
|
|
|
76
76
|
Pass the BASE object name to `d365fo_file(action="create")` and let the tool inject the token — don't hand-build the infix.
|
|
77
|
+
These patterns describe how the tool names extensions, and the tool applies them itself — editing them here does not change the names it writes. Change the style with `d365fo-mcp config naming`.
|
|
77
78
|
|
|
78
79
|
### Reuse & diff safety
|
|
79
80
|
|
|
@@ -696,6 +696,19 @@ namespace D365MetadataBridge.Models
|
|
|
696
696
|
[JsonPropertyName("module")]
|
|
697
697
|
public string? Module { get; set; }
|
|
698
698
|
|
|
699
|
+
/// <summary>
|
|
700
|
+
/// The AOT path this class actually extends, read from its [ExtensionOf] declaration —
|
|
701
|
+
/// e.g. "/Tables/SalesTable", "/Forms/SalesTable/DataSources/SalesLine",
|
|
702
|
+
/// "/Forms/VendOpenTrans/DataSources/TaxWithholdTrans/DataFields/TaxReimbursement_IT".
|
|
703
|
+
///
|
|
704
|
+
/// Without this the caller cannot tell a table CoC from a form CoC when both objects
|
|
705
|
+
/// share a name: asking about "SalesTable" legitimately matches 14 extensions of the
|
|
706
|
+
/// TABLE and 34 of the FORM and the elements below it, which must never be pooled into
|
|
707
|
+
/// one count.
|
|
708
|
+
/// </summary>
|
|
709
|
+
[JsonPropertyName("extendedElement")]
|
|
710
|
+
public string ExtendedElement { get; set; } = "";
|
|
711
|
+
|
|
699
712
|
/// <summary>Methods that the extension class wraps via CoC (next calls)</summary>
|
|
700
713
|
[JsonPropertyName("wrappedMethods")]
|
|
701
714
|
public List<string> WrappedMethods { get; set; } = new List<string>();
|
|
@@ -73,26 +73,79 @@ namespace D365MetadataBridge.Services
|
|
|
73
73
|
return (objectType, objectName, segment, segmentName);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// DYNAMICSXREFDB [References].Kind values.
|
|
77
|
+
//
|
|
78
|
+
// These were DECODED EMPIRICALLY (2026-09-09) by sampling source/target path pairs per
|
|
79
|
+
// value across three live xref databases spanning two platform versions (10.0.2645.99,
|
|
80
|
+
// 10.0.2645.111, 10.0.2428.205). The mapping was identical in all three.
|
|
81
|
+
//
|
|
82
|
+
// The previous comment here claimed "1=Read/Reference, 2=DerivedFrom/Extends". Both
|
|
83
|
+
// halves were wrong, and the second one was costly: Kind 2 is the GENERIC type
|
|
84
|
+
// reference — 18.3M of the 28M rows — so every `EcoResDescription desc;` declaration and
|
|
85
|
+
// every `NoYes::Yes` read was being reported as "extends". A where-used on /Enums/NoYes
|
|
86
|
+
// came back 500-for-500 "extends", which nothing can extend. Extends is Kind 4.
|
|
87
|
+
private const byte KindCall = 1; // method call: Foo/Methods/a -> Bar/Methods/b
|
|
88
|
+
private const byte KindTypeRef = 2; // names a type: ... -> /Edts/Counter
|
|
89
|
+
private const byte KindImplements = 3; // class -> interface: AbsNettingMarkTransMgr -> INettingMarkTrans
|
|
90
|
+
private const byte KindExtends = 4; // class -> base: _Performance -> RunBaseBatch
|
|
91
|
+
private const byte KindDelegate = 6; // ... -> /Property/IsDelegate
|
|
92
|
+
private const byte KindAttribute = 7; // ... -> /Classes/HookableAttribute
|
|
93
|
+
private const byte KindTag = 9; // ... -> /Tags/<tag>
|
|
94
|
+
private const byte KindOverride = 10; // super(): Foo/Methods/pack -> Base/Methods/pack
|
|
95
|
+
|
|
76
96
|
/// <summary>
|
|
77
|
-
///
|
|
78
|
-
///
|
|
97
|
+
/// Escape the LIKE metacharacters in a literal that is being CONCATENATED into a LIKE
|
|
98
|
+
/// pattern. Parameterising the pattern stops SQL injection but not this: inside a LIKE,
|
|
99
|
+
/// "_" still means "any single character".
|
|
100
|
+
///
|
|
101
|
+
/// That matters here because the D365 CoC naming convention is "<Base>_Extension",
|
|
102
|
+
/// so essentially every class name this service handles contains an underscore, and an
|
|
103
|
+
/// unescaped "/Classes/Foo_Extension/Methods/%" also matches "/Classes/FooXExtension/…".
|
|
104
|
+
/// A census of this xref DB found no name pair that actually collides that way, so this
|
|
105
|
+
/// is closing a latent hole rather than a live miscount — but the patterns are built from
|
|
106
|
+
/// class names and element paths, and nothing stops the next model from shipping the pair.
|
|
107
|
+
///
|
|
108
|
+
/// "[" must be replaced FIRST, or it would go on to escape the brackets introduced by the
|
|
109
|
+
/// other two replacements.
|
|
110
|
+
/// </summary>
|
|
111
|
+
private static string EscapeLike(string literal) =>
|
|
112
|
+
literal.Replace("[", "[[]").Replace("%", "[%]").Replace("_", "[_]");
|
|
113
|
+
|
|
114
|
+
/// <summary>
|
|
115
|
+
/// Last slash-separated segment of an xref path — the member name for a
|
|
116
|
+
/// "/Container/Owner/Methods/name" path. Null when the path has no segments.
|
|
117
|
+
/// </summary>
|
|
118
|
+
private static string? LeafSegment(string path)
|
|
119
|
+
{
|
|
120
|
+
var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
|
121
|
+
return parts.Length > 0 ? parts[parts.Length - 1] : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// <summary>
|
|
125
|
+
/// Categorize a reference from its xref Kind, refining the generic type-reference
|
|
126
|
+
/// kind by what the target actually is. See the Kind constants above for how the
|
|
127
|
+
/// values were established.
|
|
79
128
|
/// </summary>
|
|
80
129
|
private static string CategorizeReference(byte? kind, string sourcePath, string targetPath)
|
|
81
130
|
{
|
|
82
|
-
|
|
131
|
+
switch (kind)
|
|
132
|
+
{
|
|
133
|
+
case KindCall: return "call";
|
|
134
|
+
case KindImplements: return "implements";
|
|
135
|
+
case KindExtends: return "extends";
|
|
136
|
+
case KindDelegate: return "delegate-declaration";
|
|
137
|
+
case KindAttribute: return "attribute";
|
|
138
|
+
case KindTag: return "tag";
|
|
139
|
+
case KindOverride: return "override";
|
|
140
|
+
}
|
|
83
141
|
|
|
84
|
-
//
|
|
142
|
+
// Kind 2 (and anything unrecognised): the source line names the target. Say what
|
|
143
|
+
// kind of naming it is from the target's shape.
|
|
85
144
|
if (targetPath.Contains("/Fields/")) return "field-access";
|
|
86
145
|
|
|
87
|
-
// Check if source path suggests instantiation (heuristic: method referencing a class, not a method)
|
|
88
146
|
var (_, _, targetSeg, _) = ParsePath(targetPath);
|
|
89
|
-
if (targetSeg ==
|
|
90
|
-
|
|
91
|
-
// Target is a class/table itself (not a method/field) — could be type-reference or instantiation
|
|
92
|
-
return "type-reference";
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (targetSeg == "Methods") return "call";
|
|
147
|
+
if (targetSeg == "Methods") return "method-reference";
|
|
148
|
+
if (string.IsNullOrEmpty(targetSeg)) return "type-reference";
|
|
96
149
|
|
|
97
150
|
return "reference";
|
|
98
151
|
}
|
|
@@ -140,14 +193,28 @@ namespace D365MetadataBridge.Services
|
|
|
140
193
|
}
|
|
141
194
|
else
|
|
142
195
|
{
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
196
|
+
// Bare name — we do not know which AOT type it is, so try every container
|
|
197
|
+
// that can be the TARGET of a reference. All of these were verified against a
|
|
198
|
+
// live DYNAMICSXREFDB: the target convention is plural + leading slash, even
|
|
199
|
+
// though SOURCE paths for declarative metadata use the singular, slash-free
|
|
200
|
+
// form ("EdtString/Foo?HelpText") that parseLabelSource() on the TS side
|
|
201
|
+
// handles. In particular an EDT is "/Edts/<name>" — NOT "/EdtString/<name>":
|
|
202
|
+
// the concrete subtype appears only in source paths.
|
|
203
|
+
//
|
|
204
|
+
// Edts/Maps/Reports/MenuItem* were missing here, so a where-used on any of
|
|
205
|
+
// them returned zero rows and the TS caller silently degraded to its
|
|
206
|
+
// name-based index scan. For an EDT that is close to useless, because an
|
|
207
|
+
// EDT's real usages are table fields and form control properties — metadata,
|
|
208
|
+
// not X++ text.
|
|
209
|
+
foreach (var c in new[]
|
|
210
|
+
{
|
|
211
|
+
"Tables", "Classes", "Enums", "Views", "DataEntityViews", "Queries", "Forms",
|
|
212
|
+
"Edts", "Maps", "Reports",
|
|
213
|
+
"MenuItemDisplays", "MenuItemActions", "MenuItemOutputs",
|
|
214
|
+
})
|
|
215
|
+
{
|
|
216
|
+
pathVariants.Add($"/{c}/{objectPath}");
|
|
217
|
+
}
|
|
151
218
|
}
|
|
152
219
|
|
|
153
220
|
// Also add sub-paths (methods, fields) so we catch method-level references.
|
|
@@ -158,7 +225,10 @@ namespace D365MetadataBridge.Services
|
|
|
158
225
|
{
|
|
159
226
|
foreach (var p in pathVariants)
|
|
160
227
|
{
|
|
161
|
-
|
|
228
|
+
// LIKE pattern for children. Only this derived copy is escaped — the
|
|
229
|
+
// variants themselves go into an IN (...) equality list, where "_" is
|
|
230
|
+
// an ordinary character and escaping it would stop them matching.
|
|
231
|
+
extraPaths.Add(EscapeLike(p) + "/%");
|
|
162
232
|
}
|
|
163
233
|
}
|
|
164
234
|
|
|
@@ -247,13 +317,97 @@ namespace D365MetadataBridge.Services
|
|
|
247
317
|
// ============================================================
|
|
248
318
|
|
|
249
319
|
/// <summary>
|
|
250
|
-
///
|
|
251
|
-
///
|
|
252
|
-
///
|
|
320
|
+
/// The attribute a Chain of Command class carries. Stored under this exact path — it is
|
|
321
|
+
/// "ExtensionOf", NOT "ExtensionOfAttribute" like most other attribute names in Names.
|
|
322
|
+
/// </summary>
|
|
323
|
+
private const string ExtensionOfAttributePath = "/Classes/ExtensionOf";
|
|
324
|
+
|
|
325
|
+
/// <summary>
|
|
326
|
+
/// Containers a CoC class extension can be based on. [ExtensionOf] accepts
|
|
327
|
+
/// classStr/tableStr/formStr/viewStr/mapStr/dataEntityViewStr/queryStr, so the base is
|
|
328
|
+
/// by no means always a class — the caller passes a bare name and we resolve which.
|
|
329
|
+
/// </summary>
|
|
330
|
+
private static readonly string[] CocBaseContainers =
|
|
331
|
+
{ "Classes", "Tables", "Forms", "Views", "Maps", "DataEntityViews", "Queries" };
|
|
332
|
+
|
|
333
|
+
/// <summary>
|
|
334
|
+
/// The extended artifact from one [ExtensionOf] declaration: the terminal element of the
|
|
335
|
+
/// prefix chain the intrinsic's arguments produce.
|
|
336
|
+
///
|
|
337
|
+
/// Two details that are load-bearing:
|
|
338
|
+
/// * Comparison is case-INSENSITIVE. The xref stores the same element under inconsistent
|
|
339
|
+
/// casing — "/Forms/PurchTable/DataSources/purchLine" alongside
|
|
340
|
+
/// "/Forms/PurchTable/DataSources/PurchLine/DataFields/PriceUnit" — so an ordinal
|
|
341
|
+
/// StartsWith fails to see the chain and returns the ANCESTOR. That was 3 wrong answers
|
|
342
|
+
/// in 4,696 before this was fixed. The database collation is CI_AS, so SQL already
|
|
343
|
+
/// matches this way and the two layers must agree.
|
|
344
|
+
/// * Targets are bounded to the ExtensionOf attribute's own argument window. 52 declarations
|
|
345
|
+
/// share their line with a second attribute, and that attribute's arguments are references
|
|
346
|
+
/// on the same line. On the corpus measured, none of them changed an answer (the
|
|
347
|
+
/// co-attribute either takes no metadata argument or names the same element), so this is
|
|
348
|
+
/// insurance rather than a live fix — but it is what makes those 52 safe by design.
|
|
349
|
+
///
|
|
350
|
+
/// Returns null when the declaration produced no usable target.
|
|
351
|
+
/// </summary>
|
|
352
|
+
private static string? ResolveExtendedElement(List<(string target, int kind, int col)> refs, int extCol)
|
|
353
|
+
{
|
|
354
|
+
// The next attribute to the right closes ExtensionOf's argument window.
|
|
355
|
+
var limit = int.MaxValue;
|
|
356
|
+
foreach (var (target, kind, col) in refs)
|
|
357
|
+
if (kind == KindAttribute && target != ExtensionOfAttributePath && col > extCol && col < limit)
|
|
358
|
+
limit = col;
|
|
359
|
+
|
|
360
|
+
var scoped = new List<string>();
|
|
361
|
+
foreach (var (target, kind, col) in refs)
|
|
362
|
+
if (kind == KindTypeRef && col > extCol && col < limit && !scoped.Contains(target))
|
|
363
|
+
scoped.Add(target);
|
|
364
|
+
|
|
365
|
+
if (scoped.Count == 0) return null;
|
|
366
|
+
|
|
367
|
+
// Maximum of the prefix order: the one nothing else extends.
|
|
368
|
+
foreach (var candidate in scoped)
|
|
369
|
+
{
|
|
370
|
+
var isPrefixOfAnother = false;
|
|
371
|
+
foreach (var other in scoped)
|
|
372
|
+
{
|
|
373
|
+
if (ReferenceEquals(other, candidate)) continue;
|
|
374
|
+
if (other.StartsWith(candidate + "/", StringComparison.OrdinalIgnoreCase)) { isPrefixOfAnother = true; break; }
|
|
375
|
+
}
|
|
376
|
+
if (!isPrefixOfAnother) return candidate;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Not a chain — shouldn't happen (zero cases in 4,696), so prefer the deepest rather
|
|
380
|
+
// than silently returning an ancestor.
|
|
381
|
+
scoped.Sort((a, b) => b.Length.CompareTo(a.Length));
|
|
382
|
+
return scoped[0];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/// <summary>
|
|
386
|
+
/// True when <paramref name="element"/> IS the requested object, or is nested inside it
|
|
387
|
+
/// (a form's data source, control or data field). Nested hits are kept deliberately: an
|
|
388
|
+
/// agent about to wrap SalesLine.active on a form needs to see it is already wrapped. The
|
|
389
|
+
/// caller groups them by element so they are never pooled with the parent's own count.
|
|
390
|
+
/// </summary>
|
|
391
|
+
private static bool IsRequestedOrNested(string element, string baseName)
|
|
392
|
+
{
|
|
393
|
+
foreach (var container in CocBaseContainers)
|
|
394
|
+
{
|
|
395
|
+
var basePath = $"/{container}/{baseName}";
|
|
396
|
+
if (string.Equals(element, basePath, StringComparison.OrdinalIgnoreCase)) return true;
|
|
397
|
+
if (element.StartsWith(basePath + "/", StringComparison.OrdinalIgnoreCase)) return true;
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// <summary>
|
|
403
|
+
/// Find the Chain of Command extension classes of a given base object, together with
|
|
404
|
+
/// the base methods each one wraps. The base may be a class, table, form, view, map,
|
|
405
|
+
/// data entity or query — [ExtensionOf] accepts all of them.
|
|
253
406
|
/// </summary>
|
|
254
407
|
public object FindExtensionClasses(string baseClassName)
|
|
255
408
|
{
|
|
256
|
-
|
|
409
|
+
// className → (module, the element the class actually extends)
|
|
410
|
+
var extensionClassNames = new Dictionary<string, (string? module, string element)>();
|
|
257
411
|
|
|
258
412
|
try
|
|
259
413
|
{
|
|
@@ -261,29 +415,87 @@ namespace D365MetadataBridge.Services
|
|
|
261
415
|
{
|
|
262
416
|
conn.Open();
|
|
263
417
|
|
|
264
|
-
// Step 1
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
418
|
+
// Step 1 — identify genuine [ExtensionOf] classes and READ what each extends.
|
|
419
|
+
//
|
|
420
|
+
// History: this accepted `r.Kind = 2 OR src.Path LIKE '%_Extension%'`, believing
|
|
421
|
+
// Kind 2 meant DerivedFrom. It does not (see the Kind constants above) — it is
|
|
422
|
+
// the generic type reference, so every class that merely MENTIONED the base was
|
|
423
|
+
// reported as extending it. On SalesFormLetter: 259 reported against 9 real.
|
|
424
|
+
//
|
|
425
|
+
// How the extended element is recovered. `ExtensionOf` takes ONE string
|
|
426
|
+
// (`public void new(str name)`); the multiplicity comes from the INTRINSIC that
|
|
427
|
+
// produces it. While resolving e.g. formDataFieldStr(Form, DataSource, Field)
|
|
428
|
+
// the compiler emits one Kind 2 reference per metadata level it names, and those
|
|
429
|
+
// levels are nested by construction:
|
|
430
|
+
// /Forms/VendOpenTrans
|
|
431
|
+
// /Forms/VendOpenTrans/DataSources/TaxWithholdTrans
|
|
432
|
+
// /Forms/VendOpenTrans/DataSources/TaxWithholdTrans/DataFields/TaxReimbursement_IT
|
|
433
|
+
// So the targets form a PREFIX CHAIN and the extended artifact is its terminal
|
|
434
|
+
// element — the one that is not a proper prefix of any other. That is a maximum
|
|
435
|
+
// of a partial order, not a "longest string" guess, and it was checked rather
|
|
436
|
+
// than assumed: across all 4,696 ExtensionOf classes in a live xref DB the set
|
|
437
|
+
// is a single chain every time (zero ambiguous). Measured element shapes:
|
|
438
|
+
// /Classes/* 2633, /Tables/* 828, /Forms/* 696, /Forms/*/DataSources/* 241,
|
|
439
|
+
// /DataEntityViews/* 170, /Forms/*/Controls/* 77,
|
|
440
|
+
// /Forms/*/DataSources/*/DataFields/* 41, /Maps/* 6, /Views/* 4.
|
|
441
|
+
//
|
|
442
|
+
// Reading the element (rather than matching any declaration-level reference)
|
|
443
|
+
// is what keeps a table CoC apart from a form CoC of the same name, and it drops
|
|
444
|
+
// classes that name the base for some OTHER reason — e.g.
|
|
445
|
+
// SalesCopyingTAMDeduction_Extension extends /Classes/SalesCopying but also
|
|
446
|
+
// references /Tables/SalesTable from a different attribute on another line.
|
|
447
|
+
//
|
|
448
|
+
// Candidate classes are those naming the requested object anywhere in their
|
|
449
|
+
// ExtensionOf declaration; the element test below then decides. `LIKE base + '/%'`
|
|
450
|
+
// admits nested elements (a form's data sources, controls and data fields), which
|
|
451
|
+
// the caller groups and labels separately.
|
|
452
|
+
var whereTargets = new List<string>();
|
|
453
|
+
var sqlParams = new List<(string name, string value)>();
|
|
454
|
+
for (int i = 0; i < CocBaseContainers.Length; i++)
|
|
455
|
+
{
|
|
456
|
+
whereTargets.Add($"cand.Path = @T{i} OR cand.Path LIKE @P{i}");
|
|
457
|
+
// @T is an equality test and takes the name as-is; only @P is a LIKE pattern.
|
|
458
|
+
sqlParams.Add(($"@T{i}", $"/{CocBaseContainers[i]}/{baseClassName}"));
|
|
459
|
+
sqlParams.Add(($"@P{i}", $"/{CocBaseContainers[i]}/{EscapeLike(baseClassName)}/%"));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Returns every Kind 2 target on each candidate's ExtensionOf line, plus the
|
|
463
|
+
// columns needed to bound them to that attribute's own arguments. The chain is
|
|
464
|
+
// resolved in C# because SQL cannot express "maximum of the prefix order"
|
|
465
|
+
// cheaply, and the set per class is tiny (1-3 rows).
|
|
466
|
+
var sql = $@"
|
|
467
|
+
WITH extLine AS (
|
|
468
|
+
SELECT DISTINCT ra.SourceId, ra.Line, ra.[Column] AS ExtCol
|
|
469
|
+
FROM [References] ra
|
|
470
|
+
JOIN [Names] att ON att.Id = ra.TargetId
|
|
471
|
+
WHERE ra.Kind = {KindAttribute} AND att.Path = @ExtensionOfAttr
|
|
472
|
+
),
|
|
473
|
+
candidates AS (
|
|
474
|
+
SELECT DISTINCT e.SourceId, e.Line, e.ExtCol
|
|
475
|
+
FROM extLine e
|
|
476
|
+
JOIN [References] rc ON rc.SourceId = e.SourceId AND rc.Line = e.Line AND rc.Kind = {KindTypeRef}
|
|
477
|
+
JOIN [Names] cand ON cand.Id = rc.TargetId
|
|
478
|
+
WHERE {string.Join(" OR ", whereTargets.Select(w => $"({w})"))}
|
|
278
479
|
)
|
|
279
|
-
|
|
480
|
+
SELECT src.Path, m.Module, tgt.Path, r.Kind, r.[Column]
|
|
481
|
+
FROM candidates c
|
|
482
|
+
JOIN [Names] src ON src.Id = c.SourceId
|
|
483
|
+
LEFT JOIN [Modules] m ON m.Id = src.ModuleId
|
|
484
|
+
JOIN [References] r ON r.SourceId = c.SourceId AND r.Line = c.Line
|
|
485
|
+
AND r.Kind IN ({KindTypeRef}, {KindAttribute})
|
|
486
|
+
JOIN [Names] tgt ON tgt.Id = r.TargetId
|
|
487
|
+
WHERE c.ExtCol = (SELECT MIN(c2.ExtCol) FROM candidates c2 WHERE c2.SourceId = c.SourceId)
|
|
280
488
|
ORDER BY src.Path";
|
|
281
489
|
|
|
490
|
+
// class → (module, extCol, [(target, kind, column)])
|
|
491
|
+
var raw = new Dictionary<string, (string? module, int extCol, List<(string target, int kind, int col)> refs)>();
|
|
492
|
+
|
|
282
493
|
using (var cmd = new SqlCommand(sql, conn))
|
|
283
494
|
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
cmd.Parameters.AddWithValue("@
|
|
495
|
+
foreach (var (name, value) in sqlParams)
|
|
496
|
+
cmd.Parameters.AddWithValue(name, value);
|
|
497
|
+
cmd.Parameters.AddWithValue("@ExtensionOfAttr", ExtensionOfAttributePath);
|
|
498
|
+
cmd.CommandTimeout = 60;
|
|
287
499
|
|
|
288
500
|
using (var reader = cmd.ExecuteReader())
|
|
289
501
|
{
|
|
@@ -293,51 +505,85 @@ namespace D365MetadataBridge.Services
|
|
|
293
505
|
var parts = path.Split('/');
|
|
294
506
|
var className = parts.Length >= 3 ? parts[2] : path;
|
|
295
507
|
var module = reader.IsDBNull(1) ? null : reader.GetString(1);
|
|
508
|
+
var target = reader.GetString(2);
|
|
509
|
+
var kind = reader.IsDBNull(3) ? 0 : reader.GetByte(3);
|
|
510
|
+
var col = reader.IsDBNull(4) ? 0 : (int)reader.GetInt16(4);
|
|
296
511
|
|
|
297
|
-
if (!
|
|
298
|
-
|
|
512
|
+
if (!raw.TryGetValue(className, out var entry))
|
|
513
|
+
{
|
|
514
|
+
entry = (module, int.MaxValue, new List<(string, int, int)>());
|
|
515
|
+
raw[className] = entry;
|
|
516
|
+
}
|
|
517
|
+
entry.refs.Add((target, kind, col));
|
|
518
|
+
// The ExtensionOf attribute's own column anchors the argument window.
|
|
519
|
+
if (kind == KindAttribute && target == ExtensionOfAttributePath && col < entry.extCol)
|
|
520
|
+
entry = (entry.module, col, entry.refs);
|
|
521
|
+
raw[className] = entry;
|
|
299
522
|
}
|
|
300
523
|
}
|
|
301
524
|
}
|
|
302
525
|
|
|
303
|
-
|
|
304
|
-
|
|
526
|
+
foreach (var kv in raw)
|
|
527
|
+
{
|
|
528
|
+
var element = ResolveExtendedElement(kv.Value.refs, kv.Value.extCol);
|
|
529
|
+
if (element == null) continue;
|
|
530
|
+
// Keep only classes whose ELEMENT is the requested object or nested inside
|
|
531
|
+
// it. This is what discards a class that named the base incidentally.
|
|
532
|
+
if (!IsRequestedOrNested(element, baseClassName)) continue;
|
|
533
|
+
extensionClassNames[kv.Key] = (kv.Value.module, element);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Step 2 — which base methods each extension actually WRAPS.
|
|
537
|
+
//
|
|
538
|
+
// A CoC wrap shows up as a Kind 1 (call) from the extension's method to the
|
|
539
|
+
// base method OF THE SAME NAME — that call is the `next`. The previous query
|
|
540
|
+
// filtered on neither the kind nor the name, so it collected every base
|
|
541
|
+
// method the class happened to call: that is why non-extensions came back
|
|
542
|
+
// claiming to wrap "construct, update", which were merely the calls they made.
|
|
305
543
|
var results = new List<ExtensionClassDetailModel>();
|
|
306
544
|
|
|
307
545
|
foreach (var kvp in extensionClassNames)
|
|
308
546
|
{
|
|
309
547
|
var extClassName = kvp.Key;
|
|
310
|
-
var module = kvp.Value;
|
|
548
|
+
var (module, element) = kvp.Value;
|
|
311
549
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
var methodSql = @"
|
|
315
|
-
SELECT DISTINCT tgt.Path
|
|
550
|
+
var methodSql = $@"
|
|
551
|
+
SELECT DISTINCT src.Path, tgt.Path
|
|
316
552
|
FROM [References] r
|
|
317
553
|
JOIN [Names] src ON r.SourceId = src.Id
|
|
318
554
|
JOIN [Names] tgt ON r.TargetId = tgt.Id
|
|
319
555
|
WHERE src.Path LIKE @ExtClassMethods
|
|
320
|
-
|
|
556
|
+
AND tgt.Path LIKE @BaseClassMethods
|
|
557
|
+
AND r.Kind = {KindCall}";
|
|
321
558
|
|
|
322
559
|
var wrappedMethods = new List<string>();
|
|
323
560
|
|
|
324
561
|
using (var cmd2 = new SqlCommand(methodSql, conn))
|
|
325
562
|
{
|
|
326
|
-
|
|
327
|
-
|
|
563
|
+
// Both patterns are escaped: "_Extension" is the CoC naming convention, so
|
|
564
|
+
// extClassName almost always carries an underscore, and an element path
|
|
565
|
+
// can too ("/Forms/…/DataFields/TaxReimbursement_IT").
|
|
566
|
+
cmd2.Parameters.AddWithValue("@ExtClassMethods", $"/Classes/{EscapeLike(extClassName)}/Methods/%");
|
|
567
|
+
// Anchored to the EXTENDED ELEMENT, not to the requested object. That is
|
|
568
|
+
// the whole payoff of reading the element: a form CoC wraps a method on
|
|
569
|
+
// one specific data source ("/Forms/SalesTable/DataSources/SalesLine/
|
|
570
|
+
// Methods/active"), and the SalesTable form has NINE data sources with an
|
|
571
|
+
// `active` method. Scoping to the base and matching leaf names alone could
|
|
572
|
+
// not tell them apart; scoping to the element makes the question exact.
|
|
573
|
+
cmd2.Parameters.AddWithValue("@BaseClassMethods", $"{EscapeLike(element)}/Methods/%");
|
|
574
|
+
cmd2.CommandTimeout = 60;
|
|
328
575
|
|
|
329
576
|
using (var reader2 = cmd2.ExecuteReader())
|
|
330
577
|
{
|
|
331
578
|
while (reader2.Read())
|
|
332
579
|
{
|
|
333
|
-
|
|
334
|
-
var
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
580
|
+
// Same leaf name on both sides = the `next` call.
|
|
581
|
+
var srcLeaf = LeafSegment(reader2.GetString(0));
|
|
582
|
+
var tgtLeaf = LeafSegment(reader2.GetString(1));
|
|
583
|
+
if (srcLeaf == null || tgtLeaf == null) continue;
|
|
584
|
+
if (!string.Equals(srcLeaf, tgtLeaf, StringComparison.OrdinalIgnoreCase)) continue;
|
|
585
|
+
if (!wrappedMethods.Contains(tgtLeaf))
|
|
586
|
+
wrappedMethods.Add(tgtLeaf);
|
|
341
587
|
}
|
|
342
588
|
}
|
|
343
589
|
}
|
|
@@ -346,10 +592,20 @@ namespace D365MetadataBridge.Services
|
|
|
346
592
|
{
|
|
347
593
|
ClassName = extClassName,
|
|
348
594
|
Module = module,
|
|
595
|
+
ExtendedElement = element,
|
|
349
596
|
WrappedMethods = wrappedMethods,
|
|
350
597
|
});
|
|
351
598
|
}
|
|
352
599
|
|
|
600
|
+
// Ordered so the caller's grouping is stable and the requested object's own
|
|
601
|
+
// extensions lead, with nested elements (data sources, controls, data fields)
|
|
602
|
+
// following in path order.
|
|
603
|
+
results.Sort((a, b) =>
|
|
604
|
+
{
|
|
605
|
+
var byElement = string.Compare(a.ExtendedElement, b.ExtendedElement, StringComparison.OrdinalIgnoreCase);
|
|
606
|
+
return byElement != 0 ? byElement : string.Compare(a.ClassName, b.ClassName, StringComparison.OrdinalIgnoreCase);
|
|
607
|
+
});
|
|
608
|
+
|
|
353
609
|
return new
|
|
354
610
|
{
|
|
355
611
|
baseClassName,
|
|
@@ -409,10 +665,14 @@ namespace D365MetadataBridge.Services
|
|
|
409
665
|
|
|
410
666
|
using (var cmd = new SqlCommand(sql, conn))
|
|
411
667
|
{
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
668
|
+
// All four are compared with LIKE (see the WHERE above), so all four
|
|
669
|
+
// are escaped — including the two with no trailing wildcard, where an
|
|
670
|
+
// unescaped "_" would still match any single character.
|
|
671
|
+
var escTarget = EscapeLike(targetName);
|
|
672
|
+
cmd.Parameters.AddWithValue("@TargetTable", $"/Tables/{escTarget}");
|
|
673
|
+
cmd.Parameters.AddWithValue("@TargetTablePath", $"/Tables/{escTarget}/%");
|
|
674
|
+
cmd.Parameters.AddWithValue("@TargetClass", $"/Classes/{escTarget}");
|
|
675
|
+
cmd.Parameters.AddWithValue("@TargetClassPath", $"/Classes/{escTarget}/%");
|
|
416
676
|
|
|
417
677
|
using (var reader = cmd.ExecuteReader())
|
|
418
678
|
{
|
|
@@ -525,10 +785,12 @@ namespace D365MetadataBridge.Services
|
|
|
525
785
|
}
|
|
526
786
|
else
|
|
527
787
|
{
|
|
788
|
+
// The "/%" children are LIKE patterns and get escaped; the bare paths are
|
|
789
|
+
// dispatched to "=" below, where "_" is an ordinary character.
|
|
528
790
|
pathVariants.Add($"/Classes/{apiName}");
|
|
529
|
-
pathVariants.Add($"/Classes/{apiName}/%");
|
|
791
|
+
pathVariants.Add($"/Classes/{EscapeLike(apiName)}/%");
|
|
530
792
|
pathVariants.Add($"/Tables/{apiName}");
|
|
531
|
-
pathVariants.Add($"/Tables/{apiName}/%");
|
|
793
|
+
pathVariants.Add($"/Tables/{EscapeLike(apiName)}/%");
|
|
532
794
|
}
|
|
533
795
|
|
|
534
796
|
// Build WHERE clause
|
|
@@ -3483,14 +3483,11 @@ namespace D365MetadataBridge.Services
|
|
|
3483
3483
|
?? throw new ArgumentException($"Form extension '{objectName}' not found");
|
|
3484
3484
|
var msi = GetModelSaveInfoForObject(_provider.FormExtensions, objectName);
|
|
3485
3485
|
|
|
3486
|
-
// Same idempotency rule as the form branch below: skip on a
|
|
3487
|
-
// and on a different-named data source already bound to the same table.
|
|
3486
|
+
// Same idempotency rule as the form branch below: skip on a NAME match only.
|
|
3488
3487
|
foreach (AxFormDataSourceRoot existing in axExt.DataSources)
|
|
3489
3488
|
{
|
|
3490
3489
|
if (string.Equals(existing.Name, dsName, StringComparison.OrdinalIgnoreCase))
|
|
3491
3490
|
return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{dsName}' already exists", api = "IMetaFormExtensionProvider.Update" };
|
|
3492
|
-
if (string.Equals(existing.Table, table, StringComparison.OrdinalIgnoreCase))
|
|
3493
|
-
return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{existing.Name}' already binds table '{table}'", api = "IMetaFormExtensionProvider.Update" };
|
|
3494
3491
|
}
|
|
3495
3492
|
|
|
3496
3493
|
axExt.DataSources.Add((AxFormDataSourceRoot)CreateFormDataSourceRoot(dsName, table, joinSource, linkType));
|
|
@@ -3506,18 +3503,23 @@ namespace D365MetadataBridge.Services
|
|
|
3506
3503
|
?? throw new ArgumentException($"Form '{objectName}' not found");
|
|
3507
3504
|
var msi = GetModelSaveInfoForObject(_provider.Forms, objectName);
|
|
3508
3505
|
|
|
3509
|
-
// Idempotency: don't append a duplicate.
|
|
3510
|
-
//
|
|
3511
|
-
// right table)
|
|
3512
|
-
//
|
|
3513
|
-
//
|
|
3506
|
+
// Idempotency: don't append a duplicate. A data source with the same NAME
|
|
3507
|
+
// already there is a real conflict (it may be a template stub already bound
|
|
3508
|
+
// to the right table), so skip that one.
|
|
3509
|
+
//
|
|
3510
|
+
// A different-named data source already bound to the same TABLE is NOT a
|
|
3511
|
+
// conflict and must still be written. This used to skip too, on the theory
|
|
3512
|
+
// that "adding a second binding to the same table is almost always an
|
|
3513
|
+
// accident" — which is simply untrue: a form routinely joins one table into
|
|
3514
|
+
// several branches of its query. Microsoft's own PurchLineBackOrder carries
|
|
3515
|
+
// PurchTable and PurchTable1, both over PurchTable, with different link
|
|
3516
|
+
// types. The rule silently dropped three legitimate data sources and, because
|
|
3517
|
+
// `skipped` never reached the caller, reported all three as added.
|
|
3514
3518
|
foreach (var existing in axForm.DataSources)
|
|
3515
3519
|
{
|
|
3516
3520
|
dynamic dyn = existing;
|
|
3517
3521
|
if (string.Equals((string)dyn.Name, dsName, StringComparison.OrdinalIgnoreCase))
|
|
3518
3522
|
return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{dsName}' already exists", api = "IMetaFormProvider.Update" };
|
|
3519
|
-
if (string.Equals((string)dyn.Table, table, StringComparison.OrdinalIgnoreCase))
|
|
3520
|
-
return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{(string)dyn.Name}' already binds table '{table}'", api = "IMetaFormProvider.Update" };
|
|
3521
3523
|
}
|
|
3522
3524
|
|
|
3523
3525
|
axForm.AddDataSource(CreateFormDataSourceRoot(dsName, table, joinSource, linkType));
|